feat(canvas): image paste/drop + bookmark cards + enhanced paste/drop handlers

- Enhanced paste: clipboard images upload via /api/image-upload → folk-image shape
- Enhanced paste: URLs create folk-image (image ext) or folk-bookmark (others)
- Enhanced drop: image files upload → folk-image, URLs → bookmark/image
- Short URLs (< 20 chars) now handled instead of ignored
- Long text still goes through AI triage (existing behavior preserved)
- Updated mi-routes triage to distinguish folk-image/folk-bookmark/folk-embed
- Added folk-image + folk-bookmark to CSS selectors, SHAPE_DEFAULTS, registry
- Added zine generator link to rPubs editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 19:50:25 -07:00
parent 89ffc0aca4
commit 42c6dea091
3 changed files with 104 additions and 8 deletions

View File

@ -85,6 +85,7 @@ export class FolkPubsEditor extends HTMLElement {
{ target: '.format-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false }, { target: '.format-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false },
{ target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF in the selected format.", advanceOnClick: false }, { target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF in the selected format.", advanceOnClick: false },
{ target: '.btn-new-draft', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false }, { target: '.btn-new-draft', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false },
{ target: '.btn-zine-gen', title: "Zine Generator", message: "Generate an AI-illustrated 8-page zine — pick a topic, style, and tone, then edit any section before printing.", advanceOnClick: false },
]; ];
set formats(val: BookFormat[]) { set formats(val: BookFormat[]) {
@ -431,6 +432,10 @@ export class FolkPubsEditor extends HTMLElement {
${this._loading ? "Generating..." : "Generate PDF"} ${this._loading ? "Generating..." : "Generate PDF"}
</button> </button>
<a class="btn-zine-gen" href="/${this._spaceSlug}/rpubs/zine">
<span style="font-size:1rem">&#128240;</span> AI Zine Generator
</a>
${this._error ? `<div class="error">${this.escapeHtml(this._error)}</div>` : ""} ${this._error ? `<div class="error">${this.escapeHtml(this._error)}</div>` : ""}
${this._pdfUrl ? ` ${this._pdfUrl ? `
@ -845,6 +850,23 @@ export class FolkPubsEditor extends HTMLElement {
.btn-generate:hover { background: var(--rs-primary-hover); } .btn-generate:hover { background: var(--rs-primary-hover); }
.btn-generate:disabled { opacity: 0.5; cursor: not-allowed; } .btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-zine-gen {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, #f59e0b, #ef4444);
color: #fff;
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
text-align: center;
justify-content: center;
transition: opacity 0.15s;
}
.btn-zine-gen:hover { opacity: 0.85; }
.error { .error {
color: #f87171; color: #f87171;
font-size: 0.8rem; font-size: 0.8rem;

View File

@ -225,7 +225,9 @@ Given raw unstructured content (pasted text, meeting notes, link dumps, etc.),
analyze it and classify each distinct piece into the most appropriate canvas shape type. analyze it and classify each distinct piece into the most appropriate canvas shape type.
## Shape Mapping Rules ## Shape Mapping Rules
- URLs / links folk-embed (set url prop) - Image URLs (.png, .jpg, .gif, .webp, .svg) folk-image (set src prop)
- Simple links / URLs (not embeddable video/interactive) folk-bookmark (set url prop)
- Embeddable URLs (YouTube, Twitter, Google Maps, Gather, etc.) folk-embed (set url prop)
- Dates / events / schedules folk-calendar (set title, description props) - Dates / events / schedules folk-calendar (set title, description props)
- Locations / addresses / places folk-map (set query prop) - Locations / addresses / places folk-map (set query prop)
- Action items / TODOs / tasks folk-workflow-block (set label, blockType:"action" props) - Action items / TODOs / tasks folk-workflow-block (set label, blockType:"action" props)
@ -249,7 +251,8 @@ Return a JSON object with:
- If the content is too short or trivial for multiple shapes, still return at least one shape`; - If the content is too short or trivial for multiple shapes, still return at least one shape`;
const KNOWN_TRIAGE_SHAPES = new Set([ const KNOWN_TRIAGE_SHAPES = new Set([
"folk-markdown", "folk-embed", "folk-calendar", "folk-map", "folk-markdown", "folk-embed", "folk-image", "folk-bookmark",
"folk-calendar", "folk-map",
"folk-workflow-block", "folk-social-post", "folk-choice-vote", "folk-workflow-block", "folk-social-post", "folk-choice-vote",
"folk-prompt", "folk-image-gen", "folk-slide", "folk-prompt", "folk-image-gen", "folk-slide",
]); ]);

View File

@ -3860,14 +3860,45 @@
const overlay = document.getElementById("triage-drop-overlay"); const overlay = document.getElementById("triage-drop-overlay");
let dragEnterCount = 0; let dragEnterCount = 0;
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|svg|bmp|ico)(\?.*)?$/i;
const URL_RE = /^https?:\/\/\S+$/i;
function startTriage(text, type) { function startTriage(text, type) {
const mgr = new TriageManager(); const mgr = new TriageManager();
const panel = new MiTriagePanel(mgr); const panel = new MiTriagePanel(mgr);
mgr.analyze(text, type); mgr.analyze(text, type);
} }
async function handleImageFile(file) {
const reader = new FileReader();
reader.onload = async () => {
try {
const res = await fetch("/api/image-upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: reader.result }),
});
const data = await res.json();
if (data.url) {
window.__canvasApi.newShape("folk-image", { src: data.url, alt: file.name });
}
} catch (err) {
console.error("[canvas] image upload failed:", err);
}
};
reader.readAsDataURL(file);
}
function handleUrl(url) {
if (IMAGE_EXT_RE.test(url)) {
window.__canvasApi.newShape("folk-image", { src: url });
} else {
window.__canvasApi.newShape("folk-bookmark", { url });
}
}
document.addEventListener("dragenter", (e) => { document.addEventListener("dragenter", (e) => {
if (e.dataTransfer?.types?.includes("text/plain") || e.dataTransfer?.types?.includes("text/uri-list")) { if (e.dataTransfer?.types?.includes("text/plain") || e.dataTransfer?.types?.includes("text/uri-list") || e.dataTransfer?.types?.includes("Files")) {
dragEnterCount++; dragEnterCount++;
overlay.classList.add("active"); overlay.classList.add("active");
} }
@ -3888,10 +3919,24 @@
document.addEventListener("drop", (e) => { document.addEventListener("drop", (e) => {
dragEnterCount = 0; dragEnterCount = 0;
overlay.classList.remove("active"); overlay.classList.remove("active");
const text = e.dataTransfer?.getData("text/plain") || e.dataTransfer?.getData("text/uri-list") || "";
if (text.trim()) { // 1. Check for image files
const imageFile = Array.from(e.dataTransfer?.files || []).find(f => f.type.startsWith("image/"));
if (imageFile) {
e.preventDefault(); e.preventDefault();
startTriage(text, "drop"); handleImageFile(imageFile);
return;
}
// 2. Check for text/URL
const text = (e.dataTransfer?.getData("text/plain") || e.dataTransfer?.getData("text/uri-list") || "").trim();
if (text) {
e.preventDefault();
if (URL_RE.test(text)) {
handleUrl(text);
} else {
startTriage(text, "drop");
}
} }
}); });
@ -3899,8 +3944,32 @@
document.addEventListener("paste", (e) => { document.addEventListener("paste", (e) => {
const el = document.activeElement; const el = document.activeElement;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
const text = e.clipboardData?.getData("text/plain") || "";
if (text.trim() && text.length > 20) { // 1. Check for image in clipboard
const items = Array.from(e.clipboardData?.items || []);
const imageItem = items.find(item => item.type.startsWith("image/"));
if (imageItem) {
const file = imageItem.getAsFile();
if (file) {
e.preventDefault();
handleImageFile(file);
return;
}
}
// 2. Check for text
const text = (e.clipboardData?.getData("text/plain") || "").trim();
if (!text) return;
// 3. URL detection
if (URL_RE.test(text)) {
e.preventDefault();
handleUrl(text);
return;
}
// 4. Long text → triage (existing behavior)
if (text.length > 20) {
e.preventDefault(); e.preventDefault();
startTriage(text, "paste"); startTriage(text, "paste");
} }
@ -3930,6 +3999,8 @@
document.getElementById("new-piano").addEventListener("click", () => setPendingTool("folk-piano")); document.getElementById("new-piano").addEventListener("click", () => setPendingTool("folk-piano"));
document.getElementById("new-embed").addEventListener("click", () => setPendingTool("folk-embed")); document.getElementById("new-embed").addEventListener("click", () => setPendingTool("folk-embed"));
document.getElementById("new-image").addEventListener("click", () => setPendingTool("folk-image"));
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar")); document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map")); document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen")); document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));