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: '.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-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[]) {
@ -431,6 +432,10 @@ export class FolkPubsEditor extends HTMLElement {
${this._loading ? "Generating..." : "Generate PDF"}
</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._pdfUrl ? `
@ -845,6 +850,23 @@ export class FolkPubsEditor extends HTMLElement {
.btn-generate:hover { background: var(--rs-primary-hover); }
.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 {
color: #f87171;
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.
## 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)
- Locations / addresses / places folk-map (set query prop)
- 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`;
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-prompt", "folk-image-gen", "folk-slide",
]);

View File

@ -3860,14 +3860,45 @@
const overlay = document.getElementById("triage-drop-overlay");
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) {
const mgr = new TriageManager();
const panel = new MiTriagePanel(mgr);
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) => {
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++;
overlay.classList.add("active");
}
@ -3888,10 +3919,24 @@
document.addEventListener("drop", (e) => {
dragEnterCount = 0;
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();
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) => {
const el = document.activeElement;
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();
startTriage(text, "paste");
}
@ -3930,6 +3999,8 @@
document.getElementById("new-piano").addEventListener("click", () => setPendingTool("folk-piano"));
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-map").addEventListener("click", () => setPendingTool("folk-map"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));