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: '.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">📰</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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue