From 64ed2c5a6ef1961c4746d73b8895953bf1c79430 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 10:00:43 +0200 Subject: [PATCH 1/9] improve markdown embed drag and drop Show a drop cursor while dragging over the editor, and put dropped embeds on their own line when dropped into a line with content. --- codemirror-markdown/src/extensions/embed.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/codemirror-markdown/src/extensions/embed.ts b/codemirror-markdown/src/extensions/embed.ts index 3ded21c..e0a88ec 100644 --- a/codemirror-markdown/src/extensions/embed.ts +++ b/codemirror-markdown/src/extensions/embed.ts @@ -4,6 +4,7 @@ import { WidgetType, ViewPlugin, ViewUpdate, + dropCursor, type DecorationSet, } from "@codemirror/view"; import { Range } from "@codemirror/state"; @@ -287,7 +288,15 @@ async function fileDropRefs(files: FileList): Promise { function insertRefs(view: EditorView, pos: number, refs: DocRef[]): void { if (refs.length === 0) return; - const text = refs.map(embedSyntax).join("\n\n"); + let text = refs.map(embedSyntax).join("\n\n"); + // When dropping onto a line that has content, put the embed on its own line: + // break before it unless dropped at the line start, and after it unless + // dropped at the line end. + const line = view.state.doc.lineAt(pos); + if (/\S/.test(line.text)) { + if (pos > line.from) text = "\n" + text; + if (pos < line.to) text = text + "\n"; + } view.dispatch({ changes: { from: pos, insert: text }, selection: { anchor: pos + text.length }, @@ -366,5 +375,7 @@ const embedPlugin = ViewPlugin.fromClass( ); export function markdownEmbed() { - return [embedPlugin, embedTheme, embedDropHandlers()]; + // `dropCursor` uses event observers, so it keeps working even though our + // dragover handler claims the event. + return [embedPlugin, embedTheme, embedDropHandlers(), dropCursor()]; } From c0780d587ad87daad6baeb9bb00c6867e2514714 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 12:43:12 +0200 Subject: [PATCH 2/9] unify doc embeds behind a shared embed tool New `embed` package (TypeScript + vite) registers an unlisted wildcard patchwork:tool that renders the embed chrome (live title with rename, open-with tool picker, open button) around a nested patchwork-view. codemirror-markdown and tldraw4 now mount it via tool-id="embed" instead of each hand-rolling the same titlebar, and persist tool picks from its patchwork:embed-tool-changed event (marker text / shape props). --- codemirror-markdown/src/extensions/embed.ts | 113 +++--- codemirror-markdown/src/extensions/icons.ts | 5 - codemirror-markdown/src/themes/embed.ts | 53 +-- embed/package.json | 25 ++ embed/src/embed.css | 174 +++++++++ embed/src/index.ts | 22 ++ embed/src/tool.ts | 317 ++++++++++++++++ embed/tsconfig.json | 18 + embed/vite.config.ts | 27 ++ tldraw4/src/PatchworkDocShape.tsx | 391 ++------------------ 10 files changed, 666 insertions(+), 479 deletions(-) delete mode 100644 codemirror-markdown/src/extensions/icons.ts create mode 100644 embed/package.json create mode 100644 embed/src/embed.css create mode 100644 embed/src/index.ts create mode 100644 embed/src/tool.ts create mode 100644 embed/tsconfig.json create mode 100644 embed/vite.config.ts diff --git a/codemirror-markdown/src/extensions/embed.ts b/codemirror-markdown/src/extensions/embed.ts index e0a88ec..bc89f71 100644 --- a/codemirror-markdown/src/extensions/embed.ts +++ b/codemirror-markdown/src/extensions/embed.ts @@ -15,14 +15,19 @@ import { parseAutomergeUrl, } from "@automerge/automerge-repo/slim"; import { embedTheme } from "../themes/embed.ts"; -import { openLinkIcon } from "./icons.ts"; /** - * Widget to render an embedded element in a CodeMirror editor. + * Widget that renders an embedded doc through the shared "embed" tool (the + * `embed` package): draws the title bar, + * tool picker, and open button, and nests the actual content view inside. + * The inner tool travels via the `embed-tool-id` attribute; when the user + * picks a different tool the frame emits `patchwork:embed-tool-changed`, + * which we translate into a marker rewrite so the choice persists in the + * markdown (and the widget recreates with the new tool). */ class EmbedWidget extends WidgetType { readonly docId: DocumentId; - // `null` means "no explicit tool": falls back to the + // `null` means "no explicit tool": the embed frame falls back to the // default tool registered for the document's datatype. readonly toolId: string | null; readonly embedText: string; @@ -38,72 +43,53 @@ class EmbedWidget extends WidgetType { return other.docId === this.docId && other.toolId === this.toolId; } - toDOM() { + toDOM(view: EditorView) { const container = document.createElement("div"); container.className = "cm-embed"; - const label = document.createElement("div"); - label.className = "cm-embed-label"; - - const labelText = document.createElement("span"); - labelText.className = "cm-embed-label-text"; - labelText.textContent = this.embedText; - labelText.title = "Click to edit"; - - const openLink = document.createElement("button"); - openLink.className = "cm-embed-open-link"; - openLink.title = "Open document"; - openLink.innerHTML = openLinkIcon; - - openLink.onclick = (e) => { - e.preventDefault(); - e.stopPropagation(); - const params = new URLSearchParams(); - params.set("doc", this.docId); - // Tool-less embeds open with the datatype's default tool. - if (this.toolId) params.set("tool", this.toolId); - window.location.hash = params.toString(); - }; - - label.appendChild(labelText); - label.appendChild(openLink); - - const view = document.createElement("patchwork-view"); + const patchworkView = document.createElement("patchwork-view"); // Name the doc without heads. Resolution (OverlayRepo + the drafts // `repo:handle-descriptor` answer) pins it to the active checkpoint when one // is checked out, so the embed freezes with the document it lives in; // otherwise it renders live. - view.setAttribute("doc-url", `automerge:${this.docId}`); - if (this.toolId) view.setAttribute("tool-id", this.toolId); + patchworkView.setAttribute("doc-url", `automerge:${this.docId}`); + patchworkView.setAttribute("tool-id", "embed"); + if (this.toolId) patchworkView.setAttribute("embed-tool-id", this.toolId); // The needs an explicit, non-zero height set inline: // without it the element collapses to 0px and the embedded tool never // renders. (The stylesheet rule isn't reliably applied here, so we set it // directly on the element.) - view.style.display = "block"; - view.style.height = "500px"; - view.style.width = "100%"; + patchworkView.style.display = "block"; + patchworkView.style.height = "500px"; + patchworkView.style.width = "100%"; - container.appendChild(label); - container.appendChild(view); + // Persist tool picks made in the embed frame into the marker text. + container.addEventListener("patchwork:embed-tool-changed", (e) => { + const toolId = (e as CustomEvent<{ toolId?: string }>).detail?.toolId; + if (toolId) this.setTool(view, container, toolId); + }); + container.appendChild(patchworkView); return container; } - ignoreEvent(e: Event) { - if (e.type === "mousedown" && e.target instanceof Element) { - // Allow clicks on the label text to pass through for editing - if (e.target.classList.contains("cm-embed-label-text")) { - return false; // Let the editor handle it - } - // Block clicks on the open link button (let button handle it) - if ( - e.target.classList.contains("cm-embed-open-link") || - e.target.closest(".cm-embed-open-link") - ) { - return true; // Block from editor - } - } - // Block other events from reaching the editor (let patchwork-view handle them) + private setTool( + view: EditorView, + container: HTMLElement, + toolId: string + ): void { + const from = view.posAtDOM(container); + const to = from + this.embedText.length; + // Only rewrite if the marker is still exactly where this widget maps to. + if (view.state.doc.sliceString(from, to) !== this.embedText) return; + view.dispatch({ + changes: { from, to, insert: embedSyntax({ docId: this.docId, toolId }) }, + }); + } + + ignoreEvent() { + // The embed is atomic: no click-to-edit, so block all events from the + // editor and let the embed frame / patchwork-view handle them. return true; } } @@ -123,7 +109,6 @@ const EMBED_PATTERN = /\[patchwork:([^/\]]+)(?:\/([^\]]+))?\]/g; function getEmbedLinks(view: EditorView) { const widgets: Range[] = []; const { state } = view; - const selection = state.selection.main; // Scan only the visible ranges. Markers never span a line break, and // CodeMirror's visible ranges are line-aligned, so a marker is either fully @@ -137,14 +122,6 @@ function getEmbedLinks(view: EditorView) { const matchTo = matchFrom + m[0].length; const [matchText, docId, toolId] = m; - // Show raw text (no widget) while the cursor is on the marker or a - // selection spans it, so it can be edited. - const cursorInLink = - selection.from >= matchFrom && selection.from <= matchTo; - const selectionSpansLink = - selection.from < matchFrom && selection.to > matchTo; - if (cursorInLink || selectionSpansLink) continue; - if (!isValidDocumentId(docId)) continue; const embed = Decoration.replace({ @@ -362,15 +339,21 @@ const embedPlugin = ViewPlugin.fromClass( } update(update: ViewUpdate) { - // Recompute when the document changes, the selection moves, or the - // viewport scrolls (so newly-visible markers get decorated). - if (update.docChanged || update.selectionSet || update.viewportChanged) { + // Recompute when the document changes or the viewport scrolls (so + // newly-visible markers get decorated). + if (update.docChanged || update.viewportChanged) { this.decorations = getEmbedLinks(update.view); } } }, { decorations: (v) => v.decorations, + // Atomic: the cursor skips over embeds and backspace/delete removes the + // whole marker instead of popping it open as editable text. + provide: (plugin) => + EditorView.atomicRanges.of( + (view) => view.plugin(plugin)?.decorations ?? Decoration.none + ), } ); diff --git a/codemirror-markdown/src/extensions/icons.ts b/codemirror-markdown/src/extensions/icons.ts deleted file mode 100644 index a027bc2..0000000 --- a/codemirror-markdown/src/extensions/icons.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const openLinkIcon = ` - - - -`; diff --git a/codemirror-markdown/src/themes/embed.ts b/codemirror-markdown/src/themes/embed.ts index 991eb38..2afd118 100644 --- a/codemirror-markdown/src/themes/embed.ts +++ b/codemirror-markdown/src/themes/embed.ts @@ -1,5 +1,7 @@ import { EditorView } from "@codemirror/view"; +// Only the outer container: all embed chrome (title bar, tool picker, open +// button) lives in the shared "embed" tool mounted via . export const embedTheme = EditorView.baseTheme({ ".cm-embed": { display: "block", @@ -13,58 +15,9 @@ export const embedTheme = EditorView.baseTheme({ "&dark .cm-embed": { borderColor: "#333", }, - ".cm-embed-label": { - fontFamily: "monospace", - padding: "4px 8px", - borderBottom: "1px solid", - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - cursor: "text", - display: "flex", - alignItems: "center", - gap: "8px", - }, - ".cm-embed-label-text": { - flex: "1", - minWidth: "0", - overflow: "hidden", - textOverflow: "ellipsis", - opacity: 0.7, - "&:hover": { - opacity: 1, - }, - }, - ".cm-embed-open-link": { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: "16px", - height: "16px", - cursor: "pointer", - flexShrink: "0", - border: "none", - background: "none", - padding: "0", - color: "inherit", - opacity: 0.7, - "&:hover": { - opacity: 1, - }, - }, - "&light .cm-embed-label": { - borderBottomColor: "#ddd", - }, - "&dark .cm-embed-label": { - borderBottomColor: "#333", - }, - ".cm-embed patchwork-view": { + ".cm-embed > patchwork-view": { display: "block", height: "500px", width: "100%", }, - ".cm-embed patchwork-view > *": { - height: "100%", - width: "100%", - }, }); diff --git a/embed/package.json b/embed/package.json new file mode 100644 index 0000000..04fed6e --- /dev/null +++ b/embed/package.json @@ -0,0 +1,25 @@ +{ + "name": "embed", + "version": "0.0.1", + "description": "Shared embed frame: title bar, tool picker, and open button around a nested patchwork-view", + "type": "module", + "main": "./dist/index.js", + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit", + "push": "pnpm build && pushwork sync", + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + }, + "dependencies": { + "@inkandswitch/patchwork-bootloader": "^0.2.8", + "@inkandswitch/patchwork-elements": "1.0.0", + "@inkandswitch/patchwork-filesystem": "^0.0.8", + "@inkandswitch/patchwork-plugins": "^0.0.11" + }, + "devDependencies": { + "@automerge/automerge-repo": "^2.6.0-subduction.9", + "typescript": "~5.9.3", + "vite": "^5.4.21", + "vite-plugin-css-injected-by-js": "^3.5.2" + } +} diff --git a/embed/src/embed.css b/embed/src/embed.css new file mode 100644 index 0000000..6ed20a5 --- /dev/null +++ b/embed/src/embed.css @@ -0,0 +1,174 @@ +@layer package { + :root, + :host, + [theme] { + --embed-frame-bg: var(--editor-fill, white); + --embed-frame-fg: var(--editor-line, black); + --embed-frame-muted: var(--editor-line-offset-30, #666); + --embed-frame-border: var(--editor-fill-offset-20, #ddd); + --embed-frame-hover: var(--editor-fill-offset-10, #f4f4f4); + --embed-frame-highlight: var(--editor-fill-offset-10, #f0f0f0); + --embed-frame-family: var(--editor-family-sans, system-ui, sans-serif); + } +} + +.embed-frame { + display: flex; + flex-direction: column; + height: 100%; + background: var(--embed-frame-bg); + color: var(--embed-frame-fg); + font-family: var(--embed-frame-family); +} + +.embed-frame .header { + display: flex; + align-items: center; + gap: var(--studio-space-sm, 0.5rem); + padding: var(--studio-space-2xs, 0.25rem) var(--studio-space-sm, 0.5rem); + border-bottom: 1px solid var(--embed-frame-border); + flex-shrink: 0; +} + +.embed-frame .title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + font-size: 0.85em; + cursor: default; +} + +.embed-frame .title[data-renamable] { + cursor: text; +} + +.embed-frame .rename-input { + flex: 1; + min-width: 0; + font: inherit; + font-size: 0.85em; + color: inherit; + background: none; + border: 1px solid var(--embed-frame-border); + border-radius: var(--studio-radius-sm, 4px); + padding: 0 var(--studio-space-2xs, 0.25rem); + outline: none; +} + +.embed-frame .tool-btn { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 var(--studio-space-sm, 0.5rem); + border: 1px solid var(--embed-frame-border); + border-radius: var(--studio-radius-round, 9999px); + background: none; + color: var(--embed-frame-muted); + font: inherit; + font-size: 0.75em; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + transition: background var(--studio-transition-fast, 0.1s ease); +} + +.embed-frame .tool-btn:hover { + background: var(--embed-frame-hover); + color: var(--embed-frame-fg); +} + +.embed-frame .open-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + background: none; + color: inherit; + padding: 0; + cursor: pointer; + opacity: 0.7; + flex-shrink: 0; +} + +.embed-frame .open-btn:hover { + opacity: 1; +} + +.embed-frame .content { + flex: 1; + min-height: 0; + position: relative; +} + +.embed-frame .content > patchwork-view { + display: block; + width: 100%; + height: 100%; +} + +.embed-frame .tool-menu[popover] { + position: fixed; + inset: unset; + margin: 0; + padding: 0; + min-width: 180px; + border: 1px solid var(--embed-frame-border); + border-radius: var(--studio-radius-sm, 4px); + background: var(--embed-frame-bg); + color: var(--embed-frame-fg); + box-shadow: var(--studio-shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.08)); + overflow: hidden; + font-family: var(--embed-frame-family); + font-size: 0.85em; +} + +.embed-frame .tool-menu .search { + display: block; + width: 100%; + padding: var(--studio-space-xs, 0.375rem) var(--studio-space-sm, 0.5rem); + border: none; + border-bottom: 1px solid var(--embed-frame-border); + background: none; + font: inherit; + color: inherit; + outline: none; + box-sizing: border-box; +} + +.embed-frame .tool-menu .list { + max-height: 200px; + overflow-y: auto; + padding: var(--studio-space-2xs, 0.25rem); +} + +.embed-frame .tool-menu .item { + display: block; + width: 100%; + padding: var(--studio-space-2xs, 0.25rem) var(--studio-space-sm, 0.5rem); + border: none; + border-radius: var(--studio-radius-sm, 4px); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; +} + +.embed-frame .tool-menu .item:hover, +.embed-frame .tool-menu .item[data-highlight] { + background: var(--embed-frame-highlight); +} + +.embed-frame .tool-menu .item[data-current] { + font-weight: 600; +} diff --git a/embed/src/index.ts b/embed/src/index.ts new file mode 100644 index 0000000..c5d7768 --- /dev/null +++ b/embed/src/index.ts @@ -0,0 +1,22 @@ +// Entry module: metadata only. Patchwork evaluates it inside a worker (no +// importmap, no DOM), so no runtime imports and no behaviour here — the +// implementation loads lazily on the main thread. Type-only imports are +// erased at compile time and are safe. +import type { Tool } from "@inkandswitch/patchwork-plugins"; + +export const plugins: Tool[] = [ + { + type: "patchwork:tool", + id: "embed", + name: "Embed", + icon: "PictureInPicture2", + supportedDatatypes: "*", + // `unlisted` is load-bearing: it keeps "Embed" out of Open With menus + // AND out of getFallbackTool, so a tool-less nested view can never fall + // back to the embed frame itself (which would recurse forever). + unlisted: true, + async load() { + return (await import("./tool")).default; + }, + }, +]; diff --git a/embed/src/tool.ts b/embed/src/tool.ts new file mode 100644 index 0000000..05a322d --- /dev/null +++ b/embed/src/tool.ts @@ -0,0 +1,317 @@ +// The embed frame: chrome around a nested . Hosts (codemirror +// markers, tldraw shapes, …) mount it via +// and pass the inner tool through the `embed-tool-id` attribute — the tool +// receives the element itself, so it can read attributes off +// it and dispatch events from it. +// +// Emits `patchwork:embed-tool-changed` (bubbles, composed) when the user picks +// a different tool, so the host can persist the choice wherever it lives +// (marker text, shape props, …). + +import { + getFallbackTool, + getRegistry, + getSupportedToolsForType, + isLoadedPlugin, + type DatatypeImplementation, + type ToolDescription, + type ToolImplementation, +} from "@inkandswitch/patchwork-plugins"; +import { getType } from "@inkandswitch/patchwork-filesystem"; +import { openDocument } from "@inkandswitch/patchwork-elements"; +import "./embed.css"; + +const EMBED_TOOL_ID = "embed"; + +const EmbedFrame: ToolImplementation = (handle, element) => { + let currentToolId = element.getAttribute("embed-tool-id") || null; + let datatype: DatatypeImplementation | null = null; + + const root = document.createElement("div"); + root.className = "embed-frame"; + + const header = document.createElement("div"); + header.className = "header"; + + const titleEl = document.createElement("span"); + titleEl.className = "title"; + titleEl.textContent = "\u2026"; + + const toolButton = document.createElement("button"); + toolButton.className = "tool-btn"; + toolButton.title = "Open with\u2026"; + + const openButton = document.createElement("button"); + openButton.className = "open-btn"; + openButton.title = "Open document"; + openButton.innerHTML = openIcon; + + const content = document.createElement("div"); + content.className = "content"; + + const view = document.createElement("patchwork-view"); + view.setAttribute("doc-url", handle.url); + if (currentToolId) view.setAttribute("tool-id", currentToolId); + + const menu = document.createElement("div"); + menu.className = "tool-menu"; + menu.popover = "auto"; + menu.addEventListener("toggle", (e) => { + if ((e as { newState?: string }).newState === "closed") { + menu.replaceChildren(); + } + }); + + header.append(titleEl, toolButton, openButton); + content.append(view); + root.append(header, content, menu); + element.append(root); + + titleEl.onclick = startRename; + toolButton.onclick = () => toggleMenu(); + openButton.onclick = () => + openDocument(element, handle.url, currentToolId || undefined); + + function renderTitle(): void { + titleEl.textContent = getTitle(); + } + + function getTitle(): string { + try { + return datatype?.getTitle(handle.doc()) || "Untitled"; + } catch { + // getTitle may throw if the doc shape doesn't match the datatype + return "Untitled"; + } + } + + function renderToolButton(): void { + toolButton.textContent = currentToolName(); + } + + function currentToolName(): string { + if (currentToolId) { + const tool = getRegistry("patchwork:tool").get( + currentToolId + ); + return tool?.name ?? currentToolId; + } + try { + return getFallbackTool(handle.doc())?.name ?? "Open with\u2026"; + } catch { + return "Open with\u2026"; + } + } + + function setTool(toolId: string): void { + if (!toolId || toolId === currentToolId) return; + currentToolId = toolId; + view.setAttribute("tool-id", toolId); + renderToolButton(); + element.dispatchEvent( + new CustomEvent("patchwork:embed-tool-changed", { + detail: { url: handle.url, toolId }, + bubbles: true, + composed: true, + }) + ); + } + + // Rename in place: swap the title for an input; commit via the datatype's + // setTitle so the canonical name updates for every view of the doc. + function startRename(): void { + if (!datatype?.setTitle) return; + const input = document.createElement("input"); + input.className = "rename-input"; + input.value = getTitle(); + let done = false; + const finish = (commit: boolean) => { + if (done) return; + done = true; + const trimmed = input.value.trim(); + if (commit && trimmed && trimmed !== getTitle()) { + handle.change((d: any) => datatype!.setTitle!(d, trimmed)); + } + input.replaceWith(titleEl); + renderTitle(); + }; + input.onkeydown = (e) => { + e.stopPropagation(); + if (e.key === "Enter") finish(true); + if (e.key === "Escape") finish(false); + }; + input.onblur = () => finish(true); + titleEl.replaceWith(input); + input.focus(); + input.select(); + } + + // Replicates the sidebar's "Open with" menu: a search box filtering the + // suggested tools, Enter on a non-match forces the typed tool id. + function toggleMenu(): void { + if (menu.matches(":popover-open")) { + menu.hidePopover(); + return; + } + buildMenu(); + const rect = toolButton.getBoundingClientRect(); + const menuWidth = Math.max(rect.width, 180); + menu.style.top = `${rect.bottom + 2}px`; + menu.style.minWidth = `${menuWidth}px`; + if (rect.left + menuWidth > window.innerWidth - 8) { + menu.style.left = ""; + menu.style.right = `${window.innerWidth - rect.right}px`; + } else { + menu.style.right = ""; + menu.style.left = `${rect.left}px`; + } + menu.showPopover(); + } + + function buildMenu(): void { + menu.replaceChildren(); + const tools = listTools(); + + const input = document.createElement("input"); + input.className = "search"; + input.placeholder = "Search or enter tool id\u2026"; + menu.append(input); + + const list = document.createElement("div"); + list.className = "list"; + menu.append(list); + + let highlighted = 0; + let filtered: ToolDescription[] = []; + + const pick = (toolId: string) => { + menu.hidePopover(); + setTool(toolId); + }; + + const highlightAt = (i: number) => { + highlighted = i; + for (const [j, el] of [...list.children].entries()) { + el.toggleAttribute("data-highlight", j === i); + } + }; + + const renderList = () => { + const q = input.value.trim().toLowerCase(); + filtered = tools.filter( + (t) => + !q || + t.name.toLowerCase().includes(q) || + t.id.toLowerCase().includes(q) + ); + list.replaceChildren(); + filtered.forEach((t, i) => { + const item = document.createElement("button"); + item.className = "item"; + if (i === highlighted) item.toggleAttribute("data-highlight", true); + if (t.id === currentToolId) item.toggleAttribute("data-current", true); + item.textContent = t.name || t.id; + item.addEventListener("click", () => pick(t.id)); + item.addEventListener("pointerenter", () => highlightAt(i)); + list.append(item); + }); + }; + + input.addEventListener("input", () => { + highlighted = 0; + renderList(); + }); + + input.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "ArrowDown") { + e.preventDefault(); + highlightAt(Math.min(highlighted + 1, filtered.length - 1)); + list.children[highlighted]?.scrollIntoView({ block: "nearest" }); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + highlightAt(Math.max(highlighted - 1, 0)); + list.children[highlighted]?.scrollIntoView({ block: "nearest" }); + } else if (e.key === "Enter") { + e.preventDefault(); + const q = input.value.trim(); + if (highlighted >= 0 && highlighted < filtered.length) { + pick(filtered[highlighted].id); + } else if (q) { + // Force a tool id that isn't among the suggestions. + pick(q); + } + } else if (e.key === "Escape") { + menu.hidePopover(); + } + }); + + renderList(); + queueMicrotask(() => input.focus()); + } + + function listTools(): ToolDescription[] { + const doc = handle.doc(); + const type = doc && getType(doc); + if (!type) return []; + const list = getSupportedToolsForType(type).filter( + (t) => !t.unlisted && !t.forTitleBar && t.id !== EMBED_TOOL_ID + ); + // Datatype-specific tools before the generic wildcard ones. + list.sort((a, b) => Number(isWildcard(a)) - Number(isWildcard(b))); + return list; + } + + function onDocChange(): void { + renderTitle(); + if (!currentToolId) renderToolButton(); + } + + // Load the datatype so getTitle/setTitle work and the tools it ships with + // get registered, then re-render whatever depended on it. + async function loadDatatype(): Promise { + const doc = handle.doc(); + const type = doc && getType(doc); + if (!type) return; + const registry = getRegistry("patchwork:datatype"); + try { + await registry.load(type); + } catch { + // datatype unavailable; keep the fallbacks + } + const loaded = registry.get(type); + if (loaded && isLoadedPlugin(loaded)) { + datatype = loaded.module as DatatypeImplementation; + } + titleEl.toggleAttribute("data-renamable", Boolean(datatype?.setTitle)); + if (datatype?.setTitle) titleEl.title = "Click to rename"; + renderTitle(); + renderToolButton(); + } + + renderTitle(); + renderToolButton(); + handle.on("change", onDocChange); + void loadDatatype(); + + return () => { + handle.off("change", onDocChange); + root.remove(); + }; +}; + +export default EmbedFrame; + +function isWildcard(tool: ToolDescription): boolean { + return ( + tool.supportedDatatypes === "*" || + (Array.isArray(tool.supportedDatatypes) && + tool.supportedDatatypes.includes("*")) + ); +} + +const openIcon = ` + + + +`; diff --git a/embed/tsconfig.json b/embed/tsconfig.json new file mode 100644 index 0000000..26a5844 --- /dev/null +++ b/embed/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/embed/vite.config.ts b/embed/vite.config.ts new file mode 100644 index 0000000..ed88e36 --- /dev/null +++ b/embed/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vite"; +import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; +import external from "@inkandswitch/patchwork-bootloader/externals"; + +export default defineConfig({ + base: "./", + // Inject each chunk's CSS when that chunk loads: the entry (which Patchwork + // evaluates inside a worker to read `plugins`) stays DOM-free, and the + // styles ride with the lazily-imported tool chunk on the main thread. + plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })], + build: { + minify: false, + sourcemap: true, + cssCodeSplit: true, + rollupOptions: { + external, + input: "./src/index.ts", + output: { + format: "es", + entryFileNames: "[name].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "assets/[name][extname]", + }, + preserveEntrySignatures: "strict", + }, + }, +}); diff --git a/tldraw4/src/PatchworkDocShape.tsx b/tldraw4/src/PatchworkDocShape.tsx index 2483e79..bdba83c 100644 --- a/tldraw4/src/PatchworkDocShape.tsx +++ b/tldraw4/src/PatchworkDocShape.tsx @@ -13,22 +13,18 @@ import { type TLShape, type TLShapeId, } from "@tldraw/tldraw"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - getRegistry, - getSupportedToolsForType, - type DatatypeDescription, - type LoadedDatatype, - type LoadedTool, -} from "@inkandswitch/patchwork-plugins"; +import { useEffect, useRef } from "react"; +import { getSupportedToolsForType } from "@inkandswitch/patchwork-plugins"; import type { AutomergeUrl } from "@automerge/automerge-repo/slim"; -import { useDocument, useRepo } from "@automerge/react"; +import { useDocument } from "@automerge/react"; import { automergeUrlToServiceWorkerUrl } from "@inkandswitch/patchwork-filesystem"; -// A tldraw shape that embeds another Patchwork document, rendered via the -// host-provided `` custom element. The document reference and -// its display metadata live in the shape props, so they persist through the -// normal tldraw <-> Automerge sync like any other shape. +// A tldraw shape that embeds another Patchwork document, rendered through the +// shared "embed" tool: `` draws the title bar +// (live title, rename), the tool picker, and the open button, and nests the +// actual content view. The document reference and its display metadata live +// in the shape props, so they persist through the normal tldraw <-> Automerge +// sync like any other shape. export const PATCHWORK_DOC_SHAPE_TYPE = "patchwork-doc" as const; @@ -128,17 +124,6 @@ export function getDefaultToolId(datatypeId: string): string { } } -function useSupportedTools(docType: string): LoadedTool[] { - return useMemo(() => { - if (!docType) return []; - try { - return getSupportedToolsForType(docType).filter((t) => !(t as { unlisted?: boolean }).unlisted); - } catch { - return []; - } - }, [docType]); -} - function useIsImage(docUrl: string): boolean { const [doc] = useDocument<{ "@patchwork"?: { type?: string }; mimeType?: string }>( docUrl ? (docUrl as AutomergeUrl) : undefined, @@ -146,29 +131,11 @@ function useIsImage(docUrl: string): boolean { return doc?.["@patchwork"]?.type === "file" && !!doc?.mimeType?.startsWith("image/"); } -// Fire the host event that opens the document full-screen in Patchwork. The -// event contract lives in @inkandswitch/patchwork-elements; we dispatch it -// directly to avoid taking a hard dependency on that (externalized) package. -function openDocumentInHost(el: HTMLElement, url: string, toolId?: string) { - el.dispatchEvent( - new CustomEvent("patchwork:open-document", { - detail: { url, toolId: toolId || undefined }, - composed: true, - bubbles: true, - }), - ); -} - function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { - const { docUrl, docName, docType, toolId } = shape.props; + const { docUrl, docName, toolId } = shape.props; const editor = useEditor(); - const repo = useRepo(); const isImage = useIsImage(docUrl); - const tools = useSupportedTools(docType); - const isSelectTool = useValue("is select tool", () => editor.getCurrentToolId() === "select", [ - editor, - ]); const isEditingShape = useValue( "is editing shape", () => editor.getEditingShapeId() === shape.id, @@ -176,33 +143,8 @@ function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { ); const isFocused = isEditingShape; - const [isEditingName, setIsEditingName] = useState(false); - const [toolMenuOpen, setToolMenuOpen] = useState(false); - const nameInputRef = useRef(null); const containerRef = useRef(null); const contentRef = useRef(null); - const toolMenuRef = useRef(null); - - const currentTool = tools.find((t) => t.id === toolId) ?? tools[0]; - - useEffect(() => { - if (isEditingName && nameInputRef.current) { - nameInputRef.current.focus(); - nameInputRef.current.select(); - } - }, [isEditingName]); - - // Close the tool menu on outside pointerdown. - useEffect(() => { - if (!toolMenuOpen) return; - const handler = (e: PointerEvent) => { - if (toolMenuRef.current && !toolMenuRef.current.contains(e.target as Node)) { - setToolMenuOpen(false); - } - }; - window.addEventListener("pointerdown", handler, true); - return () => window.removeEventListener("pointerdown", handler, true); - }, [toolMenuOpen]); // While the embedded content is focused, stop tldraw from swallowing // keyboard / wheel / pointer events so the inner tool stays interactive. @@ -235,61 +177,25 @@ function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { }; }, [isFocused]); - const handleToolChange = useCallback( - (newToolId: string) => { + // Persist tool picks made in the embed frame into the shape props. The + // frame emits `patchwork:embed-tool-changed` (bubbling, composed) when the + // user chooses a different tool from its picker. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const onToolChanged = (e: Event) => { + const newToolId = (e as CustomEvent<{ toolId?: string }>).detail?.toolId; + if (!newToolId) return; editor.updateShape({ id: shape.id, type: PATCHWORK_DOC_SHAPE_TYPE, props: { toolId: newToolId }, }); - setToolMenuOpen(false); - }, - [editor, shape.id], - ); - - const handleOpenDocument = useCallback(() => { - const el = containerRef.current; - if (!el || !docUrl) return; - openDocumentInHost(el, docUrl, toolId || undefined); - }, [docUrl, toolId]); - - const handleRename = useCallback( - async (newName: string) => { - const trimmed = newName.trim(); - if (!trimmed || trimmed === docName || !docUrl || !docType) { - setIsEditingName(false); - return; - } - - try { - const datatype = await loadDatatype(docType); - if (datatype?.module.setTitle) { - const childHandle = await repo.find>(docUrl as AutomergeUrl); - childHandle.change((d) => { - datatype.module.setTitle!(d, trimmed); - }); - const childDoc = childHandle.doc(); - const canonicalName = childDoc ? datatype.module.getTitle(childDoc) : trimmed; - editor.updateShape({ - id: shape.id, - type: PATCHWORK_DOC_SHAPE_TYPE, - props: { docName: canonicalName }, - }); - } else { - editor.updateShape({ - id: shape.id, - type: PATCHWORK_DOC_SHAPE_TYPE, - props: { docName: trimmed }, - }); - } - } catch (err) { - console.warn("[tldraw4] rename failed", err); - } - - setIsEditingName(false); - }, - [editor, shape.id, docName, docUrl, docType, repo], - ); + }; + el.addEventListener("patchwork:embed-tool-changed", onToolChanged); + return () => + el.removeEventListener("patchwork:embed-tool-changed", onToolChanged); + }, [editor, shape.id]); return ( @@ -308,209 +214,11 @@ function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { pointerEvents: "all", }} > - {/* Titlebar */} -
{ - if (isEditingShape) editor.setEditingShape(null); - }} - style={{ - display: "flex", - alignItems: "center", - gap: "6px", - height: "30px", - padding: "0 6px", - borderBottom: "1px solid var(--color-divider, #e5e7eb)", - flexShrink: 0, - cursor: "grab", - userSelect: "none", - background: "var(--color-low, #fafafa)", - }} - > - {/* Open button */} - - - {/* Doc name */} -
- {isEditingName ? ( - e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") handleRename((e.target as HTMLInputElement).value); - if (e.key === "Escape") setIsEditingName(false); - }} - onBlur={(e) => handleRename(e.target.value)} - style={{ - width: "100%", - fontSize: "13px", - fontWeight: 600, - fontFamily: "inherit", - color: "var(--color-text, #111827)", - background: "var(--color-panel, #fff)", - border: "1px solid var(--color-selected, #2f80ed)", - borderRadius: "4px", - padding: "1px 4px", - outline: "none", - boxSizing: "border-box", - }} - /> - ) : ( - { - // Let a plain click start rename without dragging the shape. - e.stopPropagation(); - }} - onClick={(e) => { - e.stopPropagation(); - setIsEditingName(true); - }} - title={docName} - style={{ - display: "block", - fontSize: "13px", - fontWeight: 600, - color: "var(--color-text, #111827)", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - cursor: "text", - }} - > - {docName || "Untitled"} - - )} -
- - {/* Tool picker */} - {currentTool && ( -
- - - {toolMenuOpen && tools.length > 1 && ( -
e.stopPropagation()} - style={{ - position: "absolute", - top: "100%", - right: 0, - marginTop: "4px", - background: "var(--color-panel, #fff)", - border: "1px solid var(--color-divider, #e5e7eb)", - borderRadius: "6px", - boxShadow: "0 4px 12px rgba(0,0,0,0.12)", - padding: "4px", - minWidth: "140px", - zIndex: 10000, - }} - > - {tools.map((t) => ( - - ))} -
- )} -
- )} -
- - {/* Content */} + {/* Content: interactive only while the shape is in editing mode + (double-click, tldraw's native canEdit flow). Otherwise pointer + events fall through so tldraw can select and drag the shape from + anywhere — the embed frame's own chrome handles title, rename, + tool picking, and opening once editing. */}
{ - e.stopPropagation(); - if (!isEditingShape) editor.setEditingShape(shape.id); - } - : undefined - } - onPointerUp={ - isSelectTool - ? (e) => { - e.stopPropagation(); - // Synthesize a click for frameworks that rely on - // document-level event delegation (e.g. Solid.js); tldraw's - // preventDefault on pointerdown suppresses the native click. - (e.target as HTMLElement)?.dispatchEvent( - new MouseEvent("click", { - bubbles: true, - cancelable: true, - view: window, - clientX: e.clientX, - clientY: e.clientY, - }), - ); - } - : undefined - } > {docUrl && isImage ? ( @@ -583,12 +265,3 @@ function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { ); } - -async function loadDatatype(id: string): Promise { - try { - const registry = getRegistry("patchwork:datatype"); - return (await registry.load(id)) as unknown as LoadedDatatype | undefined; - } catch { - return undefined; - } -} From 5e95ef299058eb061425ee96c1bbedf47dd6120f Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 13:39:56 +0200 Subject: [PATCH 3/9] simplify embed tool picker to a plain menu Drop the search box (and its filtering, keyboard nav, and forcing of arbitrary tool ids) in favor of a simple click-to-pick list. When the host doesn't pin a tool, show the doc's fallback tool as the current selection instead of a special "Open with..." placeholder state. --- embed/src/embed.css | 16 +------ embed/src/tool.ts | 107 +++++++++++--------------------------------- 2 files changed, 26 insertions(+), 97 deletions(-) diff --git a/embed/src/embed.css b/embed/src/embed.css index 6ed20a5..d4bd9f6 100644 --- a/embed/src/embed.css +++ b/embed/src/embed.css @@ -131,19 +131,6 @@ font-size: 0.85em; } -.embed-frame .tool-menu .search { - display: block; - width: 100%; - padding: var(--studio-space-xs, 0.375rem) var(--studio-space-sm, 0.5rem); - border: none; - border-bottom: 1px solid var(--embed-frame-border); - background: none; - font: inherit; - color: inherit; - outline: none; - box-sizing: border-box; -} - .embed-frame .tool-menu .list { max-height: 200px; overflow-y: auto; @@ -164,8 +151,7 @@ white-space: nowrap; } -.embed-frame .tool-menu .item:hover, -.embed-frame .tool-menu .item[data-highlight] { +.embed-frame .tool-menu .item:hover { background: var(--embed-frame-highlight); } diff --git a/embed/src/tool.ts b/embed/src/tool.ts index 05a322d..2afbc8a 100644 --- a/embed/src/tool.ts +++ b/embed/src/tool.ts @@ -90,16 +90,21 @@ const EmbedFrame: ToolImplementation = (handle, element) => { } function currentToolName(): string { - if (currentToolId) { - const tool = getRegistry("patchwork:tool").get( - currentToolId - ); - return tool?.name ?? currentToolId; - } + const toolId = effectiveToolId(); + if (!toolId) return ""; + const tool = getRegistry("patchwork:tool").get(toolId); + return tool?.name ?? toolId; + } + + // When the host didn't pin a tool, the nested patchwork-view renders the + // doc's fallback tool — surface that instead of a special "unselected" + // state, so the picker always reflects what is actually shown. + function effectiveToolId(): string | null { + if (currentToolId) return currentToolId; try { - return getFallbackTool(handle.doc())?.name ?? "Open with\u2026"; + return getFallbackTool(handle.doc())?.id ?? null; } catch { - return "Open with\u2026"; + return null; } } @@ -146,8 +151,6 @@ const EmbedFrame: ToolImplementation = (handle, element) => { input.select(); } - // Replicates the sidebar's "Open with" menu: a search box filtering the - // suggested tools, Enter on a non-match forces the typed tool id. function toggleMenu(): void { if (menu.matches(":popover-open")) { menu.hidePopover(); @@ -170,84 +173,24 @@ const EmbedFrame: ToolImplementation = (handle, element) => { function buildMenu(): void { menu.replaceChildren(); - const tools = listTools(); - - const input = document.createElement("input"); - input.className = "search"; - input.placeholder = "Search or enter tool id\u2026"; - menu.append(input); - const list = document.createElement("div"); list.className = "list"; menu.append(list); - let highlighted = 0; - let filtered: ToolDescription[] = []; - - const pick = (toolId: string) => { - menu.hidePopover(); - setTool(toolId); - }; - - const highlightAt = (i: number) => { - highlighted = i; - for (const [j, el] of [...list.children].entries()) { - el.toggleAttribute("data-highlight", j === i); + const current = effectiveToolId(); + for (const tool of listTools()) { + const item = document.createElement("button"); + item.className = "item"; + if (tool.id === current) { + item.toggleAttribute("data-current", true); } - }; - - const renderList = () => { - const q = input.value.trim().toLowerCase(); - filtered = tools.filter( - (t) => - !q || - t.name.toLowerCase().includes(q) || - t.id.toLowerCase().includes(q) - ); - list.replaceChildren(); - filtered.forEach((t, i) => { - const item = document.createElement("button"); - item.className = "item"; - if (i === highlighted) item.toggleAttribute("data-highlight", true); - if (t.id === currentToolId) item.toggleAttribute("data-current", true); - item.textContent = t.name || t.id; - item.addEventListener("click", () => pick(t.id)); - item.addEventListener("pointerenter", () => highlightAt(i)); - list.append(item); - }); - }; - - input.addEventListener("input", () => { - highlighted = 0; - renderList(); - }); - - input.addEventListener("keydown", (e) => { - e.stopPropagation(); - if (e.key === "ArrowDown") { - e.preventDefault(); - highlightAt(Math.min(highlighted + 1, filtered.length - 1)); - list.children[highlighted]?.scrollIntoView({ block: "nearest" }); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - highlightAt(Math.max(highlighted - 1, 0)); - list.children[highlighted]?.scrollIntoView({ block: "nearest" }); - } else if (e.key === "Enter") { - e.preventDefault(); - const q = input.value.trim(); - if (highlighted >= 0 && highlighted < filtered.length) { - pick(filtered[highlighted].id); - } else if (q) { - // Force a tool id that isn't among the suggestions. - pick(q); - } - } else if (e.key === "Escape") { + item.textContent = tool.name || tool.id; + item.addEventListener("click", () => { menu.hidePopover(); - } - }); - - renderList(); - queueMicrotask(() => input.focus()); + setTool(tool.id); + }); + list.append(item); + } } function listTools(): ToolDescription[] { From e25f88f6b02628901b3db2e54f3746d119c7260f Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 15:13:25 +0200 Subject: [PATCH 4/9] fix embed tool selection loss and nested embed cross-talk Two fixes for the shared embed frame: - Read embed-tool-id from the outer too: tools receive the inner element, which never carries the attribute, so every remount silently dropped the pinned tool back to the fallback. - Hosts (codemirror widget, tldraw shape) stop propagation of patchwork:embed-tool-changed once handled, so a pick inside a nested embed no longer also rewrites the outer embed's tool. Also show the embed package version in the frame header as a deploy check. --- codemirror-markdown/src/extensions/embed.ts | 5 ++++- embed/src/embed.css | 6 ++++++ embed/src/tool.ts | 16 ++++++++++++++-- tldraw4/src/PatchworkDocShape.tsx | 3 +++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/codemirror-markdown/src/extensions/embed.ts b/codemirror-markdown/src/extensions/embed.ts index bc89f71..028dfbf 100644 --- a/codemirror-markdown/src/extensions/embed.ts +++ b/codemirror-markdown/src/extensions/embed.ts @@ -63,8 +63,11 @@ class EmbedWidget extends WidgetType { patchworkView.style.height = "500px"; patchworkView.style.width = "100%"; - // Persist tool picks made in the embed frame into the marker text. + // Persist tool picks made in the embed frame into the marker text. Stop + // propagation: with nested embeds the event would otherwise bubble on to + // the outer embed's host and change that one too. container.addEventListener("patchwork:embed-tool-changed", (e) => { + e.stopPropagation(); const toolId = (e as CustomEvent<{ toolId?: string }>).detail?.toolId; if (toolId) this.setTool(view, container, toolId); }); diff --git a/embed/src/embed.css b/embed/src/embed.css index d4bd9f6..09a44af 100644 --- a/embed/src/embed.css +++ b/embed/src/embed.css @@ -58,6 +58,12 @@ outline: none; } +.embed-frame .version { + font-size: 0.7em; + color: var(--embed-frame-muted); + flex-shrink: 0; +} + .embed-frame .tool-btn { display: inline-flex; align-items: center; diff --git a/embed/src/tool.ts b/embed/src/tool.ts index 2afbc8a..25bbf70 100644 --- a/embed/src/tool.ts +++ b/embed/src/tool.ts @@ -19,12 +19,19 @@ import { } from "@inkandswitch/patchwork-plugins"; import { getType } from "@inkandswitch/patchwork-filesystem"; import { openDocument } from "@inkandswitch/patchwork-elements"; +import { version } from "../package.json"; import "./embed.css"; const EMBED_TOOL_ID = "embed"; const EmbedFrame: ToolImplementation = (handle, element) => { - let currentToolId = element.getAttribute("embed-tool-id") || null; + // `element` is the inner , which only receives the + // doc-url / tool-id attributes — hosts set `embed-tool-id` on the outer + // , so resolve it from there too. + let currentToolId = + element.getAttribute("embed-tool-id") || + element.closest("patchwork-view")?.getAttribute("embed-tool-id") || + null; let datatype: DatatypeImplementation | null = null; const root = document.createElement("div"); @@ -37,6 +44,11 @@ const EmbedFrame: ToolImplementation = (handle, element) => { titleEl.className = "title"; titleEl.textContent = "\u2026"; + // Deploy check: shows which build of the embed package is actually running. + const versionEl = document.createElement("span"); + versionEl.className = "version"; + versionEl.textContent = `v${version}`; + const toolButton = document.createElement("button"); toolButton.className = "tool-btn"; toolButton.title = "Open with\u2026"; @@ -62,7 +74,7 @@ const EmbedFrame: ToolImplementation = (handle, element) => { } }); - header.append(titleEl, toolButton, openButton); + header.append(titleEl, versionEl, toolButton, openButton); content.append(view); root.append(header, content, menu); element.append(root); diff --git a/tldraw4/src/PatchworkDocShape.tsx b/tldraw4/src/PatchworkDocShape.tsx index bdba83c..da9789a 100644 --- a/tldraw4/src/PatchworkDocShape.tsx +++ b/tldraw4/src/PatchworkDocShape.tsx @@ -184,6 +184,9 @@ function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { const el = containerRef.current; if (!el) return; const onToolChanged = (e: Event) => { + // Stop propagation: with nested embeds the event would otherwise bubble + // on to an outer embed's host and change that one too. + e.stopPropagation(); const newToolId = (e as CustomEvent<{ toolId?: string }>).detail?.toolId; if (!newToolId) return; editor.updateShape({ From 6ec7bac08d8451427e75055dcfe9b58ffe8931eb Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 15:20:49 +0200 Subject: [PATCH 5/9] condense comments in embed-related code Trim the comments added on this branch down to their load-bearing content across the embed package, codemirror-markdown, and tldraw4. --- codemirror-markdown/src/extensions/embed.ts | 68 ++++++++------------- codemirror-markdown/src/themes/embed.ts | 3 +- embed/src/index.ts | 11 ++-- embed/src/tool.ts | 33 ++++------ embed/vite.config.ts | 5 +- tldraw4/src/PatchworkDocShape.tsx | 24 +++----- 6 files changed, 51 insertions(+), 93 deletions(-) diff --git a/codemirror-markdown/src/extensions/embed.ts b/codemirror-markdown/src/extensions/embed.ts index 028dfbf..f8c85c3 100644 --- a/codemirror-markdown/src/extensions/embed.ts +++ b/codemirror-markdown/src/extensions/embed.ts @@ -17,18 +17,15 @@ import { import { embedTheme } from "../themes/embed.ts"; /** - * Widget that renders an embedded doc through the shared "embed" tool (the - * `embed` package): draws the title bar, - * tool picker, and open button, and nests the actual content view inside. - * The inner tool travels via the `embed-tool-id` attribute; when the user - * picks a different tool the frame emits `patchwork:embed-tool-changed`, - * which we translate into a marker rewrite so the choice persists in the - * markdown (and the widget recreates with the new tool). + * Renders an embedded doc through the shared "embed" tool, which draws the + * chrome and nests the content view. The inner tool travels via the + * `embed-tool-id` attribute; tool picks come back as + * `patchwork:embed-tool-changed` events and are persisted by rewriting the + * marker (which recreates the widget). */ class EmbedWidget extends WidgetType { readonly docId: DocumentId; - // `null` means "no explicit tool": the embed frame falls back to the - // default tool registered for the document's datatype. + // `null` = no pinned tool; the embed frame uses the datatype's fallback. readonly toolId: string | null; readonly embedText: string; @@ -48,24 +45,19 @@ class EmbedWidget extends WidgetType { container.className = "cm-embed"; const patchworkView = document.createElement("patchwork-view"); - // Name the doc without heads. Resolution (OverlayRepo + the drafts - // `repo:handle-descriptor` answer) pins it to the active checkpoint when one - // is checked out, so the embed freezes with the document it lives in; - // otherwise it renders live. + // No heads in the url: OverlayRepo resolution pins the embed to the active + // checkpoint when one is checked out, and renders live otherwise. patchworkView.setAttribute("doc-url", `automerge:${this.docId}`); patchworkView.setAttribute("tool-id", "embed"); if (this.toolId) patchworkView.setAttribute("embed-tool-id", this.toolId); - // The needs an explicit, non-zero height set inline: - // without it the element collapses to 0px and the embedded tool never - // renders. (The stylesheet rule isn't reliably applied here, so we set it - // directly on the element.) + // Explicit inline height: without it the element collapses to 0px (the + // stylesheet rule isn't reliably applied here). patchworkView.style.display = "block"; patchworkView.style.height = "500px"; patchworkView.style.width = "100%"; - // Persist tool picks made in the embed frame into the marker text. Stop - // propagation: with nested embeds the event would otherwise bubble on to - // the outer embed's host and change that one too. + // Persist tool picks into the marker text. Stop propagation so nested + // embeds don't also change the outer embed's tool. container.addEventListener("patchwork:embed-tool-changed", (e) => { e.stopPropagation(); const toolId = (e as CustomEvent<{ toolId?: string }>).detail?.toolId; @@ -91,31 +83,24 @@ class EmbedWidget extends WidgetType { } ignoreEvent() { - // The embed is atomic: no click-to-edit, so block all events from the - // editor and let the embed frame / patchwork-view handle them. + // Atomic embed: the editor ignores all events; the frame handles them. return true; } } -// Embed marker syntax: [patchwork:docId] or [patchwork:docId/toolId]. The tool -// id is optional; when absent the embed falls back to the datatype's default -// tool. The doc id / tool id cannot contain `/` or `]`. -// -// We scan the document text directly rather than walking the markdown syntax -// tree on purpose: `@codemirror/language` is not a shared singleton across -// patchwork's separately-bundled CodeMirror extensions, so `syntaxTree(state)` -// here reads a different `Language` facet than the markdown tool populates and -// always comes back empty. Plain-text scanning keeps this extension -// self-contained and free of any `@codemirror/language` dependency. +// Marker syntax: [patchwork:docId] or [patchwork:docId/toolId] (ids can't +// contain `/` or `]`). We scan plain text instead of the markdown syntax tree +// on purpose: `@codemirror/language` isn't a shared singleton across +// patchwork's separately-bundled extensions, so `syntaxTree(state)` here would +// read a different `Language` facet and always come back empty. const EMBED_PATTERN = /\[patchwork:([^/\]]+)(?:\/([^\]]+))?\]/g; function getEmbedLinks(view: EditorView) { const widgets: Range[] = []; const { state } = view; - // Scan only the visible ranges. Markers never span a line break, and - // CodeMirror's visible ranges are line-aligned, so a marker is either fully - // inside a range or fully outside it. + // Visible ranges are line-aligned and markers never span lines, so a marker + // is always fully inside or fully outside a range. for (const { from, to } of view.visibleRanges) { const text = state.doc.sliceString(from, to); EMBED_PATTERN.lastIndex = 0; @@ -269,9 +254,7 @@ async function fileDropRefs(files: FileList): Promise { function insertRefs(view: EditorView, pos: number, refs: DocRef[]): void { if (refs.length === 0) return; let text = refs.map(embedSyntax).join("\n\n"); - // When dropping onto a line that has content, put the embed on its own line: - // break before it unless dropped at the line start, and after it unless - // dropped at the line end. + // On a non-empty line, add just enough breaks to put the embed on its own line. const line = view.state.doc.lineAt(pos); if (/\S/.test(line.text)) { if (pos > line.from) text = "\n" + text; @@ -342,8 +325,7 @@ const embedPlugin = ViewPlugin.fromClass( } update(update: ViewUpdate) { - // Recompute when the document changes or the viewport scrolls (so - // newly-visible markers get decorated). + // Viewport too, so newly-visible markers get decorated. if (update.docChanged || update.viewportChanged) { this.decorations = getEmbedLinks(update.view); } @@ -351,8 +333,7 @@ const embedPlugin = ViewPlugin.fromClass( }, { decorations: (v) => v.decorations, - // Atomic: the cursor skips over embeds and backspace/delete removes the - // whole marker instead of popping it open as editable text. + // Atomic: the cursor skips embeds and delete removes the whole marker. provide: (plugin) => EditorView.atomicRanges.of( (view) => view.plugin(plugin)?.decorations ?? Decoration.none @@ -361,7 +342,6 @@ const embedPlugin = ViewPlugin.fromClass( ); export function markdownEmbed() { - // `dropCursor` uses event observers, so it keeps working even though our - // dragover handler claims the event. + // `dropCursor` observes events, so it coexists with our dragover handler. return [embedPlugin, embedTheme, embedDropHandlers(), dropCursor()]; } diff --git a/codemirror-markdown/src/themes/embed.ts b/codemirror-markdown/src/themes/embed.ts index 2afd118..afce863 100644 --- a/codemirror-markdown/src/themes/embed.ts +++ b/codemirror-markdown/src/themes/embed.ts @@ -1,7 +1,6 @@ import { EditorView } from "@codemirror/view"; -// Only the outer container: all embed chrome (title bar, tool picker, open -// button) lives in the shared "embed" tool mounted via . +// Outer container only; all embed chrome lives in the shared "embed" tool. export const embedTheme = EditorView.baseTheme({ ".cm-embed": { display: "block", diff --git a/embed/src/index.ts b/embed/src/index.ts index c5d7768..cdb65d3 100644 --- a/embed/src/index.ts +++ b/embed/src/index.ts @@ -1,7 +1,5 @@ -// Entry module: metadata only. Patchwork evaluates it inside a worker (no -// importmap, no DOM), so no runtime imports and no behaviour here — the -// implementation loads lazily on the main thread. Type-only imports are -// erased at compile time and are safe. +// Metadata only: Patchwork evaluates this entry in a worker (no importmap, no +// DOM), so no runtime imports here — the implementation loads lazily. import type { Tool } from "@inkandswitch/patchwork-plugins"; export const plugins: Tool[] = [ @@ -11,9 +9,8 @@ export const plugins: Tool[] = [ name: "Embed", icon: "PictureInPicture2", supportedDatatypes: "*", - // `unlisted` is load-bearing: it keeps "Embed" out of Open With menus - // AND out of getFallbackTool, so a tool-less nested view can never fall - // back to the embed frame itself (which would recurse forever). + // Load-bearing: keeps "Embed" out of pickers and out of getFallbackTool, + // which would otherwise recurse (embed frame falling back to itself). unlisted: true, async load() { return (await import("./tool")).default; diff --git a/embed/src/tool.ts b/embed/src/tool.ts index 25bbf70..7769dcf 100644 --- a/embed/src/tool.ts +++ b/embed/src/tool.ts @@ -1,12 +1,7 @@ -// The embed frame: chrome around a nested . Hosts (codemirror -// markers, tldraw shapes, …) mount it via -// and pass the inner tool through the `embed-tool-id` attribute — the tool -// receives the element itself, so it can read attributes off -// it and dispatch events from it. -// -// Emits `patchwork:embed-tool-changed` (bubbles, composed) when the user picks -// a different tool, so the host can persist the choice wherever it lives -// (marker text, shape props, …). +// Embed frame: chrome (title, tool picker, open button) around a nested +// . Hosts mount it via , pin +// the inner tool with the `embed-tool-id` attribute, and persist picks by +// listening for the bubbling `patchwork:embed-tool-changed` event. import { getFallbackTool, @@ -25,9 +20,8 @@ import "./embed.css"; const EMBED_TOOL_ID = "embed"; const EmbedFrame: ToolImplementation = (handle, element) => { - // `element` is the inner , which only receives the - // doc-url / tool-id attributes — hosts set `embed-tool-id` on the outer - // , so resolve it from there too. + // `element` is the inner ; hosts set `embed-tool-id` + // on the outer , so check there too. let currentToolId = element.getAttribute("embed-tool-id") || element.closest("patchwork-view")?.getAttribute("embed-tool-id") || @@ -44,7 +38,7 @@ const EmbedFrame: ToolImplementation = (handle, element) => { titleEl.className = "title"; titleEl.textContent = "\u2026"; - // Deploy check: shows which build of the embed package is actually running. + // Deploy check: which build is actually running. const versionEl = document.createElement("span"); versionEl.className = "version"; versionEl.textContent = `v${version}`; @@ -92,7 +86,7 @@ const EmbedFrame: ToolImplementation = (handle, element) => { try { return datatype?.getTitle(handle.doc()) || "Untitled"; } catch { - // getTitle may throw if the doc shape doesn't match the datatype + // getTitle throws on unexpected doc shapes return "Untitled"; } } @@ -108,9 +102,8 @@ const EmbedFrame: ToolImplementation = (handle, element) => { return tool?.name ?? toolId; } - // When the host didn't pin a tool, the nested patchwork-view renders the - // doc's fallback tool — surface that instead of a special "unselected" - // state, so the picker always reflects what is actually shown. + // With no pinned tool the nested view renders the doc's fallback tool, so + // surface that as the selection rather than a special "unselected" state. function effectiveToolId(): string | null { if (currentToolId) return currentToolId; try { @@ -134,8 +127,7 @@ const EmbedFrame: ToolImplementation = (handle, element) => { ); } - // Rename in place: swap the title for an input; commit via the datatype's - // setTitle so the canonical name updates for every view of the doc. + // Swap the title for an input; commit via the datatype's setTitle. function startRename(): void { if (!datatype?.setTitle) return; const input = document.createElement("input"); @@ -222,8 +214,7 @@ const EmbedFrame: ToolImplementation = (handle, element) => { if (!currentToolId) renderToolButton(); } - // Load the datatype so getTitle/setTitle work and the tools it ships with - // get registered, then re-render whatever depended on it. + // Load the datatype (for getTitle/setTitle and its tools), then re-render. async function loadDatatype(): Promise { const doc = handle.doc(); const type = doc && getType(doc); diff --git a/embed/vite.config.ts b/embed/vite.config.ts index ed88e36..99f9d59 100644 --- a/embed/vite.config.ts +++ b/embed/vite.config.ts @@ -4,9 +4,8 @@ import external from "@inkandswitch/patchwork-bootloader/externals"; export default defineConfig({ base: "./", - // Inject each chunk's CSS when that chunk loads: the entry (which Patchwork - // evaluates inside a worker to read `plugins`) stays DOM-free, and the - // styles ride with the lazily-imported tool chunk on the main thread. + // Per-chunk CSS injection keeps the entry DOM-free (it's evaluated in a + // worker); styles ride with the lazily-imported tool chunk. plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })], build: { minify: false, diff --git a/tldraw4/src/PatchworkDocShape.tsx b/tldraw4/src/PatchworkDocShape.tsx index da9789a..ed745bc 100644 --- a/tldraw4/src/PatchworkDocShape.tsx +++ b/tldraw4/src/PatchworkDocShape.tsx @@ -19,12 +19,10 @@ import type { AutomergeUrl } from "@automerge/automerge-repo/slim"; import { useDocument } from "@automerge/react"; import { automergeUrlToServiceWorkerUrl } from "@inkandswitch/patchwork-filesystem"; -// A tldraw shape that embeds another Patchwork document, rendered through the -// shared "embed" tool: `` draws the title bar -// (live title, rename), the tool picker, and the open button, and nests the -// actual content view. The document reference and its display metadata live -// in the shape props, so they persist through the normal tldraw <-> Automerge -// sync like any other shape. +// A tldraw shape embedding another Patchwork document via the shared "embed" +// tool (), which draws the chrome and nests +// the content view. The doc reference lives in the shape props, so it syncs +// like any other shape. export const PATCHWORK_DOC_SHAPE_TYPE = "patchwork-doc" as const; @@ -177,15 +175,12 @@ function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { }; }, [isFocused]); - // Persist tool picks made in the embed frame into the shape props. The - // frame emits `patchwork:embed-tool-changed` (bubbling, composed) when the - // user chooses a different tool from its picker. + // Persist tool picks from the embed frame into the shape props. useEffect(() => { const el = containerRef.current; if (!el) return; const onToolChanged = (e: Event) => { - // Stop propagation: with nested embeds the event would otherwise bubble - // on to an outer embed's host and change that one too. + // Stop propagation so nested embeds don't also change the outer embed. e.stopPropagation(); const newToolId = (e as CustomEvent<{ toolId?: string }>).detail?.toolId; if (!newToolId) return; @@ -217,11 +212,8 @@ function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { pointerEvents: "all", }} > - {/* Content: interactive only while the shape is in editing mode - (double-click, tldraw's native canEdit flow). Otherwise pointer - events fall through so tldraw can select and drag the shape from - anywhere — the embed frame's own chrome handles title, rename, - tool picking, and opening once editing. */} + {/* Interactive only while editing (double-click); otherwise pointer + events fall through so tldraw can select and drag the shape. */}
Date: Tue, 28 Jul 2026 15:37:30 +0200 Subject: [PATCH 6/9] rewrite embed frame in Solid Replace the imperative DOM construction and manual re-render calls in the embed tool with a Solid component: signals for doc/datatype/pinned tool drive the title, rename input, tool button, and picker menu reactively. Behavior and CSS are unchanged; solid-js resolves through the importmap as a bootloader external. --- embed/package.json | 6 +- embed/src/tool.ts | 263 ---------------------------------------- embed/src/tool.tsx | 279 +++++++++++++++++++++++++++++++++++++++++++ embed/tsconfig.json | 2 + embed/vite.config.ts | 3 +- 5 files changed, 287 insertions(+), 266 deletions(-) delete mode 100644 embed/src/tool.ts create mode 100644 embed/src/tool.tsx diff --git a/embed/package.json b/embed/package.json index 04fed6e..8c237c4 100644 --- a/embed/package.json +++ b/embed/package.json @@ -14,12 +14,14 @@ "@inkandswitch/patchwork-bootloader": "^0.2.8", "@inkandswitch/patchwork-elements": "1.0.0", "@inkandswitch/patchwork-filesystem": "^0.0.8", - "@inkandswitch/patchwork-plugins": "^0.0.11" + "@inkandswitch/patchwork-plugins": "^0.0.11", + "solid-js": "^1.9.11" }, "devDependencies": { "@automerge/automerge-repo": "^2.6.0-subduction.9", "typescript": "~5.9.3", "vite": "^5.4.21", - "vite-plugin-css-injected-by-js": "^3.5.2" + "vite-plugin-css-injected-by-js": "^3.5.2", + "vite-plugin-solid": "^2.11.10" } } diff --git a/embed/src/tool.ts b/embed/src/tool.ts deleted file mode 100644 index 7769dcf..0000000 --- a/embed/src/tool.ts +++ /dev/null @@ -1,263 +0,0 @@ -// Embed frame: chrome (title, tool picker, open button) around a nested -// . Hosts mount it via , pin -// the inner tool with the `embed-tool-id` attribute, and persist picks by -// listening for the bubbling `patchwork:embed-tool-changed` event. - -import { - getFallbackTool, - getRegistry, - getSupportedToolsForType, - isLoadedPlugin, - type DatatypeImplementation, - type ToolDescription, - type ToolImplementation, -} from "@inkandswitch/patchwork-plugins"; -import { getType } from "@inkandswitch/patchwork-filesystem"; -import { openDocument } from "@inkandswitch/patchwork-elements"; -import { version } from "../package.json"; -import "./embed.css"; - -const EMBED_TOOL_ID = "embed"; - -const EmbedFrame: ToolImplementation = (handle, element) => { - // `element` is the inner ; hosts set `embed-tool-id` - // on the outer , so check there too. - let currentToolId = - element.getAttribute("embed-tool-id") || - element.closest("patchwork-view")?.getAttribute("embed-tool-id") || - null; - let datatype: DatatypeImplementation | null = null; - - const root = document.createElement("div"); - root.className = "embed-frame"; - - const header = document.createElement("div"); - header.className = "header"; - - const titleEl = document.createElement("span"); - titleEl.className = "title"; - titleEl.textContent = "\u2026"; - - // Deploy check: which build is actually running. - const versionEl = document.createElement("span"); - versionEl.className = "version"; - versionEl.textContent = `v${version}`; - - const toolButton = document.createElement("button"); - toolButton.className = "tool-btn"; - toolButton.title = "Open with\u2026"; - - const openButton = document.createElement("button"); - openButton.className = "open-btn"; - openButton.title = "Open document"; - openButton.innerHTML = openIcon; - - const content = document.createElement("div"); - content.className = "content"; - - const view = document.createElement("patchwork-view"); - view.setAttribute("doc-url", handle.url); - if (currentToolId) view.setAttribute("tool-id", currentToolId); - - const menu = document.createElement("div"); - menu.className = "tool-menu"; - menu.popover = "auto"; - menu.addEventListener("toggle", (e) => { - if ((e as { newState?: string }).newState === "closed") { - menu.replaceChildren(); - } - }); - - header.append(titleEl, versionEl, toolButton, openButton); - content.append(view); - root.append(header, content, menu); - element.append(root); - - titleEl.onclick = startRename; - toolButton.onclick = () => toggleMenu(); - openButton.onclick = () => - openDocument(element, handle.url, currentToolId || undefined); - - function renderTitle(): void { - titleEl.textContent = getTitle(); - } - - function getTitle(): string { - try { - return datatype?.getTitle(handle.doc()) || "Untitled"; - } catch { - // getTitle throws on unexpected doc shapes - return "Untitled"; - } - } - - function renderToolButton(): void { - toolButton.textContent = currentToolName(); - } - - function currentToolName(): string { - const toolId = effectiveToolId(); - if (!toolId) return ""; - const tool = getRegistry("patchwork:tool").get(toolId); - return tool?.name ?? toolId; - } - - // With no pinned tool the nested view renders the doc's fallback tool, so - // surface that as the selection rather than a special "unselected" state. - function effectiveToolId(): string | null { - if (currentToolId) return currentToolId; - try { - return getFallbackTool(handle.doc())?.id ?? null; - } catch { - return null; - } - } - - function setTool(toolId: string): void { - if (!toolId || toolId === currentToolId) return; - currentToolId = toolId; - view.setAttribute("tool-id", toolId); - renderToolButton(); - element.dispatchEvent( - new CustomEvent("patchwork:embed-tool-changed", { - detail: { url: handle.url, toolId }, - bubbles: true, - composed: true, - }) - ); - } - - // Swap the title for an input; commit via the datatype's setTitle. - function startRename(): void { - if (!datatype?.setTitle) return; - const input = document.createElement("input"); - input.className = "rename-input"; - input.value = getTitle(); - let done = false; - const finish = (commit: boolean) => { - if (done) return; - done = true; - const trimmed = input.value.trim(); - if (commit && trimmed && trimmed !== getTitle()) { - handle.change((d: any) => datatype!.setTitle!(d, trimmed)); - } - input.replaceWith(titleEl); - renderTitle(); - }; - input.onkeydown = (e) => { - e.stopPropagation(); - if (e.key === "Enter") finish(true); - if (e.key === "Escape") finish(false); - }; - input.onblur = () => finish(true); - titleEl.replaceWith(input); - input.focus(); - input.select(); - } - - function toggleMenu(): void { - if (menu.matches(":popover-open")) { - menu.hidePopover(); - return; - } - buildMenu(); - const rect = toolButton.getBoundingClientRect(); - const menuWidth = Math.max(rect.width, 180); - menu.style.top = `${rect.bottom + 2}px`; - menu.style.minWidth = `${menuWidth}px`; - if (rect.left + menuWidth > window.innerWidth - 8) { - menu.style.left = ""; - menu.style.right = `${window.innerWidth - rect.right}px`; - } else { - menu.style.right = ""; - menu.style.left = `${rect.left}px`; - } - menu.showPopover(); - } - - function buildMenu(): void { - menu.replaceChildren(); - const list = document.createElement("div"); - list.className = "list"; - menu.append(list); - - const current = effectiveToolId(); - for (const tool of listTools()) { - const item = document.createElement("button"); - item.className = "item"; - if (tool.id === current) { - item.toggleAttribute("data-current", true); - } - item.textContent = tool.name || tool.id; - item.addEventListener("click", () => { - menu.hidePopover(); - setTool(tool.id); - }); - list.append(item); - } - } - - function listTools(): ToolDescription[] { - const doc = handle.doc(); - const type = doc && getType(doc); - if (!type) return []; - const list = getSupportedToolsForType(type).filter( - (t) => !t.unlisted && !t.forTitleBar && t.id !== EMBED_TOOL_ID - ); - // Datatype-specific tools before the generic wildcard ones. - list.sort((a, b) => Number(isWildcard(a)) - Number(isWildcard(b))); - return list; - } - - function onDocChange(): void { - renderTitle(); - if (!currentToolId) renderToolButton(); - } - - // Load the datatype (for getTitle/setTitle and its tools), then re-render. - async function loadDatatype(): Promise { - const doc = handle.doc(); - const type = doc && getType(doc); - if (!type) return; - const registry = getRegistry("patchwork:datatype"); - try { - await registry.load(type); - } catch { - // datatype unavailable; keep the fallbacks - } - const loaded = registry.get(type); - if (loaded && isLoadedPlugin(loaded)) { - datatype = loaded.module as DatatypeImplementation; - } - titleEl.toggleAttribute("data-renamable", Boolean(datatype?.setTitle)); - if (datatype?.setTitle) titleEl.title = "Click to rename"; - renderTitle(); - renderToolButton(); - } - - renderTitle(); - renderToolButton(); - handle.on("change", onDocChange); - void loadDatatype(); - - return () => { - handle.off("change", onDocChange); - root.remove(); - }; -}; - -export default EmbedFrame; - -function isWildcard(tool: ToolDescription): boolean { - return ( - tool.supportedDatatypes === "*" || - (Array.isArray(tool.supportedDatatypes) && - tool.supportedDatatypes.includes("*")) - ); -} - -const openIcon = ` - - - -`; diff --git a/embed/src/tool.tsx b/embed/src/tool.tsx new file mode 100644 index 0000000..a89795f --- /dev/null +++ b/embed/src/tool.tsx @@ -0,0 +1,279 @@ +// Embed frame: chrome (title, tool picker, open button) around a nested +// . Hosts mount it via , pin +// the inner tool with the `embed-tool-id` attribute, and persist picks by +// listening for the bubbling `patchwork:embed-tool-changed` event. + +import { + getFallbackTool, + getRegistry, + getSupportedToolsForType, + isLoadedPlugin, + type DatatypeImplementation, + type ToolDescription, + type ToolImplementation, +} from "@inkandswitch/patchwork-plugins"; +import { getType } from "@inkandswitch/patchwork-filesystem"; +import { openDocument } from "@inkandswitch/patchwork-elements"; +import type { DocHandle } from "@automerge/automerge-repo"; +import { createSignal, For, onCleanup, Show, type JSX } from "solid-js"; +import { render } from "solid-js/web"; +import { version } from "../package.json"; +import "./embed.css"; + +const EMBED_TOOL_ID = "embed"; + +const EmbedFrame: ToolImplementation = (handle, element) => + render(() => , element); + +export default EmbedFrame; + +type FrameProps = { + handle: DocHandle; + element: HTMLElement; +}; + +function Frame(props: FrameProps) { + // Static per mount (the render contract passes them once); safe to unwrap. + const { handle, element } = props; + + // `element` is the inner ; hosts set `embed-tool-id` + // on the outer , so check there too. + const [pinnedToolId, setPinnedToolId] = createSignal( + element.getAttribute("embed-tool-id") || + element.closest("patchwork-view")?.getAttribute("embed-tool-id") || + null + ); + const [doc, setDoc] = createSignal(handle.doc(), { equals: false }); + const [datatype, setDatatype] = + createSignal | null>(null); + const [renaming, setRenaming] = createSignal(false); + const [menuOpen, setMenuOpen] = createSignal(false); + + const onDocChange = () => setDoc(handle.doc()); + handle.on("change", onDocChange); + onCleanup(() => handle.off("change", onDocChange)); + void loadDatatype(); + + let menuEl!: HTMLDivElement; + let toolButtonEl!: HTMLButtonElement; + + const title = () => { + try { + return datatype()?.getTitle(doc()) || "Untitled"; + } catch { + // getTitle throws on unexpected doc shapes + return "Untitled"; + } + }; + + const canRename = () => Boolean(datatype()?.setTitle); + + // With no pinned tool the nested view renders the doc's fallback tool, so + // surface that as the selection rather than a special "unselected" state. + const effectiveToolId = () => { + datatype(); // re-check once the datatype (and the tools it ships) loaded + if (pinnedToolId()) return pinnedToolId(); + try { + return getFallbackTool(doc())?.id ?? null; + } catch { + return null; + } + }; + + const toolName = () => { + const toolId = effectiveToolId(); + if (!toolId) return ""; + const tool = getRegistry("patchwork:tool").get(toolId); + return tool?.name ?? toolId; + }; + + function setTool(toolId: string): void { + if (!toolId || toolId === pinnedToolId()) return; + setPinnedToolId(toolId); + element.dispatchEvent( + new CustomEvent("patchwork:embed-tool-changed", { + detail: { url: handle.url, toolId }, + bubbles: true, + composed: true, + }) + ); + } + + function finishRename(commit: boolean, value: string): void { + if (!renaming()) return; + setRenaming(false); + const trimmed = value.trim(); + if (commit && trimmed && trimmed !== title()) { + handle.change((d: any) => datatype()!.setTitle!(d, trimmed)); + } + } + + function toggleMenu(): void { + if (menuEl.matches(":popover-open")) { + menuEl.hidePopover(); + return; + } + setMenuOpen(true); + const rect = toolButtonEl.getBoundingClientRect(); + const menuWidth = Math.max(rect.width, 180); + menuEl.style.top = `${rect.bottom + 2}px`; + menuEl.style.minWidth = `${menuWidth}px`; + if (rect.left + menuWidth > window.innerWidth - 8) { + menuEl.style.left = ""; + menuEl.style.right = `${window.innerWidth - rect.right}px`; + } else { + menuEl.style.right = ""; + menuEl.style.left = `${rect.left}px`; + } + menuEl.showPopover(); + } + + function listTools(): ToolDescription[] { + const d = doc(); + const type = d && getType(d); + if (!type) return []; + const list = getSupportedToolsForType(type).filter( + (t) => !t.unlisted && !t.forTitleBar && t.id !== EMBED_TOOL_ID + ); + // Datatype-specific tools before the generic wildcard ones. + list.sort((a, b) => Number(isWildcard(a)) - Number(isWildcard(b))); + return list; + } + + // Load the datatype (for getTitle/setTitle and its tools). + async function loadDatatype(): Promise { + const d = handle.doc(); + const type = d && getType(d); + if (!type) return; + const registry = getRegistry("patchwork:datatype"); + try { + await registry.load(type); + } catch { + // datatype unavailable; keep the fallbacks + } + const loaded = registry.get(type); + if (loaded && isLoadedPlugin(loaded)) { + setDatatype(() => loaded.module as DatatypeImplementation); + } + } + + return ( +
+
+ + queueMicrotask(() => { + el.focus(); + el.select(); + }) + } + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") finishRename(true, e.currentTarget.value); + if (e.key === "Escape") + finishRename(false, e.currentTarget.value); + }} + onBlur={(e) => finishRename(true, e.currentTarget.value)} + /> + } + > + canRename() && setRenaming(true)} + > + {title()} + + + {/* Deploy check: which build is actually running. */} + v{version} + +
+
+ +
+
{ + menuEl = el; + // Unrender the item list while closed. + el.addEventListener("toggle", (e) => { + if ((e as { newState?: string }).newState === "closed") { + setMenuOpen(false); + } + }); + }} + > + +
+ + {(tool) => ( + + )} + +
+
+
+
+ ); +} + +function isWildcard(tool: ToolDescription): boolean { + return ( + tool.supportedDatatypes === "*" || + (Array.isArray(tool.supportedDatatypes) && + tool.supportedDatatypes.includes("*")) + ); +} + +const openIcon = ` + + + +`; + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + "patchwork-view": { + "doc-url"?: string; + "tool-id"?: string; + style?: JSX.CSSProperties | string; + }; + } + } +} diff --git a/embed/tsconfig.json b/embed/tsconfig.json index 26a5844..83261da 100644 --- a/embed/tsconfig.json +++ b/embed/tsconfig.json @@ -4,6 +4,8 @@ "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/embed/vite.config.ts b/embed/vite.config.ts index 99f9d59..ee2494d 100644 --- a/embed/vite.config.ts +++ b/embed/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "vite"; +import solidPlugin from "vite-plugin-solid"; import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; import external from "@inkandswitch/patchwork-bootloader/externals"; @@ -6,7 +7,7 @@ export default defineConfig({ base: "./", // Per-chunk CSS injection keeps the entry DOM-free (it's evaluated in a // worker); styles ride with the lazily-imported tool chunk. - plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })], + plugins: [solidPlugin(), cssInjectedByJsPlugin({ relativeCSSInjection: true })], build: { minify: false, sourcemap: true, From 7e2b34b79706705766d37f2d8672ea6656ed4757 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 28 Jul 2026 16:11:38 +0200 Subject: [PATCH 7/9] add embed package to pnpm lockfile pnpm install never wrote the new workspace project into pnpm-lock.yaml, so CI's frozen-lockfile install failed. Importer entry written against the already-pinned snapshots (the subduction sync server that the patchwork pnpm plugin needs for re-resolving automerge: deps is currently down, blocking a regular lockfile regeneration); verified locally with pnpm install --frozen-lockfile passing lockfile validation. --- pnpm-lock.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb37ded..1cf8bea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -549,6 +549,40 @@ importers: specifier: ^2.11.10 version: 2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@7.3.6(@types/node@24.13.3)) + embed: + dependencies: + '@inkandswitch/patchwork-bootloader': + specifier: ^0.2.8 + version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(supports-color@7.2.0)(ws@8.21.1))(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29(supports-color@7.2.0))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0) + '@inkandswitch/patchwork-elements': + specifier: 1.0.0 + version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0)))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': + specifier: ^0.0.8 + version: 0.0.8(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-plugins': + specifier: ^0.0.11 + version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.39(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) + solid-js: + specifier: ^1.9.11 + version: 1.9.14 + devDependencies: + '@automerge/automerge-repo': + specifier: ^2.6.0-subduction.9 + version: 2.6.0-subduction.39(supports-color@7.2.0) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^5.4.21 + version: 5.4.21(@types/node@24.13.3) + vite-plugin-css-injected-by-js: + specifier: ^3.5.2 + version: 3.5.2(vite@5.4.21(@types/node@24.13.3)) + vite-plugin-solid: + specifier: ^2.11.10 + version: 2.11.13(solid-js@1.9.14)(supports-color@7.2.0)(vite@5.4.21(@types/node@24.13.3)) + file: dependencies: '@automerge/automerge': From dc39f22527ce4aba99cd28ba7b2c77d5d98729f5 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 29 Jul 2026 09:36:09 +0200 Subject: [PATCH 8/9] doc drags show the link cursor and can embed the same doc twice: dropEffect=link in codemirror and tldraw, tldraw drops get random shape ids instead of url-derived ones --- codemirror-markdown/src/extensions/embed.ts | 7 +++-- embed/package.json | 2 +- tldraw4/src/tool.tsx | 29 ++++++++++++--------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/codemirror-markdown/src/extensions/embed.ts b/codemirror-markdown/src/extensions/embed.ts index f8c85c3..6b1fc15 100644 --- a/codemirror-markdown/src/extensions/embed.ts +++ b/codemirror-markdown/src/extensions/embed.ts @@ -286,9 +286,12 @@ function embedDropHandlers() { return EditorView.domEventHandlers({ dragover(event) { - if (!wantsDragover(event.dataTransfer)) return false; + const dt = event.dataTransfer; + if (!wantsDragover(dt)) return false; event.preventDefault(); - if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + // Doc drags insert a *reference* to the same automerge doc, so show the + // link cursor; OS file drags genuinely copy content into new docs. + if (dt) dt.dropEffect = dt.types.includes("Files") ? "copy" : "link"; return true; }, drop(event, view) { diff --git a/embed/package.json b/embed/package.json index 8c237c4..c27ecea 100644 --- a/embed/package.json +++ b/embed/package.json @@ -1,6 +1,6 @@ { "name": "embed", - "version": "0.0.1", + "version": "0.0.2", "description": "Shared embed frame: title bar, tool picker, and open button around a nested patchwork-view", "type": "module", "main": "./dist/index.js", diff --git a/tldraw4/src/tool.tsx b/tldraw4/src/tool.tsx index 9262016..37eff64 100644 --- a/tldraw4/src/tool.tsx +++ b/tldraw4/src/tool.tsx @@ -11,6 +11,7 @@ import { useEditor, getMediaAssetInfoPartial, atom, + createShapeId, type VecLike, type TLContent, type TLAssetId, @@ -41,7 +42,6 @@ import { PatchworkDocShapeUtil, PATCHWORK_DOC_SHAPE_TYPE, getDefaultToolId, - makeShapeId, } from "./PatchworkDocShape.tsx"; import { NewDocShapeTool, @@ -421,13 +421,18 @@ function usePatchworkDrop(element: HTMLElement) { const isInsideEmbeddedPatchworkView = (e: DragEvent) => { for (const el of e.composedPath()) { if (el === element) break; - if ((el as Element).tagName?.toLowerCase() === "patchwork-view") return true; + if ((el as Element).tagName?.toLowerCase() === "patchwork-view") + return true; } return false; }; const allowDrop = (e: DragEvent) => { - if (e.dataTransfer && isPatchworkDrag(e.dataTransfer.types)) e.preventDefault(); + if (e.dataTransfer && isPatchworkDrag(e.dataTransfer.types)) { + e.preventDefault(); + // Dropping embeds a *reference* to the same automerge doc, not a copy. + e.dataTransfer.dropEffect = "link"; + } }; const handleDrop = (e: DragEvent) => { @@ -441,15 +446,13 @@ function usePatchworkDrop(element: HTMLElement) { const dropPoint = editor.screenToPage({ x: e.clientX, y: e.clientY }); const STAGGER = 24; + const createdIds: TLShapeId[] = []; docs.forEach((item, i) => { - const shapeId = makeShapeId(item.url); - - // Already embedded: select it rather than creating a duplicate. - if (editor.getShape(shapeId)) { - editor.select(shapeId); - return; - } + // Random id (not derived from the doc url) so the same document can be + // embedded on the canvas any number of times. + const shapeId = createShapeId(); + createdIds.push(shapeId); const knownType = item.type ?? ""; editor.createShape({ @@ -474,7 +477,9 @@ function usePatchworkDrop(element: HTMLElement) { if (!item.type || !item.name) { void (async () => { try { - const handle = await repo.find<{ "@patchwork"?: { type?: string } }>(item.url); + const handle = await repo.find<{ + "@patchwork"?: { type?: string }; + }>(item.url); const doc = handle.doc(); const datatypeId = doc?.["@patchwork"]?.type ?? knownType; if (!editor.getShape(shapeId)) return; @@ -494,7 +499,7 @@ function usePatchworkDrop(element: HTMLElement) { } }); - editor.setSelectedShapes(docs.map((d) => makeShapeId(d.url))); + editor.setSelectedShapes(createdIds); }; element.addEventListener("dragenter", allowDrop, { capture: true }); From 6db6ba21d98db4229db0b6eca882f7d34e12376e Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 29 Jul 2026 09:59:33 +0200 Subject: [PATCH 9/9] drop the solid JSX augmentation for patchwork-view: the embed frame creates the nested view imperatively; reword an embed comment --- codemirror-markdown/src/extensions/embed.ts | 4 +-- embed/src/tool.tsx | 31 ++++++++------------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/codemirror-markdown/src/extensions/embed.ts b/codemirror-markdown/src/extensions/embed.ts index 6b1fc15..852aec0 100644 --- a/codemirror-markdown/src/extensions/embed.ts +++ b/codemirror-markdown/src/extensions/embed.ts @@ -45,8 +45,8 @@ class EmbedWidget extends WidgetType { container.className = "cm-embed"; const patchworkView = document.createElement("patchwork-view"); - // No heads in the url: OverlayRepo resolution pins the embed to the active - // checkpoint when one is checked out, and renders live otherwise. + // No heads in the url: the embed follows whatever version the host + // resolves — the active checkpoint when one is checked out, live otherwise. patchworkView.setAttribute("doc-url", `automerge:${this.docId}`); patchworkView.setAttribute("tool-id", "embed"); if (this.toolId) patchworkView.setAttribute("embed-tool-id", this.toolId); diff --git a/embed/src/tool.tsx b/embed/src/tool.tsx index a89795f..be7c34f 100644 --- a/embed/src/tool.tsx +++ b/embed/src/tool.tsx @@ -15,7 +15,7 @@ import { import { getType } from "@inkandswitch/patchwork-filesystem"; import { openDocument } from "@inkandswitch/patchwork-elements"; import type { DocHandle } from "@automerge/automerge-repo"; -import { createSignal, For, onCleanup, Show, type JSX } from "solid-js"; +import { createEffect, createSignal, For, onCleanup, Show } from "solid-js"; import { render } from "solid-js/web"; import { version } from "../package.json"; import "./embed.css"; @@ -140,6 +140,16 @@ function Frame(props: FrameProps) { return list; } + // Built imperatively rather than as JSX so we don't have to teach Solid's + // JSX types about the custom element. + const nestedView = document.createElement("patchwork-view"); + nestedView.setAttribute("doc-url", handle.url); + createEffect(() => { + const toolId = pinnedToolId(); + if (toolId) nestedView.setAttribute("tool-id", toolId); + else nestedView.removeAttribute("tool-id"); + }); + // Load the datatype (for getTitle/setTitle and its tools). async function loadDatatype(): Promise { const d = handle.doc(); @@ -210,12 +220,7 @@ function Frame(props: FrameProps) { } />
-
- -
+
{nestedView}
`; - -declare module "solid-js" { - namespace JSX { - interface IntrinsicElements { - "patchwork-view": { - "doc-url"?: string; - "tool-id"?: string; - style?: JSX.CSSProperties | string; - }; - } - } -}