From 1fe9b1c8bdda7b57dd24098809e1309889a60f7c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 15 Apr 2026 12:00:57 -0400 Subject: [PATCH] feat(canvas): add Extract to Canvas button on all generator shapes One-click extraction of generated artifacts (images, videos, SVGs) from generator tools into standalone canvas objects visible to all collaborators. Co-Authored-By: Claude Opus 4.6 --- lib/extract-artifact.ts | 84 +++++++++++++++++++++++++++++++++++++++++ lib/folk-blender.ts | 12 +++++- lib/folk-freecad.ts | 13 ++++++- lib/folk-image-gen.ts | 12 ++++++ lib/folk-kicad.ts | 16 +++++++- lib/folk-video-gen.ts | 12 ++++++ lib/folk-zine-gen.ts | 14 ++++++- 7 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 lib/extract-artifact.ts diff --git a/lib/extract-artifact.ts b/lib/extract-artifact.ts new file mode 100644 index 00000000..e291e641 --- /dev/null +++ b/lib/extract-artifact.ts @@ -0,0 +1,84 @@ +/** + * extractArtifactToCanvas — Pull a generated artifact out of a generator shape + * and place it as a standalone canvas object (folk-image, folk-embed, or folk-bookmark). + */ + +import type { FolkShape } from "./folk-shape"; + +export type ArtifactMediaType = "image" | "video" | "pdf" | "download"; + +interface ExtractOptions { + url: string; + mediaType: ArtifactMediaType; + title?: string; + sourceShape: FolkShape; +} + +interface CanvasApi { + newShape: (tagName: string, props?: Record, atPosition?: { x: number; y: number }) => any; + findFreePosition: (w: number, h: number, px?: number, py?: number, exclude?: any) => { x: number; y: number }; + SHAPE_DEFAULTS: Record; +} + +const TAG_MAP: Record = { + image: "folk-image", + video: "folk-embed", + pdf: "folk-embed", + download: "folk-bookmark", +}; + +export function extractArtifactToCanvas({ url, mediaType, title, sourceShape }: ExtractOptions): boolean { + const api = (window as any).__canvasApi as CanvasApi | undefined; + if (!api) { + console.warn("[extract-artifact] Canvas API not available"); + return false; + } + + const tagName = TAG_MAP[mediaType]; + const defaults = api.SHAPE_DEFAULTS[tagName] || { width: 400, height: 300 }; + + // Position to the right of the source shape + const preferX = sourceShape.x + sourceShape.width + 40 + defaults.width / 2; + const preferY = sourceShape.y + sourceShape.height / 2; + const pos = api.findFreePosition(defaults.width, defaults.height, preferX, preferY, sourceShape); + + const props: Record = {}; + if (mediaType === "image") { + props.src = url; + if (title) props.alt = title; + } else if (mediaType === "video" || mediaType === "pdf") { + props.url = url; + } else { + props.url = url; + if (title) props.title = title; + } + + api.newShape(tagName, props, { x: pos.x + defaults.width / 2, y: pos.y + defaults.height / 2 }); + return true; +} + +/** CSS for the extract button — inject into each component's stylesheet */ +export const extractBtnCss = ` + .extract-btn { + position: absolute; + top: 6px; + right: 6px; + padding: 3px 8px; + background: rgba(0, 0, 0, 0.6); + color: white; + border: none; + border-radius: 12px; + font-size: 12px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; + z-index: 1; + line-height: 1; + } + .image-item:hover .extract-btn, + .video-item:hover .extract-btn, + .section:hover .extract-btn, + .render-preview:hover .extract-btn, + .preview-area:hover > .extract-btn { opacity: 1; } + .extract-btn:hover { background: rgba(0, 0, 0, 0.8); } +`; diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts index ad15281d..85aad3ad 100644 --- a/lib/folk-blender.ts +++ b/lib/folk-blender.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { extractArtifactToCanvas, extractBtnCss } from "./extract-artifact"; const styles = css` :host { @@ -202,6 +203,8 @@ const styles = css` .render-preview:hover .zoom-reset { opacity: 1; } .render-preview .zoom-reset:hover { background: var(--rs-surface-hover, #f1f5f9); } + ${extractBtnCss} + .code-area { flex: 1; overflow: auto; @@ -638,7 +641,7 @@ export class FolkBlender extends FolkShape { this.#viewCleanup = null; if (this.#renderUrl) { - this.#previewArea.innerHTML = `3D RenderScroll to zoom · drag to pan`; + this.#previewArea.innerHTML = `3D RenderScroll to zoom · drag to pan`; const img = this.#previewArea.querySelector("img") as HTMLImageElement; const resetBtn = this.#previewArea.querySelector(".zoom-reset") as HTMLButtonElement; @@ -668,6 +671,13 @@ export class FolkBlender extends FolkShape { e.stopPropagation(); fitImage(); }); + + // Extract button + const extractBtn = this.#previewArea.querySelector(".extract-btn") as HTMLElement; + extractBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + extractArtifactToCanvas({ url: extractBtn.dataset.url!, mediaType: "image", title: extractBtn.dataset.title, sourceShape: this }); + }); } else { this.#previewArea.innerHTML = '
Script generated (see Script tab)
'; } diff --git a/lib/folk-freecad.ts b/lib/folk-freecad.ts index 0b2bde7e..ffef2deb 100644 --- a/lib/folk-freecad.ts +++ b/lib/folk-freecad.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { extractArtifactToCanvas, extractBtnCss } from "./extract-artifact"; const styles = css` :host { @@ -160,6 +161,8 @@ const styles = css` background: #0e7490; } + ${extractBtnCss} + .placeholder { flex: 1; display: flex; @@ -311,6 +314,14 @@ export class FolkFreeCAD extends FolkShape { this.#renderResult(); } + // Extract button — delegated click + this.#previewArea?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".extract-btn") as HTMLElement; + if (!btn) return; + e.stopPropagation(); + extractArtifactToCanvas({ url: btn.dataset.url!, mediaType: "image", title: btn.dataset.title, sourceShape: this }); + }); + // Health check — only disable if endpoint unreachable (sidecar starts on demand) fetch("/api/freecad/health").then(r => r.json()).then((h: any) => { if (this.#generateBtn && h.available === false && h.status && !h.status.includes("on demand")) { @@ -366,7 +377,7 @@ export class FolkFreeCAD extends FolkShape { #renderResult() { if (this.#previewArea) { if (this.#previewUrl) { - this.#previewArea.innerHTML = `CAD Preview`; + this.#previewArea.innerHTML = `CAD Preview`; } else { this.#previewArea.innerHTML = '
Model generated! Download files below.
'; } diff --git a/lib/folk-image-gen.ts b/lib/folk-image-gen.ts index 3f629ddd..272cef03 100644 --- a/lib/folk-image-gen.ts +++ b/lib/folk-image-gen.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { extractArtifactToCanvas, extractBtnCss } from "./extract-artifact"; const styles = css` :host { @@ -141,6 +142,8 @@ const styles = css` position: relative; } + ${extractBtnCss} + .image-prompt { font-size: 11px; color: #64748b; @@ -316,6 +319,14 @@ export class FolkImageGen extends FolkShape { // Prevent canvas drag when interacting with content this.#imageArea?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + // Extract button — delegated click + this.#imageArea?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".extract-btn") as HTMLElement; + if (!btn) return; + e.stopPropagation(); + extractArtifactToCanvas({ url: btn.dataset.url!, mediaType: "image", title: btn.dataset.title, sourceShape: this }); + }); this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); // Close button @@ -422,6 +433,7 @@ export class FolkImageGen extends FolkShape { .map( (img) => `
+ ${this.#escapeHtml(img.prompt)}
${this.#escapeHtml(img.prompt)}
diff --git a/lib/folk-kicad.ts b/lib/folk-kicad.ts index 9aad1e81..fa6098e8 100644 --- a/lib/folk-kicad.ts +++ b/lib/folk-kicad.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { extractArtifactToCanvas, extractBtnCss } from "./extract-artifact"; const styles = css` :host { @@ -218,6 +219,8 @@ const styles = css` background: #047857; } + ${extractBtnCss} + .placeholder { flex: 1; display: flex; @@ -397,6 +400,15 @@ export class FolkKiCAD extends FolkShape { this.#showExports(); } + // Extract button — delegated click + this.#previewArea?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".extract-btn") as HTMLElement; + if (!btn) return; + e.stopPropagation(); + const mediaType = (btn.dataset.media || "image") as "image" | "pdf"; + extractArtifactToCanvas({ url: btn.dataset.url!, mediaType, title: btn.dataset.title, sourceShape: this }); + }); + // Health check — only disable if endpoint unreachable (sidecar starts on demand) fetch("/api/kicad/health").then(r => r.json()).then((h: any) => { if (this.#generateBtn && h.available === false && h.status && !h.status.includes("on demand")) { @@ -465,14 +477,14 @@ export class FolkKiCAD extends FolkShape { switch (this.#activeTab) { case "schematic": if (this.#schematicSvg) { - this.#previewArea.innerHTML = `Schematic`; + this.#previewArea.innerHTML = `Schematic`; } else { this.#previewArea.innerHTML = '
📋Schematic will appear here
'; } break; case "board": if (this.#boardSvg) { - this.#previewArea.innerHTML = `Board Layout`; + this.#previewArea.innerHTML = `Board Layout`; } else { this.#previewArea.innerHTML = '
📟Board layout will appear here
'; } diff --git a/lib/folk-video-gen.ts b/lib/folk-video-gen.ts index 5ffc6ba8..dd4c3f90 100644 --- a/lib/folk-video-gen.ts +++ b/lib/folk-video-gen.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { extractArtifactToCanvas, extractBtnCss } from "./extract-artifact"; const styles = css` :host { @@ -234,6 +235,8 @@ const styles = css` position: relative; } + ${extractBtnCss} + .video-prompt { font-size: 11px; color: #64748b; @@ -469,6 +472,14 @@ export class FolkVideoGen extends FolkShape { // Prevent drag on inputs this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + // Extract button — delegated click + this.#videoArea?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".extract-btn") as HTMLElement; + if (!btn) return; + e.stopPropagation(); + extractArtifactToCanvas({ url: btn.dataset.url!, mediaType: "video", title: btn.dataset.title, sourceShape: this }); + }); + // Model selector this.#modelSelect?.addEventListener("change", (e) => { e.stopPropagation(); @@ -690,6 +701,7 @@ export class FolkVideoGen extends FolkShape { .map( (vid) => `
+
${this.#escapeHtml(vid.prompt)}
diff --git a/lib/folk-zine-gen.ts b/lib/folk-zine-gen.ts index 5747d9d3..7620bcc8 100644 --- a/lib/folk-zine-gen.ts +++ b/lib/folk-zine-gen.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import { extractArtifactToCanvas, extractBtnCss } from "./extract-artifact"; const styles = css` :host { @@ -193,6 +194,8 @@ const styles = css` border-color: #f59e0b; } + ${extractBtnCss} + .section-header { display: flex; align-items: center; @@ -694,8 +697,9 @@ export class FolkZineGen extends FolkShape { `; } else if (section.type === "image") { - sectionsHtml += `
`; + sectionsHtml += `
`; if (section.imageUrl) { + sectionsHtml += ``; sectionsHtml += `${this.#escapeHtml(section.imagePrompt || `; } else { sectionsHtml += `
${isRegenerating ? '
' : "No image yet — click ↻ to generate"}
`; @@ -821,6 +825,14 @@ export class FolkZineGen extends FolkShape { e.stopPropagation(); this.#downloadZine(); }); + + // Extract button — delegated click on image sections + this.#contentEl.querySelector(".page-content")?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".extract-btn") as HTMLElement; + if (!btn) return; + e.stopPropagation(); + extractArtifactToCanvas({ url: btn.dataset.url!, mediaType: "image", title: btn.dataset.title, sourceShape: this }); + }); } async #regenerateSection(sectionId: string, feedback: string) {