blog post editor

This commit is contained in:
2026-05-10 19:09:26 +10:00
parent 40fd8e8e42
commit 0f59de54bb
26 changed files with 6049 additions and 625 deletions

View File

@@ -202,6 +202,13 @@ export class ItemDragger {
if (!this.#enabled) return;
const target = /** @type {HTMLElement} */ (e.target);
if (target.closest(".pin-handle")) return;
// Allow text selection in blog/info node bodies
const nodeBody = target.closest(".node-body");
if (nodeBody && (node.type === 'info' || node.type === 'blog_post')) {
return; // Don't drag, allow text selection
}
e.stopPropagation();
const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY);

View File

@@ -91,7 +91,8 @@ export class MarkdownRenderer {
const langClass = lang ? ` language-${lang}` : "";
const styleAttr = maxHeight ? ` style="max-height: ${maxHeight}px; overflow-y: auto;"` : "";
return `<pre class="md-pre"${styleAttr}><code class="${langClass}">${highlightedCode}</code></pre>`;
const escapedCode = MarkdownRenderer.#escape(code);
return `<div class="md-code-block"><button class="md-copy-btn" data-code="${escapedCode}" title="Copy code">📋</button><pre class="md-pre"${styleAttr}><code class="${langClass}">${highlightedCode}</code></pre></div>`;
}
// Horizontal rule
@@ -106,6 +107,16 @@ export class MarkdownRenderer {
return `<h${level} class="md-h${level}">${MarkdownRenderer.#renderInline(headingMatch[2])}</h${level}>`;
}
// Standalone image
const imgBlockMatch = block.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (imgBlockMatch) {
const alt = MarkdownRenderer.#escape(imgBlockMatch[1]);
const src = MarkdownRenderer.#sanitizeUrl(imgBlockMatch[2]);
if (src) {
return `<figure class="md-figure"><img class="md-img" src="${src}" alt="${alt}" />${alt ? `<figcaption class="md-figcaption">${alt}</figcaption>` : ""}</figure>`;
}
}
// Blockquote
if (/^> /.test(block)) {
const inner = block.replace(/^> ?/gm, "");
@@ -114,20 +125,12 @@ export class MarkdownRenderer {
// Unordered list
if (/^[-*+] /.test(block)) {
const items = block.split("\n").filter(Boolean).map(line => {
const content = line.replace(/^[-*+]\s+/, "");
return `<li class="md-li">${MarkdownRenderer.#renderInline(content)}</li>`;
});
return `<ul class="md-ul">${items.join("")}</ul>`;
return MarkdownRenderer.#renderList(block, "ul");
}
// Ordered list
if (/^\d+\. /.test(block)) {
const items = block.split("\n").filter(Boolean).map(line => {
const content = line.replace(/^\d+\.\s+/, "");
return `<li class="md-li">${MarkdownRenderer.#renderInline(content)}</li>`;
});
return `<ol class="md-ol">${items.join("")}</ol>`;
return MarkdownRenderer.#renderList(block, "ol");
}
// Paragraph (handle single-line line breaks within block)
@@ -135,6 +138,57 @@ export class MarkdownRenderer {
return `<p class="md-p">${lines.join("<br />")}</p>`;
}
/**
* Renders nested lists (ul or ol) with indentation support.
* @param {string} block
* @param {"ul" | "ol"} listType
* @returns {string}
*/
static #renderList(block, listType) {
const lines = block.split("\n").filter(Boolean);
const items = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const indent = line.match(/^(\s*)/)[1].length;
// Extract content
let content;
if (listType === "ul") {
content = line.replace(/^\s*[-*+]\s+/, "");
} else {
content = line.replace(/^\s*\d+\.\s+/, "");
}
// Look ahead for nested items (more indented)
let nestedBlock = "";
let j = i + 1;
while (j < lines.length) {
const nextLine = lines[j];
const nextIndent = nextLine.match(/^(\s*)/)[1].length;
if (nextIndent > indent) {
nestedBlock += (nestedBlock ? "\n" : "") + nextLine.slice(indent + 2); // Remove parent indent
j++;
} else {
break;
}
}
// Render item with nested list if present
let itemHtml = MarkdownRenderer.#renderInline(content);
if (nestedBlock) {
const nestedType = /^[-*+] /.test(nestedBlock.trim()) ? "ul" : "ol";
itemHtml += MarkdownRenderer.#renderList(nestedBlock, nestedType);
}
items.push(`<li class="md-li">${itemHtml}</li>`);
i = j;
}
const tag = listType === "ul" ? "ul" : "ol";
return `<${tag} class="md-${tag}">${items.join("")}</${tag}>`;
}
// ─── Inline-level ─────────────────────────────────────────────────────────
/**
@@ -159,6 +213,16 @@ export class MarkdownRenderer {
// Italic
out = out.replace(/\*(.+?)\*/g, "<em>$1</em>");
// Images ![alt](url) — must be matched before links
out = out.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
(_, alt, url) => {
const safeUrl = MarkdownRenderer.#sanitizeUrl(url);
if (!safeUrl) return MarkdownRenderer.#escape(alt);
return `<img class="md-img md-img--inline" src="${safeUrl}" alt="${MarkdownRenderer.#escape(alt)}" />`;
}
);
// Links [text](url)
out = out.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
@@ -203,7 +267,10 @@ export class MarkdownRenderer {
*/
static #sanitizeUrl(url) {
const trimmed = url.trim();
// Allow absolute URLs, root-relative, hash links, relative-dot paths, and plain relative paths
if (/^(https?:\/\/|mailto:|\/|#|\.)/.test(trimmed)) return trimmed;
// Allow relative paths (e.g. data/blog/media/image.png) — no protocol = safe relative
if (/^[a-zA-Z0-9_\-][a-zA-Z0-9_.\-\/]*$/.test(trimmed)) return trimmed;
return null;
}
}

View File

@@ -481,6 +481,19 @@ export class WorkspaceNavigator {
/** @param {PointerEvent} e */
#onPointerDown(e) {
if (e.button !== 0 && e.button !== 2) return;
// Allow text selection in blog/info nodes
const nodeBody = e.target.closest('.node-body');
if (nodeBody) {
const blueprintNode = nodeBody.closest('.blueprint-node');
if (blueprintNode) {
const nodeType = blueprintNode.dataset.nodeType;
if (nodeType === 'info' || nodeType === 'blog_post') {
return; // Don't pan, allow text selection
}
}
}
this.#beginPan(e);
}

View File

@@ -100,6 +100,27 @@ export class BlogPostNode extends NodeBase {
const { MarkdownRenderer } = await import('../MarkdownRenderer.js');
contentDiv.innerHTML = MarkdownRenderer.render(post.content);
// Add copy button handlers
contentDiv.querySelectorAll('.md-copy-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const code = btn.getAttribute('data-code');
if (code) {
navigator.clipboard.writeText(code).then(() => {
const originalText = btn.textContent;
btn.textContent = '✓';
btn.classList.add('copied');
setTimeout(() => {
btn.textContent = originalText;
btn.classList.remove('copied');
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
});
});
} catch (error) {
body.innerHTML = `<p style="color: #ff6b6b;">Error loading blog post: ${error.message}</p>`;
console.error('Failed to load blog post:', error);

View File

@@ -40,3 +40,5 @@ export class PrintNode extends NodeBase {
}
NodeRegistry.UCLASS_Register(PrintNode);