Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07089aa9a7 | ||
|
|
c3146744b7 | ||
|
|
48d7435d0b | ||
|
|
3e80325053 | ||
|
|
31fc02ddbf | ||
|
|
2406ea87b0 | ||
|
|
3a1dd2b7da | ||
|
|
3df6b079c9 | ||
|
|
af804e2c9f | ||
|
|
1dc0c4441a | ||
|
|
c7ba0d5a04 | ||
|
|
0071058b29 | ||
|
|
338c74de69 | ||
|
|
b13f08eab2 |
+10
-6
@@ -148,12 +148,16 @@ async def main_after_start(app):
|
|||||||
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||||
@app.before_server_stop
|
@app.before_server_stop
|
||||||
async def main_stop(app):
|
async def main_stop(app):
|
||||||
watching.stop(app)
|
async with asyncio.TaskGroup() as tg:
|
||||||
await onlyoffice.close_oo_client()
|
tg.create_task(asyncio.to_thread(watching.stop, app))
|
||||||
await shutdown_preview_workers()
|
tg.create_task(onlyoffice.close_oo_client())
|
||||||
app.ctx.threadexec.shutdown()
|
tg.create_task(shutdown_preview_workers())
|
||||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
tg.create_task(sso.close_client())
|
||||||
await sso.close_client()
|
|
||||||
|
async with asyncio.TaskGroup() as tg:
|
||||||
|
tg.create_task(asyncio.to_thread(app.ctx.threadexec.shutdown))
|
||||||
|
tg.create_task(asyncio.to_thread(app.ctx.zipexec.shutdown, cancel_futures=True))
|
||||||
|
|
||||||
logger.debug("Cista worker threads all finished")
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+48
-11
@@ -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,
|
||||||
@@ -244,7 +245,12 @@ class _PreviewWorkerPool:
|
|||||||
args[0].name,
|
args[0].name,
|
||||||
)
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(PreviewTimeoutError(args[0].name))
|
future.set_exception(
|
||||||
|
PreviewTimeoutError(
|
||||||
|
args[0].name,
|
||||||
|
backend=_expected_preview_backend(args[0]),
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
filepath = args[0]
|
filepath = args[0]
|
||||||
@@ -258,11 +264,13 @@ class _PreviewWorkerPool:
|
|||||||
future.set_result((out, resp))
|
future.set_result((out, resp))
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
replace = True
|
replace = True
|
||||||
logger.warning(
|
|
||||||
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
|
|
||||||
)
|
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(PreviewTimeoutError(filepath.name))
|
future.set_exception(
|
||||||
|
PreviewTimeoutError(
|
||||||
|
filepath.name,
|
||||||
|
backend=_expected_preview_backend(filepath),
|
||||||
|
)
|
||||||
|
)
|
||||||
except WorkerChecksumError:
|
except WorkerChecksumError:
|
||||||
replace = True
|
replace = True
|
||||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||||
@@ -410,6 +418,10 @@ async def verify_preview(request):
|
|||||||
class PreviewTimeoutError(Exception):
|
class PreviewTimeoutError(Exception):
|
||||||
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, backend: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
|
||||||
class PreviewError(Exception):
|
class PreviewError(Exception):
|
||||||
"""Raised when the preview subprocess exits with a non-zero status."""
|
"""Raised when the preview subprocess exits with a non-zero status."""
|
||||||
@@ -466,11 +478,13 @@ class OOConversionManager:
|
|||||||
filepath, request_timeout=5.0
|
filepath, request_timeout=5.0
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
future.set_exception(e)
|
if not future.done():
|
||||||
|
future.set_exception(e)
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._in_flight.pop(key, None)
|
self._in_flight.pop(key, None)
|
||||||
else:
|
else:
|
||||||
future.set_result(png_bytes)
|
if not future.done():
|
||||||
|
future.set_result(png_bytes)
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._in_flight.pop(key, None)
|
self._in_flight.pop(key, None)
|
||||||
|
|
||||||
@@ -548,6 +562,21 @@ def _preview_job_priority(path) -> int:
|
|||||||
return 4
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_preview_backend(path: Path) -> str:
|
||||||
|
"""Best-effort backend label used for timeout/access logging."""
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
return "onlyoffice"
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
|
return "pdf"
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime_type and mime_type.startswith("video/"):
|
||||||
|
return "video"
|
||||||
|
if mime_type and mime_type.startswith("image/"):
|
||||||
|
return "pyvips"
|
||||||
|
return "preview"
|
||||||
|
|
||||||
|
|
||||||
def is_previewable_path(path) -> bool:
|
def is_previewable_path(path) -> bool:
|
||||||
suffix = path.suffix.lower()
|
suffix = path.suffix.lower()
|
||||||
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
|
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||||
@@ -609,10 +638,12 @@ async def preview(req, path):
|
|||||||
timeout=PREVIEW_TIMEOUT,
|
timeout=PREVIEW_TIMEOUT,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning("Preview timeout for %s", filepath)
|
req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout"
|
||||||
return empty(503)
|
return empty(503)
|
||||||
except PreviewTimeoutError:
|
except PreviewTimeoutError as e:
|
||||||
logger.warning("Preview worker timeout for %s", filepath)
|
req.ctx.log_extra = (
|
||||||
|
f"{(e.backend or _expected_preview_backend(filepath))} timeout"
|
||||||
|
)
|
||||||
return empty(503)
|
return empty(503)
|
||||||
except httpx.HTTPStatusError:
|
except httpx.HTTPStatusError:
|
||||||
req.ctx.log_extra = "onlyoffice N/A"
|
req.ctx.log_extra = "onlyoffice N/A"
|
||||||
@@ -654,6 +685,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
|
||||||
|
|||||||
+37
-1
@@ -17,6 +17,7 @@ import gc
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import shlex
|
||||||
import struct
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -95,6 +96,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 +176,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:
|
||||||
@@ -214,7 +218,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
|||||||
cmd.insert(4, "-s")
|
cmd.insert(4, "-s")
|
||||||
cmd.insert(5, f"{new_w}x{new_h}")
|
cmd.insert(5, f"{new_w}x{new_h}")
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
shell_cmd = shlex.join(cmd)
|
||||||
|
stderr = (e.stderr or b"").decode(errors="replace").strip()
|
||||||
|
if stderr:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}\n{stderr}"
|
||||||
|
) from e
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}"
|
||||||
|
) from e
|
||||||
with Path(tmp_path).open("rb") as f:
|
with Path(tmp_path).open("rb") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
finally:
|
finally:
|
||||||
@@ -228,6 +243,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 +252,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 +274,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 +284,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 +294,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 +311,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 +342,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 +376,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 +476,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()"
|
||||||
|
|||||||
+40
-1
@@ -1,6 +1,7 @@
|
|||||||
"""Custom access logging middleware for Sanic."""
|
"""Custom access logging middleware for Sanic."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from ipaddress import IPv6Address
|
from ipaddress import IPv6Address
|
||||||
@@ -9,6 +10,40 @@ from sanic.log import LOGGING_CONFIG_DEFAULTS
|
|||||||
|
|
||||||
logger = logging.getLogger("cista.access")
|
logger = logging.getLogger("cista.access")
|
||||||
|
|
||||||
|
|
||||||
|
class ReentrantSafeStreamHandler(logging.StreamHandler):
|
||||||
|
"""Stream handler that degrades gracefully on signal-time reentrant writes.
|
||||||
|
|
||||||
|
Python's buffered text streams are not reentrant. If a signal handler logs
|
||||||
|
while another log write is in progress, StreamHandler.emit can raise:
|
||||||
|
RuntimeError("reentrant call inside <_io.BufferedWriter ...>")
|
||||||
|
|
||||||
|
Instead of letting logging emit a long "--- Logging error ---" traceback,
|
||||||
|
we fall back to a best-effort os.write to the same file descriptor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
msg = ""
|
||||||
|
try:
|
||||||
|
msg = self.format(record)
|
||||||
|
stream = self.stream
|
||||||
|
stream.write(msg + self.terminator)
|
||||||
|
self.flush()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "reentrant call inside" not in str(exc):
|
||||||
|
self.handleError(record)
|
||||||
|
return
|
||||||
|
stream = self.stream
|
||||||
|
fd = stream.fileno()
|
||||||
|
encoding = getattr(stream, "encoding", None) or "utf-8"
|
||||||
|
data = (msg + self.terminator).encode(encoding, errors="replace")
|
||||||
|
os.write(fd, data)
|
||||||
|
except RecursionError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
|
||||||
_RESET = "\033[0m"
|
_RESET = "\033[0m"
|
||||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||||
@@ -236,7 +271,7 @@ def log_ws_close(
|
|||||||
|
|
||||||
def configure_access_logging() -> None:
|
def configure_access_logging() -> None:
|
||||||
"""Configure the cista.access logger to output to stderr."""
|
"""Configure the cista.access logger to output to stderr."""
|
||||||
handler = logging.StreamHandler(sys.stderr)
|
handler = ReentrantSafeStreamHandler(sys.stderr)
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@@ -271,6 +306,10 @@ def configure_main_logging() -> None:
|
|||||||
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
||||||
call Sanic makes during serve_single() / serve().
|
call Sanic makes during serve_single() / serve().
|
||||||
"""
|
"""
|
||||||
|
for handler_name in ("console", "error_console", "access_console"):
|
||||||
|
LOGGING_CONFIG_DEFAULTS["handlers"][handler_name]["class"] = (
|
||||||
|
"cista.sanic_logging.ReentrantSafeStreamHandler"
|
||||||
|
)
|
||||||
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
|
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
|
||||||
"class": "cista.sanic_logging._EmojiFormatter",
|
"class": "cista.sanic_logging._EmojiFormatter",
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -4,11 +4,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
from sanic.worker.loader import AppLoader
|
||||||
|
|
||||||
from cista import config, server80
|
from cista import config, server80
|
||||||
from cista.app import app
|
from cista.app import app
|
||||||
|
|
||||||
|
|
||||||
|
def load_app() -> Sanic:
|
||||||
|
"""Return the app instance for spawned Sanic worker/reloader processes."""
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
def run(*, dev=False):
|
def run(*, dev=False):
|
||||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||||
_url, opts = parse_listen(config.config.listen)
|
_url, opts = parse_listen(config.config.listen)
|
||||||
@@ -29,7 +35,7 @@ def run(*, dev=False):
|
|||||||
access_log=False,
|
access_log=False,
|
||||||
) # type: ignore[call-arg]
|
) # type: ignore[call-arg]
|
||||||
if dev:
|
if dev:
|
||||||
Sanic.serve()
|
Sanic.serve(app_loader=AppLoader(factory=load_app))
|
||||||
else:
|
else:
|
||||||
Sanic.serve_single()
|
Sanic.serve_single()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import time
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import websockets.exceptions
|
||||||
from sanic import errorpages
|
from sanic import errorpages
|
||||||
from sanic.exceptions import SanicException
|
from sanic.exceptions import SanicException
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
@@ -67,6 +68,12 @@ def websocket_wrapper(handler):
|
|||||||
try:
|
try:
|
||||||
await auth.verify(request)
|
await auth.verify(request)
|
||||||
await handler(request, ws, *args, **kwargs)
|
await handler(request, ws, *args, **kwargs)
|
||||||
|
except (
|
||||||
|
websockets.exceptions.ConnectionClosedOK,
|
||||||
|
websockets.exceptions.ConnectionClosedError,
|
||||||
|
):
|
||||||
|
# Normal websocket closure - already logged in access log
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||||
if isinstance(e, SanicException):
|
if isinstance(e, SanicException):
|
||||||
|
|||||||
+71
-4
@@ -55,6 +55,10 @@ class FormatUpdateLoopError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _WatcherStoppingError(Exception):
|
||||||
|
"""Internal control-flow exception for quick watcher shutdown."""
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
@@ -154,6 +158,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 +201,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]
|
||||||
@@ -202,7 +219,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
li = []
|
li = []
|
||||||
for f in path.iterdir():
|
for f in path.iterdir():
|
||||||
if stop_event.is_set():
|
if stop_event.is_set():
|
||||||
raise SystemExit("quit")
|
raise _WatcherStoppingError
|
||||||
if f.name.startswith("."):
|
if f.name.startswith("."):
|
||||||
continue # No dotfiles
|
continue # No dotfiles
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
@@ -214,7 +231,11 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
li.append((int(isfile), f.name, s))
|
li.append((int(isfile), f.name, s))
|
||||||
# Build the tree as a list of FileEntries
|
# Build the tree as a list of FileEntries
|
||||||
for [_, name, s] in humansorted(li):
|
for [_, name, s] in humansorted(li):
|
||||||
|
if stop_event.is_set():
|
||||||
|
raise _WatcherStoppingError
|
||||||
sub = walk(rel / name, stat=s)
|
sub = walk(rel / name, stat=s)
|
||||||
|
if not sub:
|
||||||
|
continue
|
||||||
child = sub[0]
|
child = sub[0]
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=entry.level,
|
level=entry.level,
|
||||||
@@ -666,7 +687,10 @@ def watcher(loop):
|
|||||||
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
|
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||||
|
|
||||||
# Initialize the tree from filesystem
|
# Initialize the tree from filesystem
|
||||||
update_root(loop)
|
try:
|
||||||
|
update_root(loop)
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
path_index = PathIndex(state.root[:])
|
path_index = PathIndex(state.root[:])
|
||||||
|
|
||||||
trefresh = time.monotonic() + 300.0
|
trefresh = time.monotonic() + 300.0
|
||||||
@@ -750,7 +774,10 @@ def watcher(loop):
|
|||||||
# Process each collapsed path
|
# Process each collapsed path
|
||||||
new_root = path_index.root
|
new_root = path_index.root
|
||||||
for path in collapsed:
|
for path in collapsed:
|
||||||
new_entries = walk(path)
|
try:
|
||||||
|
new_entries = walk(path)
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
new_root = path_index.apply_update(path, new_entries)
|
new_root = path_index.apply_update(path, new_entries)
|
||||||
|
|
||||||
# Broadcast if changed
|
# Broadcast if changed
|
||||||
@@ -769,12 +796,51 @@ def watcher(loop):
|
|||||||
with state.lock:
|
with state.lock:
|
||||||
broadcast(update_msg, loop)
|
broadcast(update_msg, loop)
|
||||||
state.root = fresh
|
state.root = fresh
|
||||||
|
except _WatcherStoppingError:
|
||||||
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Fallback failed; sending full root")
|
logger.exception("Fallback failed; sending full root")
|
||||||
with state.lock:
|
with state.lock:
|
||||||
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:
|
||||||
@@ -820,6 +886,7 @@ def start(app):
|
|||||||
global rootpath
|
global rootpath
|
||||||
config.load_config()
|
config.load_config()
|
||||||
rootpath = config.config.path
|
rootpath = config.config.path
|
||||||
|
stop_event.clear()
|
||||||
app.ctx.watcher = threading.Thread(
|
app.ctx.watcher = threading.Thread(
|
||||||
target=watcher,
|
target=watcher,
|
||||||
args=[app.loop],
|
args=[app.loop],
|
||||||
|
|||||||
+46
-7
@@ -8,6 +8,7 @@
|
|||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<UserManagementModal />
|
<UserManagementModal />
|
||||||
<UserTokensModal />
|
<UserTokensModal />
|
||||||
|
<AboutModal />
|
||||||
<AccessDeniedModal />
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||||
@@ -28,11 +29,12 @@ import type HeaderMain from '@/components/HeaderMain.vue'
|
|||||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import AboutModal from './components/AboutModal.vue'
|
||||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
@@ -56,12 +58,16 @@ const path: ComputedRef<Path> = computed(() => {
|
|||||||
query
|
query
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watch(
|
||||||
document.title =
|
() => path.value.path,
|
||||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
() => {
|
||||||
store.server.name ||
|
document.title =
|
||||||
'Cista Storage'
|
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||||
})
|
store.server.name ||
|
||||||
|
'Cista Storage'
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
onMounted(loadSession)
|
onMounted(loadSession)
|
||||||
onMounted(watchConnect)
|
onMounted(watchConnect)
|
||||||
onUnmounted(watchDisconnect)
|
onUnmounted(watchDisconnect)
|
||||||
@@ -95,6 +101,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
event.key === 'ArrowDown' ||
|
event.key === 'ArrowDown' ||
|
||||||
event.key === 'ArrowLeft' ||
|
event.key === 'ArrowLeft' ||
|
||||||
event.key === 'ArrowRight' ||
|
event.key === 'ArrowRight' ||
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'PageDown' ||
|
||||||
(c && event.code === 'Space')
|
(c && event.code === 'Space')
|
||||||
) {
|
) {
|
||||||
if (!input) event.preventDefault()
|
if (!input) event.preventDefault()
|
||||||
@@ -104,6 +112,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
|
let paging = ''
|
||||||
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||||
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||||
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||||
@@ -115,10 +124,22 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||||
return // Let browser handle cursor movement
|
return // Let browser handle cursor movement
|
||||||
}
|
}
|
||||||
|
// Don't intercept arrows for non-search inputs (e.g. rename input)
|
||||||
|
if (input && !searchInput) return
|
||||||
arrow = dir
|
arrow = dir
|
||||||
|
} else if (
|
||||||
|
event.key === 'PageUp' ||
|
||||||
|
event.key === 'PageDown' ||
|
||||||
|
event.key === 'Home' ||
|
||||||
|
event.key === 'End'
|
||||||
|
) {
|
||||||
|
if (input) return
|
||||||
|
paging = event.key
|
||||||
}
|
}
|
||||||
if (arrow) {
|
if (arrow) {
|
||||||
// Arrow key handling - fall through to bottom
|
// Arrow key handling - fall through to bottom
|
||||||
|
} else if (paging) {
|
||||||
|
// Paging/navigation key handling - fall through to bottom
|
||||||
}
|
}
|
||||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||||
@@ -136,6 +157,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
else if (keyup && event.key === 'Escape') {
|
else if (keyup && event.key === 'Escape') {
|
||||||
store.error = ''
|
store.error = ''
|
||||||
store.clearToast()
|
store.clearToast()
|
||||||
|
// Keep rename and other non-search inputs isolated from search behavior.
|
||||||
|
if (input && !searchInput) return
|
||||||
headerMain.value!.clearSearch(event)
|
headerMain.value!.clearSearch(event)
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
} else if (!input && keyup && event.key === 'Backspace') {
|
} else if (!input && keyup && event.key === 'Backspace') {
|
||||||
@@ -235,12 +258,28 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (paging && !keyup && !inHeader && !inBreadcrumb) {
|
||||||
|
switch (paging) {
|
||||||
|
case 'PageUp':
|
||||||
|
f = () => fileExplorer.pageUp?.(event)
|
||||||
|
break
|
||||||
|
case 'PageDown':
|
||||||
|
f = () => fileExplorer.pageDown?.(event)
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
f = () => fileExplorer.home?.(event)
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
f = () => fileExplorer.end?.(event)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (f) {
|
if (f) {
|
||||||
// Initial move, then t0 delay until repeats at tr intervals
|
// Initial move, then t0 delay until repeats at tr intervals
|
||||||
const t0 = 200,
|
const t0 = 200,
|
||||||
tr = event.altKey ? 20 : 100
|
tr = event.altKey ? 20 : 100
|
||||||
f()
|
f()
|
||||||
|
if (paging === 'Home' || paging === 'End') return
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
timer = setInterval(f, tr)
|
timer = setInterval(f, tr)
|
||||||
}, t0 - tr)
|
}, t0 - tr)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>
|
||||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name="about" title="">
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="about-logo-pane">
|
||||||
|
<img :src="logoUrl" alt="Cista Storage logo" class="about-logo" />
|
||||||
|
</div>
|
||||||
|
<div class="about-details">
|
||||||
|
<h3 class="about-name">Cista {{ softwareVersion }}</h3>
|
||||||
|
<p class="about-link">
|
||||||
|
<a :href="projectUrl" target="_blank" rel="noopener noreferrer">{{ displayProjectUrl }}</a>
|
||||||
|
</p>
|
||||||
|
<div class="dialog-buttons about-actions">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<input id="close" type="reset" value="Close" class="button" @click="close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import logoUrl from '@/assets/logo-square.svg?url'
|
||||||
|
import ModalDialog from '@/components/ModalDialog.vue'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const softwareVersion = computed(() => store.server.version || 'unknown')
|
||||||
|
const projectUrl = 'https://git.zi.fi/Vasanko/cista-storage'
|
||||||
|
const displayProjectUrl = projectUrl.replace(/^https?:\/\//, '')
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(#about.modal-dialog) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
width: min(35rem, 92vw);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
margin: -1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo-pane {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-name {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
margin: 0.65rem 0 1rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.about-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: min(24rem, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo-pane {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-details {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,32 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
|
<ModalDialog name="accessdenied" title="">
|
||||||
<div class="modal-dialog" id="accessdenied">
|
<div class="access-denied">
|
||||||
<div class="modal-content access-denied">
|
<p class="icon">⛔</p>
|
||||||
<p class="icon">⛔</p>
|
<p class="message">Access Denied</p>
|
||||||
<p class="message">Access Denied</p>
|
<button @click="reload" class="button">Reload</button>
|
||||||
<button @click="reload" class="button">Reload</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import ModalDialog from '@/components/ModalDialog.vue'
|
||||||
import { holdGlobalBackdrop } from 'paskia'
|
|
||||||
import { watchEffect } from 'vue'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
location.reload()
|
location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep backdrop active when this dialog shows
|
|
||||||
watchEffect(() => {
|
|
||||||
if (store.dialog === 'accessdenied') {
|
|
||||||
holdGlobalBackdrop()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import { apiFetch } from '@/repositories/Client'
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
@@ -112,21 +113,95 @@ const parseErrorMessage = async (res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCursorIndex = () =>
|
||||||
|
store.cursor
|
||||||
|
? props.documents.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: props.documents.length
|
||||||
|
|
||||||
|
const getDocElement = (key: string) =>
|
||||||
|
document.getElementById(`file-${key}`) as HTMLElement | null
|
||||||
|
|
||||||
|
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
|
||||||
|
const select = !!ev?.shiftKey
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) {
|
||||||
|
store.cursor = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const N = docs.length
|
||||||
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
|
const index = getCursorIndex()
|
||||||
|
|
||||||
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
|
const tr = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
if (select) {
|
||||||
|
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
|
||||||
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
|
if (p === N) continue
|
||||||
|
const key = docs[p]!.key
|
||||||
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
|
else store.selected.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keepCursorVisibleSmooth(tr)
|
||||||
|
if (moveto === N) {
|
||||||
|
if (index > moveto) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) return
|
||||||
|
const scroller =
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
const currentIndex = getCursorIndex()
|
||||||
|
const currentEl = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
const currentCenter = currentEl
|
||||||
|
? currentEl.getBoundingClientRect().top +
|
||||||
|
currentEl.getBoundingClientRect().height / 2
|
||||||
|
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
|
||||||
|
const targetCenter =
|
||||||
|
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
|
||||||
|
|
||||||
|
let bestIndex = direction > 0 ? docs.length - 1 : 0
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (
|
||||||
|
currentIndex !== docs.length &&
|
||||||
|
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const el = getDocElement(docs[i]!.key)
|
||||||
|
if (!el) continue
|
||||||
|
const center =
|
||||||
|
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
|
||||||
|
const distance = Math.abs(center - targetCenter)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(bestIndex, ev)
|
||||||
|
}
|
||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
store.documentsChanged()
|
||||||
try {
|
try {
|
||||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||||
const res = await apiFetch(
|
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||||
{ method: 'POST' }
|
|
||||||
)
|
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Rename failed', err)
|
console.error('Rename failed', err)
|
||||||
doc.name = oldName
|
doc.name = oldName
|
||||||
|
store.documentsChanged()
|
||||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,14 +251,33 @@ defineExpose({
|
|||||||
} else {
|
} else {
|
||||||
store.selected.add(key)
|
store.selected.add(key)
|
||||||
}
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) {
|
up(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(-1, ev)
|
this.cursorMove(-1, ev)
|
||||||
},
|
},
|
||||||
down(ev: KeyboardEvent) {
|
down(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, ev)
|
this.cursorMove(1, ev)
|
||||||
},
|
},
|
||||||
|
pageUp(ev: KeyboardEvent) {
|
||||||
|
pageMove(-1, ev)
|
||||||
|
},
|
||||||
|
pageDown(ev: KeyboardEvent) {
|
||||||
|
pageMove(1, ev)
|
||||||
|
},
|
||||||
|
home(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(0, ev)
|
||||||
|
},
|
||||||
|
end(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(props.documents.length - 1, ev)
|
||||||
|
},
|
||||||
left(ev: KeyboardEvent) {
|
left(ev: KeyboardEvent) {
|
||||||
// Only go back if we're in a subfolder (not at root)
|
// Only go back if we're in a subfolder (not at root)
|
||||||
if (props.path.length > 0) {
|
if (props.path.length > 0) {
|
||||||
@@ -197,8 +291,6 @@ defineExpose({
|
|||||||
if (a) a.click()
|
if (a) a.click()
|
||||||
},
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
|
||||||
// Move cursor up or down (keyboard navigation)
|
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
@@ -207,35 +299,9 @@ defineExpose({
|
|||||||
const N = docs.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index = store.cursor
|
const index = getCursorIndex()
|
||||||
? docs.findIndex(doc => doc.key === store.cursor)
|
|
||||||
: docs.length
|
|
||||||
const moveto = increment(index, d)
|
const moveto = increment(index, d)
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
moveCursorTo(moveto, ev)
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
|
||||||
if (select) {
|
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
|
||||||
if (p === N) continue
|
|
||||||
const key = docs[p]!.key
|
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
|
||||||
else store.selected.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
scrolltr = tr
|
|
||||||
if (!scrolltimer) {
|
|
||||||
scrolltimer = setTimeout(() => {
|
|
||||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
|
||||||
if (moveto === N) {
|
|
||||||
if (d < 0) focusBreadcrumb()
|
|
||||||
else focusHeader()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const focusHeader = () => {
|
const focusHeader = () => {
|
||||||
@@ -248,8 +314,9 @@ const focusBreadcrumb = () => {
|
|||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
let scrolltimer: any = null
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
let scrolltr: any = null
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||||
if (editing.value) store.cursor = editing.value?.key
|
if (editing.value) store.cursor = editing.value?.key
|
||||||
@@ -257,7 +324,7 @@ watchEffect(() => {
|
|||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor} .name a`
|
`#file-${store.cursor} .name a`
|
||||||
) as HTMLAnchorElement | null
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.focus()
|
if (a) a.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@@ -276,11 +343,11 @@ onMounted(() => {
|
|||||||
modifiedTimer = setInterval(updateModified, 1000)
|
modifiedTimer = setInterval(updateModified, 1000)
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.focus({ preventScroll: true })
|
||||||
active.focus()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
keyboardFollowScroll.cancel()
|
||||||
clearInterval(modifiedTimer)
|
clearInterval(modifiedTimer)
|
||||||
})
|
})
|
||||||
const mkdir = async (doc: Doc, name: string) => {
|
const mkdir = async (doc: Doc, name: string) => {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
<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)"
|
||||||
|
@rename="editing = doc; store.cursor = doc.key"
|
||||||
|
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -13,6 +20,7 @@ import { apiFetch } from '@/repositories/Client'
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { SortOrder } from '@/utils/docsort'
|
import type { SortOrder } from '@/utils/docsort'
|
||||||
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
@@ -21,6 +29,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'
|
||||||
@@ -56,28 +65,200 @@ const exit = () => {
|
|||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
|
store.documentsChanged()
|
||||||
try {
|
try {
|
||||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||||
const res = await apiFetch(
|
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||||
{ method: 'POST' }
|
|
||||||
)
|
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Rename failed', err)
|
console.error('Rename failed', err)
|
||||||
doc.name = oldName
|
doc.name = oldName
|
||||||
|
store.documentsChanged()
|
||||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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(30 * 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)
|
||||||
|
|
||||||
|
const getCursorIndex = () =>
|
||||||
|
store.cursor
|
||||||
|
? props.documents.findIndex(doc => doc.key === store.cursor)
|
||||||
|
: props.documents.length
|
||||||
|
|
||||||
|
const getDocElement = (key: string) =>
|
||||||
|
document.getElementById(`file-${key}`) as HTMLElement | null
|
||||||
|
|
||||||
|
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
|
||||||
|
const select = !!ev?.shiftKey
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) {
|
||||||
|
store.cursor = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const N = docs.length
|
||||||
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
|
const index = getCursorIndex()
|
||||||
|
|
||||||
|
store.cursor = docs[moveto]?.key ?? ''
|
||||||
|
const tr = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
if (select) {
|
||||||
|
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
|
||||||
|
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||||
|
if (p === N) continue
|
||||||
|
const key = docs[p]!.key
|
||||||
|
if (store.selected.has(key)) store.selected.delete(key)
|
||||||
|
else store.selected.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keepCursorVisibleSmooth(tr)
|
||||||
|
if (moveto === N) {
|
||||||
|
if (index > moveto) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length === 0) return
|
||||||
|
const scroller =
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
const currentIndex = getCursorIndex()
|
||||||
|
const currentEl = store.cursor ? getDocElement(store.cursor) : null
|
||||||
|
const currentCenter = currentEl
|
||||||
|
? currentEl.getBoundingClientRect().top +
|
||||||
|
currentEl.getBoundingClientRect().height / 2
|
||||||
|
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
|
||||||
|
const targetCenter =
|
||||||
|
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
|
||||||
|
|
||||||
|
let bestIndex = direction > 0 ? docs.length - 1 : 0
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY
|
||||||
|
for (let i = 0; i < docs.length; i++) {
|
||||||
|
if (
|
||||||
|
currentIndex !== docs.length &&
|
||||||
|
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const el = getDocElement(docs[i]!.key)
|
||||||
|
if (!el) continue
|
||||||
|
const center =
|
||||||
|
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
|
||||||
|
const distance = Math.abs(center - targetCenter)
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(bestIndex, ev)
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
newFolder() {
|
newFolder() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
@@ -127,23 +308,42 @@ defineExpose({
|
|||||||
} else {
|
} else {
|
||||||
store.selected.add(key)
|
store.selected.add(key)
|
||||||
}
|
}
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, null)
|
this.cursorMove(1, null)
|
||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) {
|
up(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(-columns.value, ev)
|
this.cursorMove(-columns.value, ev)
|
||||||
},
|
},
|
||||||
down(ev: KeyboardEvent) {
|
down(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(columns.value, ev)
|
this.cursorMove(columns.value, ev)
|
||||||
},
|
},
|
||||||
left(ev: KeyboardEvent) {
|
left(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(-1, ev)
|
this.cursorMove(-1, ev)
|
||||||
},
|
},
|
||||||
right(ev: KeyboardEvent) {
|
right(ev: KeyboardEvent) {
|
||||||
|
markKeyboardFollow()
|
||||||
this.cursorMove(1, ev)
|
this.cursorMove(1, ev)
|
||||||
},
|
},
|
||||||
|
pageUp(ev: KeyboardEvent) {
|
||||||
|
pageMove(-1, ev)
|
||||||
|
},
|
||||||
|
pageDown(ev: KeyboardEvent) {
|
||||||
|
pageMove(1, ev)
|
||||||
|
},
|
||||||
|
home(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(0, ev)
|
||||||
|
},
|
||||||
|
end(ev: KeyboardEvent) {
|
||||||
|
if (!props.documents.length) return
|
||||||
|
markKeyboardFollow()
|
||||||
|
moveCursorTo(props.documents.length - 1, ev)
|
||||||
|
},
|
||||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||||
const select = !!ev?.shiftKey
|
|
||||||
// Move cursor up or down (keyboard navigation)
|
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
@@ -152,7 +352,7 @@ defineExpose({
|
|||||||
const N = docs.length
|
const N = docs.length
|
||||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||||
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
const index = getCursorIndex()
|
||||||
// Stop navigation sideways away from the grid (only with up/down)
|
// Stop navigation sideways away from the grid (only with up/down)
|
||||||
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
||||||
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
||||||
@@ -164,31 +364,7 @@ defineExpose({
|
|||||||
// Wrapping either end, just land outside the list
|
// Wrapping either end, just land outside the list
|
||||||
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
||||||
}
|
}
|
||||||
store.cursor = docs[moveto]?.key ?? ''
|
moveCursorTo(moveto, ev)
|
||||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
|
||||||
if (select) {
|
|
||||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
|
||||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
|
||||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
|
||||||
if (p === N) continue
|
|
||||||
const key = docs[p]!.key
|
|
||||||
if (store.selected.has(key)) store.selected.delete(key)
|
|
||||||
else store.selected.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
scrolltr = tr
|
|
||||||
if (!scrolltimer) {
|
|
||||||
scrolltimer = setTimeout(() => {
|
|
||||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
scrolltimer = null
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
|
||||||
if (moveto === N) {
|
|
||||||
if (d < 0) focusBreadcrumb()
|
|
||||||
else focusHeader()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const focusHeader = () => {
|
const focusHeader = () => {
|
||||||
@@ -201,18 +377,18 @@ const focusBreadcrumb = () => {
|
|||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
}
|
}
|
||||||
let scrolltimer: any = null
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
let scrolltr: any = null
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||||
if (editing.value) store.cursor = editing.value.key
|
if (editing.value) store.cursor = editing.value.key
|
||||||
if (store.cursor) {
|
if (store.cursor && !editing.value) {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor}`
|
`#file-${store.cursor}`
|
||||||
) as HTMLAnchorElement | null
|
) as HTMLAnchorElement | null
|
||||||
if (a) {
|
if (a) {
|
||||||
a.focus()
|
a.focus({ preventScroll: true })
|
||||||
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -226,18 +402,24 @@ let resizeObserver: ResizeObserver | null = null
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
active.focus({ preventScroll: true })
|
||||||
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(() => {
|
||||||
|
keyboardFollowScroll.cancel()
|
||||||
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()
|
||||||
|
|||||||
@@ -10,21 +10,33 @@
|
|||||||
>
|
>
|
||||||
<figure>
|
<figure>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
<MediaPreview :key="snap.ext" ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
||||||
<div class="titlespacer"></div>
|
<div class="titlespacer"></div>
|
||||||
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||||
<template v-if="editing">
|
<template v-if="editing">
|
||||||
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
|
<div class="filename-row rename-row">
|
||||||
|
<div class="rename-wrap">
|
||||||
|
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
<div class="filename-row">
|
||||||
|
<span class="filename-group">
|
||||||
|
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
|
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
|
||||||
|
</span>
|
||||||
|
<button class="rename-btn" @click="$emit('rename')" title="Rename">✏️</button>
|
||||||
|
</div>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
<div class="tooltip-name">{{ doc.name }}</div>
|
<div class="tooltip-name">{{ snap.name }}</div>
|
||||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||||
</CursorTooltip>
|
</CursorTooltip>
|
||||||
@@ -42,7 +54,7 @@ import SparseIndicator from './SparseIndicator.vue'
|
|||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
rename: (name: string) => void
|
rename: (doc: Doc, newName: string) => void
|
||||||
exit: () => void
|
exit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +72,20 @@ const sparseText = computed(() => {
|
|||||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Single subscription to docVersion; all doc-derived values come from here.
|
||||||
|
// This is needed because Doc instances are non-reactive plain objects, so
|
||||||
|
// mutating doc.name alone won't invalidate computed caches.
|
||||||
|
const snap = computed(() => {
|
||||||
|
void store.docVersion
|
||||||
|
const { name, ext } = props.doc
|
||||||
|
const base = ext ? name.slice(0, name.length - ext.length - 1) : name
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ext,
|
||||||
|
displayName: base.replace(/[_.]+/g, ' ')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) ev.preventDefault()
|
||||||
store.cursor = props.doc.key
|
store.cursor = props.doc.key
|
||||||
@@ -81,8 +107,78 @@ const onclick = (ev: Event) => {
|
|||||||
.after-name {
|
.after-name {
|
||||||
margin-left: 0.3em;
|
margin-left: 0.3em;
|
||||||
}
|
}
|
||||||
|
.filename-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: calc(100% - 4.5em);
|
||||||
|
}
|
||||||
|
.filename-row::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
width: 1.4em;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.filename-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.filename {
|
||||||
|
cursor: default;
|
||||||
|
padding: .5em 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-ext {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||||
|
padding: 0 .15em 0 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.rename-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(0.2em, -50%);
|
||||||
|
z-index: 2;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
.filename-row:hover .rename-btn {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
max-height: 15em;
|
height: var(--gallery-figure-height, 15em);
|
||||||
|
max-height: var(--gallery-figure-height, 15em);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: .5em;
|
border-radius: .5em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -92,12 +188,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;
|
||||||
@@ -124,17 +221,10 @@ figcaption input[type='checkbox'] {
|
|||||||
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
figcaption span {
|
.cursor .filename {
|
||||||
cursor: default;
|
color: var(--accent-color);
|
||||||
padding: .5em;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
|
||||||
text-wrap: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.cursor figcaption span {
|
.cursor .file-ext {
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
figcaption .namespacer {
|
figcaption .namespacer {
|
||||||
@@ -142,4 +232,17 @@ figcaption .namespacer {
|
|||||||
height: 2em;
|
height: 2em;
|
||||||
width: 2em;
|
width: 2em;
|
||||||
}
|
}
|
||||||
|
.rename-wrap {
|
||||||
|
font-size: 0.8em;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.rename-row {
|
||||||
|
max-width: calc(100% - 4.5em);
|
||||||
|
}
|
||||||
|
.rename-wrap :deep(#FileRenameInput) {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ const settingsMenu = (e: Event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: 'ℹ️ About Cista...',
|
||||||
|
onClick: () => {
|
||||||
|
store.dialog = 'about'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ContextMenu.showContextMenu({
|
ContextMenu.showContextMenu({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
x: e.target.getBoundingClientRect().right,
|
x: e.target.getBoundingClientRect().right,
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
<div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
|
||||||
<SpinnerIcon />
|
<span
|
||||||
|
v-show="activeImageLoading"
|
||||||
|
class="file icon"
|
||||||
|
:class="[`ext-${doc.ext}`, 'loading-pulse']"
|
||||||
|
:style="loadingPulseStyle"
|
||||||
|
></span>
|
||||||
|
<img
|
||||||
|
v-if="showPreviewImage"
|
||||||
|
:src="previewSrc"
|
||||||
|
alt=""
|
||||||
|
:class="{ ready: !previewImageLoading }"
|
||||||
|
@load="onPreviewImageLoad"
|
||||||
|
@error="onPreviewImageError"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="doc.url"
|
||||||
|
alt=""
|
||||||
|
:class="{ ready: !nativeImageLoading }"
|
||||||
|
@load="onNativeImageLoad"
|
||||||
|
@error="onNativeImageError"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-else-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||||
|
<span
|
||||||
|
class="file icon"
|
||||||
|
:class="[`ext-${doc.ext}`, { 'loading-pulse': !previewLoadFailed }]"
|
||||||
|
:style="loadingPulseStyle"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
|
|
||||||
<img v-else-if=doc.img :src=doc.url alt="">
|
|
||||||
<span v-else-if=doc.dir class="folder icon"></span>
|
<span v-else-if=doc.dir class="folder icon"></span>
|
||||||
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
|
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
|
||||||
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
@@ -18,10 +44,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
import { Play as PlayIcon } from '@/assets/svg'
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
const vid = ref<HTMLVideoElement | null>(null)
|
const vid = ref<HTMLVideoElement | null>(null)
|
||||||
@@ -30,11 +56,58 @@ const props = defineProps<{
|
|||||||
doc: Doc
|
doc: Doc
|
||||||
quality: string
|
quality: string
|
||||||
}>()
|
}>()
|
||||||
|
const previewImageFailed = ref(false)
|
||||||
|
const nativeImageFailed = ref(false)
|
||||||
|
const previewImageLoading = ref(true)
|
||||||
|
const nativeImageLoading = ref(true)
|
||||||
const previewSrc = computed(() =>
|
const previewSrc = computed(() =>
|
||||||
props.doc.previewurl
|
props.doc.previewurl
|
||||||
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
|
const showPreviewImage = computed(
|
||||||
|
() => !!previewSrc.value && !video() && !audio() && !previewImageFailed.value
|
||||||
|
)
|
||||||
|
const showNativeImage = computed(() => props.doc.img && !nativeImageFailed.value)
|
||||||
|
const activeImageLoading = computed(() =>
|
||||||
|
showPreviewImage.value ? previewImageLoading.value : nativeImageLoading.value
|
||||||
|
)
|
||||||
|
const previewLoadFailed = computed(
|
||||||
|
() => previewImageFailed.value || nativeImageFailed.value
|
||||||
|
)
|
||||||
|
const loadingPulseDelayMs = computed(() => {
|
||||||
|
let hash = 0
|
||||||
|
for (const ch of props.doc.key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
|
||||||
|
return hash % 1800
|
||||||
|
})
|
||||||
|
const loadingPulseStyle = computed(() => ({
|
||||||
|
animationDelay: `${-loadingPulseDelayMs.value}ms`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const onPreviewImageLoad = () => {
|
||||||
|
previewImageLoading.value = false
|
||||||
|
}
|
||||||
|
const onPreviewImageError = () => {
|
||||||
|
previewImageLoading.value = false
|
||||||
|
previewImageFailed.value = true
|
||||||
|
}
|
||||||
|
const onNativeImageLoad = () => {
|
||||||
|
nativeImageLoading.value = false
|
||||||
|
}
|
||||||
|
const onNativeImageError = () => {
|
||||||
|
nativeImageLoading.value = false
|
||||||
|
nativeImageFailed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.doc.key,
|
||||||
|
() => {
|
||||||
|
previewImageFailed.value = false
|
||||||
|
nativeImageFailed.value = false
|
||||||
|
previewImageLoading.value = true
|
||||||
|
nativeImageLoading.value = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const onplay = () => {
|
const onplay = () => {
|
||||||
if (!media.value) return
|
if (!media.value) return
|
||||||
@@ -154,6 +227,7 @@ img, embed, .icon, audio, video {
|
|||||||
border-radius: calc(.5em / 8);
|
border-radius: calc(.5em / 8);
|
||||||
}
|
}
|
||||||
.preview-progress {
|
.preview-progress {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -162,18 +236,47 @@ img, embed, .icon, audio, video {
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
}
|
}
|
||||||
.preview-progress :deep(svg) {
|
.preview-progress .icon {
|
||||||
width: 4.5em;
|
opacity: 0.9;
|
||||||
height: 4.5em;
|
|
||||||
opacity: 0.8;
|
|
||||||
animation: media-preview-spin 0.9s linear infinite;
|
|
||||||
}
|
}
|
||||||
@keyframes media-preview-spin {
|
.preview-image-shell {
|
||||||
from {
|
position: relative;
|
||||||
transform: rotate(0deg);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.preview-image-shell img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.preview-image-shell img.ready {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.loading-pulse {
|
||||||
|
animation: media-preview-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes media-preview-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.86;
|
||||||
}
|
}
|
||||||
to {
|
50% {
|
||||||
transform: rotate(360deg);
|
transform: scale(1.04);
|
||||||
|
opacity: 0.98;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.86;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.folder::before {
|
.folder::before {
|
||||||
@@ -219,12 +322,6 @@ img, embed, .icon, audio, video {
|
|||||||
figure.cursor .icon {
|
figure.cursor .icon {
|
||||||
filter: brightness(1);
|
filter: brightness(1);
|
||||||
}
|
}
|
||||||
img::before {
|
|
||||||
/* broken image */
|
|
||||||
text-shadow: 0 0 .5rem #000;
|
|
||||||
filter: grayscale(1);
|
|
||||||
content: '❌';
|
|
||||||
}
|
|
||||||
.video-container {
|
.video-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -15,15 +15,55 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||||
import { nextTick, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const overlay = ref<HTMLDivElement | null>(null)
|
const overlay = ref<HTMLDivElement | null>(null)
|
||||||
const dialog = ref<HTMLDivElement | null>(null)
|
const dialog = ref<HTMLDivElement | null>(null)
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
let backdropHeld = false
|
||||||
|
|
||||||
|
const ensureGlobalBackdropStyles = () => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (document.getElementById('paskia-dialog')) return
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = 'paskia-dialog'
|
||||||
|
style.textContent = `body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1099;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(0) brightness(1);
|
||||||
|
-webkit-backdrop-filter: blur(0) brightness(1);
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop::before {
|
||||||
|
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
||||||
|
backdrop-filter: blur(.2rem) brightness(0.5);
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
body.paskia-backdrop {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
#paskia-iframe {
|
||||||
|
border: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
color-scheme: auto;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.insertBefore(style, document.head.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
releaseGlobalBackdrop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -33,7 +73,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const show = () => {
|
const show = () => {
|
||||||
store.dialog = props.name
|
store.dialog = props.name
|
||||||
holdGlobalBackdrop()
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
overlay.value?.focus()
|
overlay.value?.focus()
|
||||||
const input = dialog.value?.querySelector('input')
|
const input = dialog.value?.querySelector('input')
|
||||||
@@ -41,6 +80,29 @@ const show = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
defineExpose({ show, close })
|
defineExpose({ show, close })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.dialog === props.name,
|
||||||
|
isOpen => {
|
||||||
|
if (isOpen && !backdropHeld) {
|
||||||
|
ensureGlobalBackdropStyles()
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
backdropHeld = true
|
||||||
|
} else if (!isOpen && backdropHeld) {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
backdropHeld = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (backdropHeld) {
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
backdropHeld = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (overlay.value) {
|
if (overlay.value) {
|
||||||
overlay.value.focus()
|
overlay.value.focus()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -84,7 +84,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
paskia?: boolean
|
paskia?: boolean
|
||||||
office_previews?: boolean
|
office_previews?: boolean
|
||||||
},
|
},
|
||||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
@@ -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
|
||||||
@@ -236,6 +253,12 @@ export const useMainStore = defineStore('main', {
|
|||||||
}))
|
}))
|
||||||
worker.postMessage({ type: 'update', documents: docData })
|
worker.postMessage({ type: 'update', documents: docData })
|
||||||
},
|
},
|
||||||
|
/** Notify UI/search that existing document objects were mutated in-place */
|
||||||
|
documentsChanged() {
|
||||||
|
triggerUpdate()
|
||||||
|
this.docVersion++
|
||||||
|
this.syncSearchWorker()
|
||||||
|
},
|
||||||
search(query: string, loc: string) {
|
search(query: string, loc: string) {
|
||||||
const worker = getSearchWorker()
|
const worker = getSearchWorker()
|
||||||
const id = ++searchId
|
const id = ++searchId
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
type ScrollOptions = {
|
||||||
|
topPad?: number
|
||||||
|
bottomPad?: number
|
||||||
|
keyboardWindowMs?: number
|
||||||
|
getScrollContainer?: () => HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKeyboardFollowScroll(options: ScrollOptions = {}) {
|
||||||
|
const {
|
||||||
|
topPad = 84,
|
||||||
|
bottomPad = 84,
|
||||||
|
keyboardWindowMs = 260,
|
||||||
|
getScrollContainer = () =>
|
||||||
|
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
|
||||||
|
} = options
|
||||||
|
|
||||||
|
let scrollAnimationFrame: number | null = null
|
||||||
|
let scrollTargetY: number | null = null
|
||||||
|
let scrollVelocity = 0
|
||||||
|
let keyboardFollowUntil = 0
|
||||||
|
|
||||||
|
const markKeyboardFollow = () => {
|
||||||
|
keyboardFollowUntil = performance.now() + keyboardWindowMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
|
||||||
|
|
||||||
|
const clampScrollY = (y: number, scroller: HTMLElement) => {
|
||||||
|
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
|
||||||
|
return Math.min(maxY, Math.max(0, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorScrollTarget = (el: HTMLElement): number | null => {
|
||||||
|
const scroller = getScrollContainer() ?? document.documentElement
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect()
|
||||||
|
const visibleTop = scrollerRect.top + topPad
|
||||||
|
const visibleBottom = scrollerRect.bottom - bottomPad
|
||||||
|
|
||||||
|
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
|
||||||
|
|
||||||
|
if (rect.top < visibleTop) {
|
||||||
|
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSmoothCursorScroll = () => {
|
||||||
|
if (scrollAnimationFrame != null) return
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
const scroller = getScrollContainer() ?? document.documentElement
|
||||||
|
|
||||||
|
if (scrollTargetY == null) {
|
||||||
|
scrollVelocity *= 0.68
|
||||||
|
if (Math.abs(scrollVelocity) > 0.05) {
|
||||||
|
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollVelocity = 0
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = scroller.scrollTop
|
||||||
|
const delta = scrollTargetY - current
|
||||||
|
const absDelta = Math.abs(delta)
|
||||||
|
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
|
||||||
|
scroller.scrollTop = scrollTargetY
|
||||||
|
scrollVelocity = 0
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stiffness = Math.min(0.022, 0.01 + absDelta / 10000)
|
||||||
|
const damping = 0.76
|
||||||
|
scrollVelocity += delta * stiffness
|
||||||
|
scrollVelocity *= damping
|
||||||
|
|
||||||
|
const next = clampScrollY(current + scrollVelocity, scroller)
|
||||||
|
if (next === current) scrollVelocity = 0
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollAnimationFrame = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepVisible = (el: HTMLElement | null) => {
|
||||||
|
if (!keyboardFollowActive()) {
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollVelocity = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
scrollTargetY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = cursorScrollTarget(el)
|
||||||
|
if (target == null) {
|
||||||
|
scrollTargetY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTargetY = target
|
||||||
|
runSmoothCursorScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
|
||||||
|
scrollAnimationFrame = null
|
||||||
|
scrollTargetY = null
|
||||||
|
scrollVelocity = 0
|
||||||
|
keyboardFollowUntil = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return { markKeyboardFollow, keepVisible, cancel }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user