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:
parent
89ffc0aca4
commit
42c6dea091
|
|
@ -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">📰</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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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,19 +3919,57 @@
|
||||||
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();
|
||||||
|
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");
|
startTriage(text, "drop");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Paste handler — only when not focused on an input/textarea/contenteditable
|
// Paste handler — only when not focused on an input/textarea/contenteditable
|
||||||
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"));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue