diff --git a/codemirror-markdown/src/extensions/embed.ts b/codemirror-markdown/src/extensions/embed.ts index 3ded21c4..852aec02 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"; @@ -14,15 +15,17 @@ 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. + * 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": 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; @@ -37,96 +40,67 @@ 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(); + const patchworkView = document.createElement("patchwork-view"); + // 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); + // 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 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 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"); - // 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); - // 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%"; - - container.appendChild(label); - container.appendChild(view); + 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() { + // 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; - 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 - // 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; @@ -136,14 +110,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({ @@ -287,7 +253,13 @@ 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"); + // 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; + if (pos < line.to) text = text + "\n"; + } view.dispatch({ changes: { from: pos, insert: text }, selection: { anchor: pos + text.length }, @@ -314,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) { @@ -353,18 +328,23 @@ 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) { + // Viewport too, so newly-visible markers get decorated. + if (update.docChanged || update.viewportChanged) { this.decorations = getEmbedLinks(update.view); } } }, { decorations: (v) => v.decorations, + // Atomic: the cursor skips embeds and delete removes the whole marker. + provide: (plugin) => + EditorView.atomicRanges.of( + (view) => view.plugin(plugin)?.decorations ?? Decoration.none + ), } ); export function markdownEmbed() { - return [embedPlugin, embedTheme, embedDropHandlers()]; + // `dropCursor` observes events, so it coexists with our dragover handler. + return [embedPlugin, embedTheme, embedDropHandlers(), dropCursor()]; } diff --git a/codemirror-markdown/src/extensions/icons.ts b/codemirror-markdown/src/extensions/icons.ts deleted file mode 100644 index a027bc2f..00000000 --- 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 991eb389..afce8633 100644 --- a/codemirror-markdown/src/themes/embed.ts +++ b/codemirror-markdown/src/themes/embed.ts @@ -1,5 +1,6 @@ import { EditorView } from "@codemirror/view"; +// Outer container only; all embed chrome lives in the shared "embed" tool. export const embedTheme = EditorView.baseTheme({ ".cm-embed": { display: "block", @@ -13,58 +14,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 00000000..c27ecea8 --- /dev/null +++ b/embed/package.json @@ -0,0 +1,27 @@ +{ + "name": "embed", + "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", + "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", + "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-solid": "^2.11.10" + } +} diff --git a/embed/src/embed.css b/embed/src/embed.css new file mode 100644 index 00000000..09a44af1 --- /dev/null +++ b/embed/src/embed.css @@ -0,0 +1,166 @@ +@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 .version { + font-size: 0.7em; + color: var(--embed-frame-muted); + flex-shrink: 0; +} + +.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 .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 { + 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 00000000..cdb65d32 --- /dev/null +++ b/embed/src/index.ts @@ -0,0 +1,19 @@ +// 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[] = [ + { + type: "patchwork:tool", + id: "embed", + name: "Embed", + icon: "PictureInPicture2", + supportedDatatypes: "*", + // 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.tsx b/embed/src/tool.tsx new file mode 100644 index 00000000..be7c34f5 --- /dev/null +++ b/embed/src/tool.tsx @@ -0,0 +1,272 @@ +// 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 { createEffect, createSignal, For, onCleanup, Show } 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; + } + + // 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(); + 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} + +
+
{nestedView}
+
{ + 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 = ` + + + +`; diff --git a/embed/tsconfig.json b/embed/tsconfig.json new file mode 100644 index 00000000..83261da9 --- /dev/null +++ b/embed/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "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 00000000..ee2494d2 --- /dev/null +++ b/embed/vite.config.ts @@ -0,0 +1,27 @@ +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"; + +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: [solidPlugin(), 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/pnpm-lock.yaml b/pnpm-lock.yaml index eb37ded8..1cf8beaf 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': diff --git a/tldraw4/src/PatchworkDocShape.tsx b/tldraw4/src/PatchworkDocShape.tsx index 2483e798..ed745bcb 100644 --- a/tldraw4/src/PatchworkDocShape.tsx +++ b/tldraw4/src/PatchworkDocShape.tsx @@ -13,22 +13,16 @@ 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 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; @@ -128,17 +122,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 +129,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 +141,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 +175,25 @@ function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { }; }, [isFocused]); - const handleToolChange = useCallback( - (newToolId: string) => { + // 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 so nested embeds don't also change the outer embed. + e.stopPropagation(); + 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 +212,8 @@ 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 */} + {/* Interactive only while editing (double-click); otherwise pointer + events fall through so tldraw can select and drag the shape. */}
{ - 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 +260,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; - } -} diff --git a/tldraw4/src/tool.tsx b/tldraw4/src/tool.tsx index 92620165..37eff644 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 });