Compare commits

..
Author SHA1 Message Date
Philip Okugbe b29e130f26 fix: enhance lightbox (#2429)
* fix: close on click outside; smaller icon size

* fix: make ALT more visible
2026-08-26 02:24:02 +01:00
9 changed files with 116 additions and 39 deletions
@@ -4,8 +4,6 @@ import Lightbox, { type Slide } from "yet-another-react-lightbox";
import type { LightboxRequest } from "@/features/editor/atoms/editor-atoms";
import { getFileUrl } from "@/lib/config.ts";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css";
import Captions from "yet-another-react-lightbox/plugins/captions";
import Download from "yet-another-react-lightbox/plugins/download";
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import Video from "yet-another-react-lightbox/plugins/video";
@@ -53,13 +51,11 @@ function getFilename(src: string) {
function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
const src = getFileUrl(rawSrc);
const filename = getFilename(rawSrc);
const caption = alt || filename;
if (type === "video") {
return {
type: "video",
sources: [{ src, type: getVideoMimeType(rawSrc) }],
title: caption,
download: { url: src, filename },
};
} else {
@@ -67,7 +63,6 @@ function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
type: "image",
src,
alt: alt || undefined,
title: caption,
download: { url: src, filename },
};
}
@@ -124,6 +119,11 @@ export default function LightboxView({
const [pageSlides, setPageSlides] = useState<Slide[]>([]);
const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!open) setIsFullscreen(false);
}, [open]);
useEffect(() => {
if (!open) return;
@@ -161,9 +161,21 @@ export default function LightboxView({
close={onClose}
index={index}
slides={slides}
plugins={[Captions, Download, Fullscreen, Video, Zoom]}
styles={{ container: { backgroundColor: "rgba(0, 0, 0, 0.8)" } }}
captions={{ descriptionTextAlign: "center" }}
plugins={[Download, Fullscreen, Video, Zoom]}
styles={{
container: { backgroundColor: "rgba(0, 0, 0, 0.8)" },
icon: { width: 24, height: 24 },
toolbar: {
margin: 8,
borderRadius: 8,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
}}
controller={{ closeOnBackdropClick: !isFullscreen }}
on={{
enterFullscreen: () => setIsFullscreen(true),
exitFullscreen: () => setIsFullscreen(false),
}}
video={{ controls: true, playsInline: true }}
zoom={{
scrollToZoom: true,
@@ -38,6 +38,60 @@
line-height: var(--mantine-line-height-md);
}
.media-alt-badge {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 1;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: default;
user-select: none;
background-color: var(--mantine-color-body);
@mixin light {
border: 1px solid var(--mantine-color-gray-3);
color: var(--mantine-color-gray-7);
}
@mixin dark {
border: 1px solid var(--mantine-color-dark-4);
color: var(--mantine-color-dark-1);
}
@media print {
display: none;
}
&::after {
content: attr(data-alt);
display: none;
position: absolute;
right: 0;
bottom: calc(100% + 6px);
width: max-content;
max-width: 320px;
padding: 6px 10px;
border-radius: var(--mantine-radius-default);
font-size: var(--mantine-font-size-sm);
font-weight: 400;
letter-spacing: normal;
line-height: var(--mantine-line-height-sm);
white-space: pre-wrap;
overflow-wrap: break-word;
text-align: start;
background-color: var(--mantine-color-gray-9);
color: var(--mantine-color-white);
}
&:hover::after {
display: block;
}
}
.media-pulse {
animation: media-pulse 1.2s ease-in-out infinite;
@@ -48,7 +48,7 @@ export class SearchService {
const rankColumn = browseByFilters
? sql<number>`0`.as('rank')
: titleOnly
? sql<number>`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as(
? sql<number>`word_similarity(lower(${titleQuery}), lower(pages.title))`.as(
'rank',
)
: sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
@@ -84,9 +84,9 @@ export class SearchService {
.$if(!browseByFilters && titleOnly, (qb) =>
qb.where((eb) =>
eb(
sql`lower(f_unaccent(pages.title))`,
sql`lower(pages.title)`,
'like',
sql`lower(f_unaccent(${`%${titleLikeQuery}%`}))`,
sql`lower(${`%${titleLikeQuery}%`})`,
),
),
)
@@ -1,34 +1,12 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// f_unaccent must be schema-qualified in its body to be usable inside an index
// expression: Postgres 15+ builds indexes under a secured search_path
// (pg_catalog, pg_temp), so an unqualified unaccent() cannot be resolved during
// CREATE INDEX. Redefine f_unaccent in place (in whatever schema it already
// lives) with the unaccent function and dictionary both qualified.
await sql`
DO $migration$
DECLARE ext_schema text;
BEGIN
SELECT n.nspname INTO ext_schema
FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace
WHERE e.extname = 'unaccent';
EXECUTE format(
'CREATE OR REPLACE FUNCTION f_unaccent(text) RETURNS text '
|| 'LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT '
|| 'AS $f$ SELECT %I.unaccent(%L::regdictionary, $1) $f$',
ext_schema, ext_schema || '.unaccent');
END
$migration$;
`.execute(db);
await sql`CREATE INDEX IF NOT EXISTS pages_title_trgm_idx ON pages USING gin (lower(f_unaccent(title)) gin_trgm_ops)`.execute(
await sql`CREATE INDEX IF NOT EXISTS pages_title_trgm_idx ON pages USING gin (lower(title) gin_trgm_ops)`.execute(
db,
);
// separators normalized to spaces so space-typed queries match How_to_export.pdf
await sql`CREATE INDEX IF NOT EXISTS attachments_file_name_trgm_idx ON attachments USING gin (lower(f_unaccent(translate(file_name, '_.-', ' '))) gin_trgm_ops)`.execute(
await sql`CREATE INDEX IF NOT EXISTS attachments_file_name_trgm_idx ON attachments USING gin (lower(translate(file_name, '_.-', ' ')) gin_trgm_ops)`.execute(
db,
);
}
+5 -1
View File
@@ -2,7 +2,7 @@ import { Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils";
import { normalizeFileUrl, syncAltBadge } from "./media-utils";
export type DrawioResizeOptions = {
enabled: boolean;
@@ -293,6 +293,8 @@ export const Drawio = Node.create<DrawioOptions>({
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
currentNode = updatedNode;
return true;
},
@@ -310,6 +312,8 @@ export const Drawio = Node.create<DrawioOptions>({
const dom = nodeView.dom as HTMLElement;
syncAltBadge(nodeView.wrapper, node.attrs.alt);
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
+5 -1
View File
@@ -2,7 +2,7 @@ import { Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils";
import { normalizeFileUrl, syncAltBadge } from "./media-utils";
export type ExcalidrawResizeOptions = {
enabled: boolean;
@@ -293,6 +293,8 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
currentNode = updatedNode;
return true;
},
@@ -310,6 +312,8 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
const dom = nodeView.dom as HTMLElement;
syncAltBadge(nodeView.wrapper, node.attrs.alt);
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
+5 -1
View File
@@ -7,7 +7,7 @@ import {
} from "@tiptap/core";
import { ResizableNodeView } from "../resizable-nodeview";
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
import { normalizeFileUrl } from "../media-utils";
import { normalizeFileUrl, syncAltBadge } from "../media-utils";
export type ImageResizeOptions = {
enabled: boolean;
@@ -316,6 +316,8 @@ export const TiptapImage = Image.extend<ImageOptions>({
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
currentNode = updatedNode;
return true;
},
@@ -333,6 +335,8 @@ export const TiptapImage = Image.extend<ImageOptions>({
const dom = nodeView.dom as HTMLElement;
syncAltBadge(nodeView.wrapper, node.attrs.alt);
// Apply initial alignment
applyAlignment(dom, node.attrs.align || "center");
@@ -7,6 +7,27 @@ export function normalizeFileUrl(src: string): string {
return src || "";
}
export function syncAltBadge(wrapper: HTMLElement, alt: unknown): void {
const existing = wrapper.querySelector<HTMLElement>(
":scope > .media-alt-badge",
);
if (typeof alt !== "string" || !alt.trim()) {
existing?.remove();
return;
}
const badge = existing ?? document.createElement("span");
badge.dataset.alt = alt;
if (!existing) {
badge.className = "media-alt-badge";
badge.textContent = "ALT";
badge.setAttribute("aria-hidden", "true");
wrapper.appendChild(badge);
}
}
export type UploadFn = (
file: File,
editor: Editor,