mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
feat(attachments): editor inline image node (TASK-876) (#292)
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:  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
<img src="pad-attachment:UUID" alt="…">, 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 <img class="attachment-image" loading="lazy">
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 <dialog> 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 <dialog> 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).
This commit is contained in:
@@ -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 <dialog> 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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ``
|
||||
*
|
||||
* DOM contract — produced by both this extension's renderHTML AND by
|
||||
* markdown-it when it tokenizes ``:
|
||||
*
|
||||
* <img data-attachment-id="UUID" src="…?variant=thumb-md" alt="…">
|
||||
*
|
||||
* 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 `` keeps round-trips
|
||||
* idempotent. Without this storage, tiptap-markdown would fall back to
|
||||
* HTMLNode passthrough and output literal `<img …>` 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<string, unknown>;
|
||||
/**
|
||||
* 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<ReturnType> {
|
||||
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<AttachmentImageOptions>({
|
||||
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
|
||||
// `` to an <img>.
|
||||
{ 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 ``. 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(``);
|
||||
},
|
||||
parse: {
|
||||
// markdown-it's default image token already produces
|
||||
// <img src="pad-attachment:UUID" alt="…">; 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 <dialog> 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();
|
||||
}
|
||||
Reference in New Issue
Block a user