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:
2026-05-02 18:45:44 +00:00
parent 0071058b29
commit c7ba0d5a04
9 changed files with 232 additions and 14 deletions
+8 -1
View File
@@ -20,7 +20,8 @@ from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound
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 (
DOC_PREVIEW_SUFFIXES,
OFFICE_PREVIEW_SUFFIXES,
@@ -656,6 +657,12 @@ async def preview(req, path):
# Preview generation failed, redirect to the file itself
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
preview_mime = (
preview_resp.mime
+24
View File
@@ -95,6 +95,8 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
timings: list[float] | None = None
error: str | None = None
stderr: str | None = None
width: int | None = None
height: int | None = None
_enc = msgspec.json.Encoder()
@@ -173,6 +175,7 @@ def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
"""
try:
img = pyvips.Image.new_from_file(str(path))
img = img.autorot()
except pyvips.error.Error:
return None
else:
@@ -228,6 +231,8 @@ def process_image_pyvips(path, *, maxsize, quality):
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
# skip pyvips entirely.
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)
t_end = perf_counter()
return ret, PreviewResponse(
@@ -235,13 +240,17 @@ def process_image_pyvips(path, *, maxsize, quality):
mime="image/avif",
backend="ffmpeg",
timings=[round((t_end - t_start) * 1000, 1)],
width=width,
height=height,
)
# Other image formats: pyvips first, ffmpeg fallback.
load_opts = {"access": "sequential"}
orig_w = orig_h = None
try:
img = pyvips.Image.new_from_file(str(path), **load_opts)
img = img.autorot()
orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0:
img = img.resize(scale)
@@ -253,6 +262,7 @@ def process_image_pyvips(path, *, maxsize, quality):
)
backend = "pyvips"
except pyvips.error.Error:
orig_w, orig_h = None, None
ret = _image_via_ffmpeg(path, maxsize, quality)
backend = "ffmpeg"
t_end = perf_counter()
@@ -262,6 +272,8 @@ def process_image_pyvips(path, *, maxsize, quality):
mime="image/avif",
backend=backend,
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()
img = pyvips.Image.new_from_buffer(data, "")
img = img.autorot()
orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0:
img = img.resize(scale)
@@ -286,6 +299,8 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
mime="image/avif",
backend="pyvips",
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_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")
# 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:
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
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_save_end - t_save_start) * 1000, 1),
],
width=disp_w,
height=disp_h,
)
del imgdata, istream, ostream, icc, occ, frame
gc.collect()
+2 -1
View File
@@ -12,7 +12,7 @@ class ErrorMsg(msgspec.Struct):
## 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
name: str
key: str
@@ -20,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
size: int
allocated: int
isfile: int
ar: float | None = None
def __str__(self):
return self.key or "FileEntry()"
+51 -1
View File
@@ -154,6 +154,17 @@ stop_event = threading.Event()
# Thread-safe queue for signaling path updates from websockets
_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):
"""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:
logger.exception(f"get_allocated_size failed for {path}")
allocated = st.st_size if isfile else 0
key = fuid(st)
entry = FileEntry(
level=len(rel.parts),
name=rel.name,
key=fuid(st),
key=key,
mtime=int(st.st_mtime),
size=st.st_size if isfile else 0,
allocated=allocated,
isfile=isfile,
ar=_ar_map.get(key) if isfile else None,
)
if isfile:
return [entry]
@@ -775,6 +788,43 @@ def watcher(loop):
broadcast(format_root(fresh), loop)
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)
try:
while True:
+114 -4
View File
@@ -3,7 +3,13 @@
<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>
<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>
</div>
</template>
@@ -21,6 +27,7 @@ import {
onUnmounted,
ref,
shallowRef,
watch,
watchEffect
} from 'vue'
import { useRouter } from 'vue-router'
@@ -71,11 +78,108 @@ const rename = async (doc: Doc, newName: string) => {
}
const gallery = ref<HTMLElement>()
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 = () => {
if (!gallery.value) return
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
' '
).length
const style = getComputedStyle(gallery.value)
const templates = style.gridTemplateColumns
.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)
defineExpose({
@@ -230,14 +334,20 @@ onMounted(() => {
active.focus()
}
updateColumns()
seedFromDocs()
if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
gallery.value.addEventListener('load', onImgLoad, { capture: true })
}
})
onUnmounted(() => {
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) => {
doc.name = name
doc.key = crypto.randomUUID()
+4 -3
View File
@@ -82,8 +82,8 @@ const onclick = (ev: Event) => {
margin-left: 0.3em;
}
figure {
height: 15em;
max-height: 15em;
height: var(--gallery-figure-height, 15em);
max-height: var(--gallery-figure-height, 15em);
position: relative;
border-radius: .5em;
overflow: hidden;
@@ -93,12 +93,13 @@ figure {
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.4s ease, max-height 0.4s ease;
}
figure > article {
flex: 0 0 auto;
}
figure :deep(.video-container) {
height: 15em;
height: var(--gallery-figure-height, 15em);
}
.titlespacer {
flex-shrink: 100000;
+4 -1
View File
@@ -13,6 +13,7 @@ export type DocProps = {
dir: boolean
ghost?: boolean
expires?: number // Unix timestamp for ghost expiry
ar?: number // Aspect ratio (height/width) from server, if known
}
export class Doc {
@@ -26,6 +27,7 @@ export class Doc {
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */
public _name: string = ''
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props
@@ -130,7 +132,8 @@ export type FileEntry = [
number, // mtime
number, // size
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>]
+5
View File
@@ -164,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => {
case !!msg.update:
handleUpdateMessage(msg)
break
case !!msg.ar: {
const store = useMainStore()
store.updateAr(msg.ar as Record<string, number>)
break
}
case !!msg.space:
const store = useMainStore()
store.space = msg.space
+20 -3
View File
@@ -5,7 +5,7 @@ import { collator } from '@/utils'
import { type SortOrder, sorted } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker'
import { type StateTree, defineStore } from 'pinia'
import { documentRef, getDocuments, setDocuments } from './documentStore'
import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
// Singleton search worker instance
let searchWorker: Worker | null = null
@@ -124,7 +124,7 @@ export const useMainStore = defineStore('main', {
updateRoot(root: FileEntry[]) {
const docs = []
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)
docs.push(
new Doc({
@@ -134,7 +134,8 @@ export const useMainStore = defineStore('main', {
size,
allocated,
mtime,
dir: !isfile
dir: !isfile,
ar
})
)
loc.push(name)
@@ -157,6 +158,22 @@ export const useMainStore = defineStore('main', {
// Sync documents to search worker
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 */
addGhost(doc: Doc) {
doc.ghost = true