mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 10:46:03 +10:00
blog post editor
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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  — 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -40,3 +40,5 @@ export class PrintNode extends NodeBase {
|
||||
}
|
||||
|
||||
NodeRegistry.UCLASS_Register(PrintNode);
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user