Dynamically adjusting layout to maximize screen space used for document previews. Row width changes with aspect ratio of items on that row. Server side tracking of size as part of the main listing.
This commit is contained in:
+8
-1
@@ -20,7 +20,8 @@ from sanic import Blueprint, empty, raw, redirect
|
|||||||
from sanic.exceptions import NotFound
|
from sanic.exceptions import NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import auth, config, onlyoffice, sharefs
|
from cista import auth, config, onlyoffice, sharefs, watching
|
||||||
|
from cista.fileio import fuid
|
||||||
from cista.preview_worker import (
|
from cista.preview_worker import (
|
||||||
DOC_PREVIEW_SUFFIXES,
|
DOC_PREVIEW_SUFFIXES,
|
||||||
OFFICE_PREVIEW_SUFFIXES,
|
OFFICE_PREVIEW_SUFFIXES,
|
||||||
@@ -656,6 +657,12 @@ async def preview(req, path):
|
|||||||
# Preview generation failed, redirect to the file itself
|
# Preview generation failed, redirect to the file itself
|
||||||
return redirect(f"/files/{path}", status=303)
|
return redirect(f"/files/{path}", status=303)
|
||||||
|
|
||||||
|
# Store aspect ratio if the worker returned dimensions
|
||||||
|
if preview_resp and preview_resp.width and preview_resp.height:
|
||||||
|
ar = round(preview_resp.height / preview_resp.width, 2)
|
||||||
|
fuid_str = fuid(stat)
|
||||||
|
watching.notify_ar(fuid_str, ar)
|
||||||
|
|
||||||
# Build headers and cache the full response
|
# Build headers and cache the full response
|
||||||
preview_mime = (
|
preview_mime = (
|
||||||
preview_resp.mime
|
preview_resp.mime
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
|||||||
timings: list[float] | None = None
|
timings: list[float] | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
stderr: str | None = None
|
stderr: str | None = None
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
|
||||||
|
|
||||||
_enc = msgspec.json.Encoder()
|
_enc = msgspec.json.Encoder()
|
||||||
@@ -173,6 +175,7 @@ def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
img = pyvips.Image.new_from_file(str(path))
|
img = pyvips.Image.new_from_file(str(path))
|
||||||
|
img = img.autorot()
|
||||||
except pyvips.error.Error:
|
except pyvips.error.Error:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
@@ -228,6 +231,8 @@ def process_image_pyvips(path, *, maxsize, quality):
|
|||||||
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
|
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
|
||||||
# skip pyvips entirely.
|
# skip pyvips entirely.
|
||||||
if suffix in (".heic", ".heif"):
|
if suffix in (".heic", ".heif"):
|
||||||
|
heic_dims = _get_image_dimensions(path)
|
||||||
|
width, height = heic_dims or (None, None)
|
||||||
ret = _image_via_ffmpeg(path, maxsize, quality)
|
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||||
t_end = perf_counter()
|
t_end = perf_counter()
|
||||||
return ret, PreviewResponse(
|
return ret, PreviewResponse(
|
||||||
@@ -235,13 +240,17 @@ def process_image_pyvips(path, *, maxsize, quality):
|
|||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend="ffmpeg",
|
backend="ffmpeg",
|
||||||
timings=[round((t_end - t_start) * 1000, 1)],
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Other image formats: pyvips first, ffmpeg fallback.
|
# Other image formats: pyvips first, ffmpeg fallback.
|
||||||
load_opts = {"access": "sequential"}
|
load_opts = {"access": "sequential"}
|
||||||
|
orig_w = orig_h = None
|
||||||
try:
|
try:
|
||||||
img = pyvips.Image.new_from_file(str(path), **load_opts)
|
img = pyvips.Image.new_from_file(str(path), **load_opts)
|
||||||
img = img.autorot()
|
img = img.autorot()
|
||||||
|
orig_w, orig_h = img.width, img.height
|
||||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
if scale < 1.0:
|
if scale < 1.0:
|
||||||
img = img.resize(scale)
|
img = img.resize(scale)
|
||||||
@@ -253,6 +262,7 @@ def process_image_pyvips(path, *, maxsize, quality):
|
|||||||
)
|
)
|
||||||
backend = "pyvips"
|
backend = "pyvips"
|
||||||
except pyvips.error.Error:
|
except pyvips.error.Error:
|
||||||
|
orig_w, orig_h = None, None
|
||||||
ret = _image_via_ffmpeg(path, maxsize, quality)
|
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||||
backend = "ffmpeg"
|
backend = "ffmpeg"
|
||||||
t_end = perf_counter()
|
t_end = perf_counter()
|
||||||
@@ -262,6 +272,8 @@ def process_image_pyvips(path, *, maxsize, quality):
|
|||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend=backend,
|
backend=backend,
|
||||||
timings=[round((t_end - t_start) * 1000, 1)],
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
width=orig_w,
|
||||||
|
height=orig_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -270,6 +282,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
|||||||
t_start = perf_counter()
|
t_start = perf_counter()
|
||||||
img = pyvips.Image.new_from_buffer(data, "")
|
img = pyvips.Image.new_from_buffer(data, "")
|
||||||
img = img.autorot()
|
img = img.autorot()
|
||||||
|
orig_w, orig_h = img.width, img.height
|
||||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
if scale < 1.0:
|
if scale < 1.0:
|
||||||
img = img.resize(scale)
|
img = img.resize(scale)
|
||||||
@@ -286,6 +299,8 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
|||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend="pyvips",
|
backend="pyvips",
|
||||||
timings=[round((t_end - t_start) * 1000, 1)],
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
width=orig_w,
|
||||||
|
height=orig_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -315,6 +330,8 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
|||||||
round((t_load_end - t_load_start) * 1000, 1),
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
round((t_save_end - t_save_start) * 1000, 1),
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
],
|
],
|
||||||
|
width=round(w),
|
||||||
|
height=round(h),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -347,6 +364,11 @@ def process_video(path, *, maxsize, quality):
|
|||||||
raise RuntimeError("No frames found in video")
|
raise RuntimeError("No frames found in video")
|
||||||
|
|
||||||
# Resize frame to thumbnail size
|
# Resize frame to thumbnail size
|
||||||
|
# Capture display dimensions before resize (accounting for rotation)
|
||||||
|
disp_w = frame.width
|
||||||
|
disp_h = frame.height
|
||||||
|
if frame.rotation in (90, 270):
|
||||||
|
disp_w, disp_h = disp_h, disp_w
|
||||||
if frame.width > maxsize or frame.height > maxsize:
|
if frame.width > maxsize or frame.height > maxsize:
|
||||||
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||||
new_width = int(frame.width * scale_factor)
|
new_width = int(frame.width * scale_factor)
|
||||||
@@ -442,6 +464,8 @@ def process_video(path, *, maxsize, quality):
|
|||||||
round((t_load_end - t_load_start) * 1000, 1),
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
round((t_save_end - t_save_start) * 1000, 1),
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
],
|
],
|
||||||
|
width=disp_w,
|
||||||
|
height=disp_h,
|
||||||
)
|
)
|
||||||
del imgdata, istream, ostream, icc, occ, frame
|
del imgdata, istream, ostream, icc, occ, frame
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,7 @@ class ErrorMsg(msgspec.Struct):
|
|||||||
## Directory listings
|
## Directory listings
|
||||||
|
|
||||||
|
|
||||||
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
class FileEntry(msgspec.Struct, array_like=True, frozen=True, omit_defaults=True):
|
||||||
level: int
|
level: int
|
||||||
name: str
|
name: str
|
||||||
key: str
|
key: str
|
||||||
@@ -20,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
|||||||
size: int
|
size: int
|
||||||
allocated: int
|
allocated: int
|
||||||
isfile: int
|
isfile: int
|
||||||
|
ar: float | None = None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.key or "FileEntry()"
|
return self.key or "FileEntry()"
|
||||||
|
|||||||
+51
-1
@@ -154,6 +154,17 @@ stop_event = threading.Event()
|
|||||||
# Thread-safe queue for signaling path updates from websockets
|
# Thread-safe queue for signaling path updates from websockets
|
||||||
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
||||||
|
|
||||||
|
# Thread-safe queue for AR updates from the preview worker
|
||||||
|
_ar_queue: queue.Queue[tuple[str, float]] = queue.Queue()
|
||||||
|
|
||||||
|
# AR map: fuid -> aspect ratio (height/width). Written only by the watcher thread.
|
||||||
|
_ar_map: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def notify_ar(fuid_key: str, ar: float) -> None:
|
||||||
|
"""Called from preview handler to update the AR for a file."""
|
||||||
|
_ar_queue.put_nowait((fuid_key, ar))
|
||||||
|
|
||||||
|
|
||||||
def notify_change(*paths: PurePosixPath | str):
|
def notify_change(*paths: PurePosixPath | str):
|
||||||
"""Signal that paths have changed. Called from control/upload websockets."""
|
"""Signal that paths have changed. Called from control/upload websockets."""
|
||||||
@@ -186,14 +197,16 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"get_allocated_size failed for {path}")
|
logger.exception(f"get_allocated_size failed for {path}")
|
||||||
allocated = st.st_size if isfile else 0
|
allocated = st.st_size if isfile else 0
|
||||||
|
key = fuid(st)
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=len(rel.parts),
|
level=len(rel.parts),
|
||||||
name=rel.name,
|
name=rel.name,
|
||||||
key=fuid(st),
|
key=key,
|
||||||
mtime=int(st.st_mtime),
|
mtime=int(st.st_mtime),
|
||||||
size=st.st_size if isfile else 0,
|
size=st.st_size if isfile else 0,
|
||||||
allocated=allocated,
|
allocated=allocated,
|
||||||
isfile=isfile,
|
isfile=isfile,
|
||||||
|
ar=_ar_map.get(key) if isfile else None,
|
||||||
)
|
)
|
||||||
if isfile:
|
if isfile:
|
||||||
return [entry]
|
return [entry]
|
||||||
@@ -775,6 +788,43 @@ def watcher(loop):
|
|||||||
broadcast(format_root(fresh), loop)
|
broadcast(format_root(fresh), loop)
|
||||||
state.root = fresh
|
state.root = fresh
|
||||||
|
|
||||||
|
# Drain AR updates from preview worker (immediate, no debounce)
|
||||||
|
ar_new_root: list[FileEntry] | None = None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
fuid_key, ar = _ar_queue.get_nowait()
|
||||||
|
_ar_map[fuid_key] = ar
|
||||||
|
# Patch the matching entry in the current root
|
||||||
|
root_to_patch = (
|
||||||
|
ar_new_root if ar_new_root is not None else path_index.root
|
||||||
|
)
|
||||||
|
for i, entry in enumerate(root_to_patch):
|
||||||
|
if entry.key == fuid_key and entry.isfile and entry.ar != ar:
|
||||||
|
if ar_new_root is None:
|
||||||
|
ar_new_root = root_to_patch[:]
|
||||||
|
ar_new_root[i] = FileEntry(
|
||||||
|
level=entry.level,
|
||||||
|
name=entry.name,
|
||||||
|
key=entry.key,
|
||||||
|
mtime=entry.mtime,
|
||||||
|
size=entry.size,
|
||||||
|
allocated=entry.allocated,
|
||||||
|
isfile=entry.isfile,
|
||||||
|
ar=ar,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
if ar_new_root is not None:
|
||||||
|
try:
|
||||||
|
update_msg = format_update(state.root, ar_new_root)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update_msg, loop)
|
||||||
|
state.root = ar_new_root
|
||||||
|
path_index = PathIndex(ar_new_root)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("AR update broadcast failed")
|
||||||
|
|
||||||
# Collect events from websocket signals (non-blocking)
|
# Collect events from websocket signals (non-blocking)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
||||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||||
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
|
<GalleryFigure
|
||||||
|
:doc=doc
|
||||||
|
:editing="editing === doc ? {rename, exit} : null"
|
||||||
|
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
||||||
|
@menu="contextMenu($event, doc)"
|
||||||
|
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -21,6 +27,7 @@ import {
|
|||||||
onUnmounted,
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
|
watch,
|
||||||
watchEffect
|
watchEffect
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -71,11 +78,108 @@ const rename = async (doc: Doc, newName: string) => {
|
|||||||
}
|
}
|
||||||
const gallery = ref<HTMLElement>()
|
const gallery = ref<HTMLElement>()
|
||||||
const columnCount = ref(1)
|
const columnCount = ref(1)
|
||||||
|
const columnWidthPx = ref(240)
|
||||||
|
const emPx = ref(16)
|
||||||
|
const aspectByKey = ref<Record<string, number>>({})
|
||||||
|
|
||||||
|
const optimalRowHeightPx = (ratios: number[]) => {
|
||||||
|
const w = Math.max(1, columnWidthPx.value)
|
||||||
|
const minH = Math.max(1, Math.round(7 * emPx.value))
|
||||||
|
const maxH = Math.max(minH, Math.round(25 * emPx.value))
|
||||||
|
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
|
||||||
|
if (usable.length === 0) return Math.round(15 * emPx.value)
|
||||||
|
|
||||||
|
let bestH = Math.round(15 * emPx.value)
|
||||||
|
let bestScore = -1
|
||||||
|
for (let h = minH; h <= maxH; h++) {
|
||||||
|
let score = 0
|
||||||
|
for (const ar of usable) {
|
||||||
|
let shownW = w
|
||||||
|
let shownH = w * ar
|
||||||
|
if (shownH > h) {
|
||||||
|
shownH = h
|
||||||
|
shownW = h / ar
|
||||||
|
}
|
||||||
|
// Fill efficiency in the row cell (0..1)
|
||||||
|
score += (shownW * shownH) / (w * h)
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestH = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestH
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAspect = (key: string, ar: number) => {
|
||||||
|
if (!Number.isFinite(ar) || ar <= 0) return
|
||||||
|
if (aspectByKey.value[key] === ar) return
|
||||||
|
aspectByKey.value = {
|
||||||
|
...aspectByKey.value,
|
||||||
|
[key]: ar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowHeightsByKey = computed<Record<string, string>>(() => {
|
||||||
|
const docs = props.documents
|
||||||
|
const cols = Math.max(1, columnCount.value)
|
||||||
|
const byKey = aspectByKey.value
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
|
||||||
|
const assignRows = (group: Doc[]) => {
|
||||||
|
for (let start = 0; start < group.length; start += cols) {
|
||||||
|
const row = group.slice(start, start + cols)
|
||||||
|
const ratios = row
|
||||||
|
.filter(doc => doc.previewable)
|
||||||
|
.map(doc => byKey[doc.key])
|
||||||
|
.filter((ar): ar is number => ar != null)
|
||||||
|
const height = `${optimalRowHeightPx(ratios)}px`
|
||||||
|
for (const doc of row) out[doc.key] = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let group: Doc[] = []
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) {
|
||||||
|
assignRows(group)
|
||||||
|
group = []
|
||||||
|
}
|
||||||
|
group.push(docs[i]!)
|
||||||
|
}
|
||||||
|
assignRows(group)
|
||||||
|
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
// Seed collected ratios from server-provided ar values on docs
|
||||||
|
const seedFromDocs = () => {
|
||||||
|
for (const doc of props.documents)
|
||||||
|
if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImgLoad = (e: Event) => {
|
||||||
|
const img = e.target as HTMLImageElement
|
||||||
|
if (img.tagName !== 'IMG' || img.naturalWidth === 0) return
|
||||||
|
const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null
|
||||||
|
if (!anchor) return
|
||||||
|
const key = anchor.id.slice('file-'.length)
|
||||||
|
if (!key) return
|
||||||
|
setAspect(key, img.naturalHeight / img.naturalWidth)
|
||||||
|
}
|
||||||
const updateColumns = () => {
|
const updateColumns = () => {
|
||||||
if (!gallery.value) return
|
if (!gallery.value) return
|
||||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
|
const style = getComputedStyle(gallery.value)
|
||||||
' '
|
const templates = style.gridTemplateColumns
|
||||||
).length
|
.split(' ')
|
||||||
|
.filter(part => !!part && part !== 'none')
|
||||||
|
columnCount.value = Math.max(1, templates.length)
|
||||||
|
const first = templates[0]
|
||||||
|
if (first && first.endsWith('px')) {
|
||||||
|
const parsed = Number.parseFloat(first)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed
|
||||||
|
}
|
||||||
|
const parsedEm = Number.parseFloat(style.fontSize)
|
||||||
|
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
|
||||||
}
|
}
|
||||||
const columns = computed(() => columnCount.value)
|
const columns = computed(() => columnCount.value)
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -230,14 +334,20 @@ onMounted(() => {
|
|||||||
active.focus()
|
active.focus()
|
||||||
}
|
}
|
||||||
updateColumns()
|
updateColumns()
|
||||||
|
seedFromDocs()
|
||||||
if (gallery.value) {
|
if (gallery.value) {
|
||||||
resizeObserver = new ResizeObserver(updateColumns)
|
resizeObserver = new ResizeObserver(updateColumns)
|
||||||
resizeObserver.observe(gallery.value)
|
resizeObserver.observe(gallery.value)
|
||||||
|
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
|
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
||||||
|
watch(() => props.documents, seedFromDocs)
|
||||||
const mkdir = async (doc: Doc, name: string) => {
|
const mkdir = async (doc: Doc, name: string) => {
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ const onclick = (ev: Event) => {
|
|||||||
margin-left: 0.3em;
|
margin-left: 0.3em;
|
||||||
}
|
}
|
||||||
figure {
|
figure {
|
||||||
height: 15em;
|
height: var(--gallery-figure-height, 15em);
|
||||||
max-height: 15em;
|
max-height: var(--gallery-figure-height, 15em);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: .5em;
|
border-radius: .5em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -93,12 +93,13 @@ figure {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: height 0.4s ease, max-height 0.4s ease;
|
||||||
}
|
}
|
||||||
figure > article {
|
figure > article {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
figure :deep(.video-container) {
|
figure :deep(.video-container) {
|
||||||
height: 15em;
|
height: var(--gallery-figure-height, 15em);
|
||||||
}
|
}
|
||||||
.titlespacer {
|
.titlespacer {
|
||||||
flex-shrink: 100000;
|
flex-shrink: 100000;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type DocProps = {
|
|||||||
dir: boolean
|
dir: boolean
|
||||||
ghost?: boolean
|
ghost?: boolean
|
||||||
expires?: number // Unix timestamp for ghost expiry
|
expires?: number // Unix timestamp for ghost expiry
|
||||||
|
ar?: number // Aspect ratio (height/width) from server, if known
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
@@ -26,6 +27,7 @@ export class Doc {
|
|||||||
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||||
/** @internal Use the name getter/setter instead */
|
/** @internal Use the name getter/setter instead */
|
||||||
public _name: string = ''
|
public _name: string = ''
|
||||||
|
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
|
||||||
|
|
||||||
constructor(props: Partial<DocProps> = {}) {
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
const { name, ...rest } = props
|
const { name, ...rest } = props
|
||||||
@@ -130,7 +132,8 @@ export type FileEntry = [
|
|||||||
number, // mtime
|
number, // mtime
|
||||||
number, // size
|
number, // size
|
||||||
number, // allocated (actual disk usage)
|
number, // allocated (actual disk usage)
|
||||||
number // isfile
|
number, // isfile
|
||||||
|
number? // ar: aspect ratio (height/width), present if known
|
||||||
]
|
]
|
||||||
|
|
||||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => {
|
|||||||
case !!msg.update:
|
case !!msg.update:
|
||||||
handleUpdateMessage(msg)
|
handleUpdateMessage(msg)
|
||||||
break
|
break
|
||||||
|
case !!msg.ar: {
|
||||||
|
const store = useMainStore()
|
||||||
|
store.updateAr(msg.ar as Record<string, number>)
|
||||||
|
break
|
||||||
|
}
|
||||||
case !!msg.space:
|
case !!msg.space:
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
store.space = msg.space
|
store.space = msg.space
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { collator } from '@/utils'
|
|||||||
import { type SortOrder, sorted } from '@/utils/docsort'
|
import { type SortOrder, sorted } from '@/utils/docsort'
|
||||||
import SearchWorker from '@/workers/searchWorker?worker'
|
import SearchWorker from '@/workers/searchWorker?worker'
|
||||||
import { type StateTree, defineStore } from 'pinia'
|
import { type StateTree, defineStore } from 'pinia'
|
||||||
import { documentRef, getDocuments, setDocuments } from './documentStore'
|
import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
|
||||||
|
|
||||||
// Singleton search worker instance
|
// Singleton search worker instance
|
||||||
let searchWorker: Worker | null = null
|
let searchWorker: Worker | null = null
|
||||||
@@ -124,7 +124,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
const docs = []
|
const docs = []
|
||||||
let loc = [] as string[]
|
let loc = [] as string[]
|
||||||
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
for (const [level, name, key, mtime, size, allocated, isfile, ar] of root) {
|
||||||
loc = loc.slice(0, level - 1)
|
loc = loc.slice(0, level - 1)
|
||||||
docs.push(
|
docs.push(
|
||||||
new Doc({
|
new Doc({
|
||||||
@@ -134,7 +134,8 @@ export const useMainStore = defineStore('main', {
|
|||||||
size,
|
size,
|
||||||
allocated,
|
allocated,
|
||||||
mtime,
|
mtime,
|
||||||
dir: !isfile
|
dir: !isfile,
|
||||||
|
ar
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
loc.push(name)
|
loc.push(name)
|
||||||
@@ -157,6 +158,22 @@ export const useMainStore = defineStore('main', {
|
|||||||
// Sync documents to search worker
|
// Sync documents to search worker
|
||||||
this.syncSearchWorker()
|
this.syncSearchWorker()
|
||||||
},
|
},
|
||||||
|
/** Patch aspect ratios on existing docs from a server ar update message */
|
||||||
|
updateAr(arMap: Record<string, number>) {
|
||||||
|
const docs = getDocuments()
|
||||||
|
let changed = false
|
||||||
|
for (const doc of docs) {
|
||||||
|
const ar = arMap[doc.key]
|
||||||
|
if (ar != null && doc.ar !== ar) {
|
||||||
|
doc.ar = ar
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
triggerUpdate()
|
||||||
|
this.docVersion++
|
||||||
|
}
|
||||||
|
},
|
||||||
/** Add a ghost file/folder for optimistic UI updates */
|
/** Add a ghost file/folder for optimistic UI updates */
|
||||||
addGhost(doc: Doc) {
|
addGhost(doc: Doc) {
|
||||||
doc.ghost = true
|
doc.ghost = true
|
||||||
|
|||||||
Reference in New Issue
Block a user