From f1ce9ca24aab63bb641488e10aa5ac9d4366faad Mon Sep 17 00:00:00 2001 From: xarmian Date: Wed, 29 Apr 2026 13:22:13 -0400 Subject: [PATCH] feat(attachments): editor inline image node (TASK-876) (#292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom Tiptap node for inline `pad-attachment:UUID` image references. Stores the attachment UUID (not a backend URL) so item content survives a storage-backend migration untouched. See DOC-865. Node shape: - uuid: string — the attachments-row UUID (required) - alt: string — preserved across save/reload for accessibility Markdown round-trip: - Serialize: ![alt](pad-attachment:UUID) via tiptap-markdown's addStorage.markdown.serialize, with [/] in the alt text escaped so brackets stay balanced. - Parse: markdown-it's default image token already produces …, captured by parseHTML rule img[src^="pad-attachment:"]. The alternate parseHTML rule img[data-attachment-id] catches editor-rendered HTML on copy/paste. Editor display: - addNodeView renders pointing at /api/v1/workspaces/{ws}/attachments/{id}?variant=thumb-md via an injected getDownloadUrl callback (Editor.svelte resolves the workspace slug from page.params at mount time, falling back to the workspace store). - Single-click opens a native lightbox with the original- resolution variant; multi-click events fall through so users can drag-select around the image. - atom: true means Backspace/Delete remove the image as a single unit and the cursor never lands inside the node. Lightbox styles live in app.css because the is appended to document.body, outside Editor.svelte's scoped style block. The configure() default returns the literal `pad-attachment:UUID` href — sufficient for markdown round-trip in headless / SSR contexts and a clearly-broken render in any environment that hasn't wired the URL builder, which is the right signal to fix. Parent: PLAN-866. Unblocks TASK-875 (the upload plugin needs a node to insert on success). --- web/src/app.css | 64 +++++ web/src/lib/components/editor/Editor.svelte | 14 + .../lib/components/editor/attachment-image.ts | 245 ++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 web/src/lib/components/editor/attachment-image.ts diff --git a/web/src/app.css b/web/src/app.css index 0d787499..9d29742a 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -215,6 +215,70 @@ input:focus, textarea:focus { border-bottom-color: var(--accent-blue); } +/* Attachment images — both inside the editor and in rendered markdown. + Lazy-loaded via the node's renderHTML/NodeView; styles below set a + sane default size so the editor doesn't reflow once thumbnails arrive. */ +.attachment-image { + max-width: 100%; + height: auto; + border-radius: var(--radius); + cursor: zoom-in; + display: block; + margin: 0.4em 0; +} +.attachment-image:hover { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-blue) 35%, transparent); +} + +/* Lightbox dialog — appended to document.body when an attachment image + is clicked, so styles must be global rather than scoped to Editor.svelte. + Uses the native element; the ::backdrop pseudo handles the + overlay shade. The dialog content fills the viewport with a centered + image and a single close button. */ +dialog.attachment-image-lightbox { + border: none; + background: transparent; + padding: 0; + max-width: 100vw; + max-height: 100vh; + overflow: visible; + color: #fff; +} +dialog.attachment-image-lightbox::backdrop { + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(2px); +} +dialog.attachment-image-lightbox .attachment-image-lightbox-img { + max-width: 95vw; + max-height: 95vh; + display: block; + margin: 0 auto; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); + cursor: zoom-out; + border-radius: var(--radius); +} +dialog.attachment-image-lightbox .attachment-image-lightbox-close { + position: fixed; + top: 16px; + right: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 28px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} +dialog.attachment-image-lightbox .attachment-image-lightbox-close:hover { + background: rgba(0, 0, 0, 0.85); +} + /* Light mode */ [data-theme="light"] { --bg-primary: #ffffff; diff --git a/web/src/lib/components/editor/Editor.svelte b/web/src/lib/components/editor/Editor.svelte index ef8e4d6d..f20cc116 100644 --- a/web/src/lib/components/editor/Editor.svelte +++ b/web/src/lib/components/editor/Editor.svelte @@ -280,8 +280,10 @@ import { formatItemRef, itemUrlId, type Item } from '$lib/types'; import { collectionStore } from '$lib/stores/collections.svelte'; import { workspaceStore } from '$lib/stores/workspace.svelte'; + import { api } from '$lib/api/client'; import { BlockDragHandle } from './block-drag-handle'; import { SLASH_ITEMS } from './block-types'; + import { AttachmentImage, type AttachmentVariant } from './attachment-image'; let { content = '', @@ -421,6 +423,17 @@ onMount(() => { if (!element) return; + // Resolve the workspace slug at mount time. The Editor lives inside + // a route that has page.params.workspace set; falling back to the + // workspace store covers code paths where the editor is rendered + // outside that route shape (e.g. component-driven previews). When + // neither is available, attachment images render the literal + // `pad-attachment:UUID` href and fail to load — clearly broken in + // the UI, which is the right signal for "no workspace context". + const wsSlug = page.params.workspace ?? workspaceStore.current?.slug ?? ''; + const getAttachmentUrl = (uuid: string, variant?: AttachmentVariant) => + wsSlug ? api.attachments.downloadUrl(wsSlug, uuid, variant) : `pad-attachment:${uuid}`; + const extensions = [ StarterKit.configure({ codeBlock: false, @@ -454,6 +467,7 @@ transformCopiedText: true, }), BlockDragHandle, + AttachmentImage.configure({ getDownloadUrl: getAttachmentUrl }), ]; editor = new Editor({ diff --git a/web/src/lib/components/editor/attachment-image.ts b/web/src/lib/components/editor/attachment-image.ts new file mode 100644 index 00000000..e58e4b53 --- /dev/null +++ b/web/src/lib/components/editor/attachment-image.ts @@ -0,0 +1,245 @@ +/** + * AttachmentImage — Tiptap node for `pad-attachment:UUID` image references. + * + * Stores the attachment UUID (not a backend URL) so item content survives + * a storage-backend migration untouched. See DOC-865 for the architecture + * and `web/src/lib/markdown/attachments.ts` for the read-only render path + * (the same UUID rendering is implemented there for shared/exported items). + * + * Node shape: + * - `uuid`: string — the attachment row's UUID (required) + * - `alt` : string | null — the alt text from `![alt](pad-attachment:UUID)` + * + * DOM contract — produced by both this extension's renderHTML AND by + * markdown-it when it tokenizes `![alt](pad-attachment:UUID)`: + * + * … + * + * The two `parseHTML` rules below cover both forms — the editor's own + * render path (data-attachment-id) and the markdown round-trip path + * (src starts with `pad-attachment:`). Either one normalizes back to a + * single attachmentImage node with the canonical attributes. + * + * Markdown serialization is opt-in via tiptap-markdown's `addStorage` + * hook — emitting `![alt](pad-attachment:UUID)` keeps round-trips + * idempotent. Without this storage, tiptap-markdown would fall back to + * HTMLNode passthrough and output literal `` tags. + */ + +import { Node, mergeAttributes } from '@tiptap/core'; + +/** Variants the download URL builder must support. Mirrors the API. */ +export type AttachmentVariant = 'thumb-sm' | 'thumb-md' | 'original'; + +/** URL builder injected by Editor.svelte at configure time. */ +export type AttachmentUrlBuilder = (uuid: string, variant?: AttachmentVariant) => string; + +export interface AttachmentImageOptions { + HTMLAttributes: Record; + /** + * Resolves an attachment UUID to a download URL. Default implementation + * returns the literal `pad-attachment:UUID` reference — sufficient for + * markdown round-trip, but the editor will configure it to the actual + * `/api/v1/workspaces/{ws}/attachments/{id}` endpoint so images render. + */ + getDownloadUrl: AttachmentUrlBuilder; +} + +declare module '@tiptap/core' { + interface Commands { + attachmentImage: { + /** + * Insert an attachment image at the current selection. + * Used by the upload plugin in TASK-875. + */ + setAttachmentImage: (options: { uuid: string; alt?: string | null }) => ReturnType; + }; + } +} + +const PAD_ATTACHMENT_PREFIX = 'pad-attachment:'; + +/** Escape `[` and `]` in alt text so the markdown serializer's brackets stay balanced. */ +function escapeMarkdownAlt(s: string): string { + return s.replace(/[\[\]]/g, (m) => '\\' + m); +} + +export const AttachmentImage = Node.create({ + name: 'attachmentImage', + + // Inline atom — same shape as a regular Image. `atom: true` prevents + // keyboard navigation from putting the cursor *inside* the node, so + // Backspace/Delete remove it as a single unit. + group: 'inline', + inline: true, + atom: true, + selectable: true, + draggable: true, + + addOptions() { + return { + HTMLAttributes: {}, + getDownloadUrl: (uuid: string) => `${PAD_ATTACHMENT_PREFIX}${uuid}`, + }; + }, + + addAttributes() { + return { + uuid: { + default: null, + parseHTML: (element) => { + // Editor render output uses data-attachment-id; markdown-it's + // image token output uses src=pad-attachment:UUID. Accept both + // so the round-trip lands on the same canonical node. + const dataId = element.getAttribute('data-attachment-id'); + if (dataId) return dataId; + const src = element.getAttribute('src') ?? ''; + if (src.startsWith(PAD_ATTACHMENT_PREFIX)) { + return src.slice(PAD_ATTACHMENT_PREFIX.length); + } + return null; + }, + renderHTML: (attrs) => (attrs.uuid ? { 'data-attachment-id': attrs.uuid } : {}), + }, + alt: { + default: null, + parseHTML: (element) => element.getAttribute('alt'), + renderHTML: (attrs) => (attrs.alt ? { alt: attrs.alt } : {}), + }, + }; + }, + + parseHTML() { + return [ + // Canonical editor form — produced by our own renderHTML and also + // by Codex / external HTML pastes that include the data attribute. + { tag: 'img[data-attachment-id]' }, + // Markdown round-trip form — produced when markdown-it renders + // `![alt](pad-attachment:UUID)` to an . + { tag: 'img[src^="pad-attachment:"]' }, + ]; + }, + + renderHTML({ HTMLAttributes, node }) { + const uuid = (node.attrs.uuid as string | null) ?? ''; + const src = uuid ? this.options.getDownloadUrl(uuid, 'thumb-md') : ''; + return [ + 'img', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + src, + loading: 'lazy', + class: 'attachment-image', + }), + ]; + }, + + /** + * NodeView attaches a click handler that opens the original-resolution + * variant in a lightbox dialog. Tiptap calls renderHTML for clipboard + * / `getHTML()` / serialization paths, so both must stay in sync — the + * NodeView is only the live editor representation. + */ + addNodeView() { + return ({ node }) => { + const img = document.createElement('img'); + img.classList.add('attachment-image'); + img.loading = 'lazy'; + const uuid = (node.attrs.uuid as string | null) ?? ''; + const alt = (node.attrs.alt as string | null) ?? ''; + if (uuid) { + img.src = this.options.getDownloadUrl(uuid, 'thumb-md'); + img.setAttribute('data-attachment-id', uuid); + } + if (alt) img.alt = alt; + + img.addEventListener('click', (event) => { + // In a contenteditable, ProseMirror handles selection on + // mousedown; intercept click so a single click opens the + // lightbox without being swallowed as "click into the + // editor selection". Multi-click events (double-click, etc.) + // fall through so users can still drag-select around the + // image without triggering the modal. + if (event.detail > 1) return; + event.preventDefault(); + event.stopPropagation(); + if (!uuid) return; + const fullUrl = this.options.getDownloadUrl(uuid, 'original'); + openImageLightbox(fullUrl, alt); + }); + + return { dom: img }; + }; + }, + + addStorage() { + return { + markdown: { + /** + * Emit `![alt](pad-attachment:UUID)`. tiptap-markdown's serializer + * expects this signature — the `state` object exposes `write`, + * `closeBlock`, etc. We only need `write` here since the node is + * inline. + */ + serialize(state: { write: (s: string) => void }, node: { attrs: { uuid: unknown; alt: unknown } }) { + const uuid = node.attrs.uuid; + if (typeof uuid !== 'string' || uuid === '') return; + const altRaw = typeof node.attrs.alt === 'string' ? node.attrs.alt : ''; + state.write(`![${escapeMarkdownAlt(altRaw)}](${PAD_ATTACHMENT_PREFIX}${uuid})`); + }, + parse: { + // markdown-it's default image token already produces + // …; our parseHTML + // rules pick that up. No custom markdown-it rule needed. + }, + }, + }; + }, + + addCommands() { + return { + setAttachmentImage: + (options) => + ({ commands }) => + commands.insertContent({ + type: this.name, + attrs: { uuid: options.uuid, alt: options.alt ?? null }, + }), + }; + }, +}); + +/** + * Open a centered showing the full-resolution attachment. + * Closes on backdrop click, the close button, or the Esc key. + */ +function openImageLightbox(fullUrl: string, alt: string): void { + if (typeof document === 'undefined') return; + const dialog = document.createElement('dialog'); + dialog.className = 'attachment-image-lightbox'; + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'attachment-image-lightbox-close'; + closeBtn.setAttribute('aria-label', 'Close image preview'); + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', () => closeLightbox(dialog)); + + const img = document.createElement('img'); + img.className = 'attachment-image-lightbox-img'; + img.src = fullUrl; + if (alt) img.alt = alt; + // Prevent clicks on the image itself from bubbling to the backdrop + // handler below (which closes the dialog). + img.addEventListener('click', (event) => event.stopPropagation()); + + dialog.append(closeBtn, img); + dialog.addEventListener('click', () => closeLightbox(dialog)); + dialog.addEventListener('close', () => dialog.remove()); + + document.body.appendChild(dialog); + dialog.showModal(); +} + +function closeLightbox(dialog: HTMLDialogElement): void { + if (dialog.open) dialog.close(); +}