import { fromMarkdown } from "https://esm.sh/mdast-util-from-markdown@2.0.0"; import { toMarkdown as mdastUtilToMarkdown } from "https://esm.sh/mdast-util-to-markdown@2.1.2"; import Quill from "https://esm.sh/quill@2.0.3"; /** * @typedef {Object} QuillAttributes * @property {boolean} [bold] - Whether the text is bold. * @property {boolean} [italic] - Whether the text is italic. * @property {string} [link] - URL if the text is a link. * @property {number} [header] - Header level (1-3). * @property {string} [list] - List type ('ordered' or 'bullet'). * @property {boolean} [blockquote] - Whether the text is in a blockquote. * @property {string} [code-block] - Code language if in a code block. * @property {string} [alt] - Alt text for images. */ /** * @typedef {Object} QuillOperation * @property {string|Object} [insert] - Content to insert (string or object with image URL). * @property {number} [delete] - Number of characters to delete. * @property {number} [retain] - Number of characters to retain. * @property {QuillAttributes} [attributes] - Formatting attributes. */ /** * @typedef {Object} QuillDelta * @property {Array} ops - Array of operations in the delta. */ /** * Converts Quill Delta object to a Markdown string using mdast. * @param {QuillDelta} delta - Quill Delta object (https://quilljs.com/docs/delta/). * @returns {string} - Markdown representation. */ function deltaToMarkdown(delta) { const mdastTree = deltaToMdast(delta); const options = { bullet: "*", listItemIndent: "one", handlers: {}, unknownHandler: (node) => { console.warn(`Unknown node type encountered: ${node.type}`, node); return false; }, }; return mdastUtilToMarkdown(mdastTree, options); } /** * Creates a div to replace the textarea and prepares it for Quill. * @param {HTMLTextAreaElement} textarea - The original textarea. * @returns {HTMLDivElement} - The div element created for the Quill editor. */ function createAndReplaceTextarea(textarea) { const editorDiv = document.createElement("div"); editorDiv.className = "mb-3"; editorDiv.style.height = "250px"; const label = textarea.closest("label"); if (!label) { textarea.parentNode.insertBefore(editorDiv, textarea); } else { label.parentNode.insertBefore(editorDiv, label.nextSibling); } // Hide the original textarea, but keep it focusable for validation textarea.style = "transform: scale(0); position: absolute; opacity: 0;"; return editorDiv; } /** * Returns the toolbar options array configured for Markdown compatibility. * @returns {Array>} - Quill toolbar options. */ function getMarkdownToolbarOptions() { return [ [{ header: 1 }, { header: 2 }, { header: 3 }], ["bold", "italic", "code"], ["link", "image", "blockquote", "code-block"], [{ list: "ordered" }, { list: "bullet" }], ["clean"], ]; } /** * Initializes a Quill editor instance on a given div. * @param {HTMLDivElement} editorDiv - The div element for the editor. * @param {Array>} toolbarOptions - The toolbar configuration. * @param {string} initialValue - The initial content for the editor. * @returns {Quill} - The initialized Quill instance. */ function initializeQuillEditor( editorDiv, toolbarOptions, initialValue, readOnly, ) { const quill = new Quill(editorDiv, { theme: "snow", modules: { toolbar: toolbarOptions, }, readOnly: readOnly, formats: [ "bold", "italic", "link", "header", "list", "blockquote", "code", "code-block", "image", ], }); if (initialValue) { const delta = markdownToDelta(initialValue); quill.setContents(delta); } return quill; } /** * Converts Markdown string to a Quill Delta object. * @param {string} markdown - The markdown string to convert. * @returns {QuillDelta} - Quill Delta representation. */ function markdownToDelta(markdown) { try { const mdastTree = fromMarkdown(markdown); return mdastToDelta(mdastTree); } catch (error) { console.error("Error parsing markdown:", error); return { ops: [{ insert: markdown }] }; } } /** * Converts MDAST to Quill Delta. * @param {MdastNode} tree - The MDAST tree to convert. * @returns {QuillDelta} - Quill Delta representation. */ function mdastToDelta(tree) { const delta = { ops: [] }; if (!tree || !tree.children) return delta; for (const node of tree.children) { traverseMdastNode(node, delta); } return delta; } /** * Recursively traverse MDAST nodes and convert to Delta operations. * @param {MdastNode} node - The MDAST node to process. * @param {QuillDelta} delta - The Delta object to append operations to. * @param {QuillAttributes} [attributes={}] - The current attributes to apply. */ function traverseMdastNode(node, delta, attributes = {}) { if (!node) return; switch (node.type) { case "root": for (const child of node.children || []) { traverseMdastNode(child, delta); } break; case "paragraph": { for (const child of node.children || []) { traverseMdastNode(child, delta, attributes); } const pLineAttributes = {}; if (attributes.blockquote) { pLineAttributes.blockquote = true; } delta.ops.push({ insert: "\n", attributes: pLineAttributes }); break; } case "heading": { const headingContentAttributes = { ...attributes, header: node.depth }; for (const child of node.children || []) { traverseMdastNode(child, delta, headingContentAttributes); } const headingLineAttributes = { header: node.depth }; if (attributes.blockquote) { headingLineAttributes.blockquote = true; } delta.ops.push({ insert: "\n", attributes: headingLineAttributes }); break; } case "text": delta.ops.push({ insert: node.value || "", attributes }); break; case "strong": for (const child of node.children || []) { traverseMdastNode(child, delta, { ...attributes, bold: true }); } break; case "emphasis": for (const child of node.children || []) { traverseMdastNode(child, delta, { ...attributes, italic: true }); } break; case "link": for (const child of node.children || []) { traverseMdastNode(child, delta, { ...attributes, link: node.url }); } break; case "image": delta.ops.push({ insert: { image: node.url }, attributes: { alt: node.alt || "" }, }); break; case "list": for (const child of node.children || []) { traverseMdastNode(child, delta, { ...attributes, list: node.ordered ? "ordered" : "bullet", }); } break; case "listItem": { // biome-ignore lint/correctness/noUnusedVariables: object destructuring with a spread const { list, ...listItemChildrenAttributes } = attributes; for (const child of node.children || []) { traverseMdastNode(child, delta, listItemChildrenAttributes); } // Attributes for the listItem's newline (e.g., { list: 'bullet', blockquote: true }) // are in `attributes` passed to this `listItem` case. { const lastOp = delta.ops[delta.ops.length - 1]; if (lastOp && lastOp.insert === "\n") { lastOp.attributes = { ...lastOp.attributes, ...attributes }; } else { delta.ops.push({ insert: "\n", attributes }); } } break; } case "blockquote": for (const child of node.children || []) { traverseMdastNode(child, delta, { ...attributes, blockquote: true }); } break; case "code": { // mdast 'code' is a block const codeBlockLineFormat = { "code-block": node.lang || true }; if (attributes.blockquote) { codeBlockLineFormat.blockquote = true; } const textInCodeAttributes = {}; if (attributes.blockquote) { // Text lines also get blockquote if active textInCodeAttributes.blockquote = true; } const lines = (node.value || "").split("\n"); for (const lineText of lines) { delta.ops.push({ insert: lineText, attributes: textInCodeAttributes }); delta.ops.push({ insert: "\n", attributes: codeBlockLineFormat }); } break; } case "inlineCode": delta.ops.push({ insert: node.value || "", attributes: { ...attributes, code: true }, }); break; default: if (node.children) { for (const child of node.children) { traverseMdastNode(child, delta, attributes); } } else if (node.value) { delta.ops.push({ insert: node.value, attributes }); } } } /** * Attaches a submit event listener to the form to update the hidden textarea. * @param {HTMLFormElement|null} form - The form containing the editor. * @param {HTMLTextAreaElement} textarea - The original (hidden) textarea. * @param {Quill} quill - The Quill editor instance. * @returns {void} */ function updateTextareaOnSubmit(form, textarea, quill) { if (!form) { console.warn( "Textarea not inside a form, submission handling skipped for:", textarea.name || textarea.id, ); return; } form.addEventListener("submit", (event) => { const delta = quill.getContents(); const markdownContent = deltaToMarkdown(delta); textarea.value = markdownContent; console.log( `${textarea.name}:\n${markdownContent}\ntransformed from delta:\n${JSON.stringify(delta, null, 2)}`, ); if (textarea.required && !markdownContent) { textarea.setCustomValidity(`${textarea.name} cannot be empty`); quill.once("text-change", (delta) => { textarea.value = deltaToMarkdown(delta); textarea.setCustomValidity(""); }); quill.focus(); event.preventDefault(); } }); } /** * Loads the Quill CSS stylesheet. * @returns {void} */ function loadQuillStylesheet() { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "https://esm.sh/quill@2.0.3/dist/quill.snow.css"; document.head.appendChild(link); } /** * Handles errors during editor initialization. * @param {HTMLTextAreaElement} textarea - The textarea that failed initialization. * @param {Error} error - The error that occurred. * @returns {void} */ function handleEditorInitError(textarea, error) { console.error("Failed to initialize Quill for textarea:", textarea, error); textarea.style.display = ""; const errorMsg = document.createElement("p"); errorMsg.textContent = "Failed to load rich text editor."; errorMsg.style.color = "red"; textarea.parentNode.insertBefore(errorMsg, textarea.nextSibling); } /** * Sets up a single editor for a textarea. * @param {HTMLTextAreaElement} textarea - The textarea to replace with an editor. * @param {Array>} toolbarOptions - The toolbar configuration. * @returns {boolean} - Whether the setup was successful. */ function setupSingleEditor(textarea, toolbarOptions) { if (textarea.dataset.quillInitialized === "true") { return false; } try { const initialValue = textarea.value; const readOnly = textarea.readOnly || textarea.disabled; const form = textarea.closest("form"); const editorDiv = createAndReplaceTextarea(textarea); const quill = initializeQuillEditor( editorDiv, toolbarOptions, initialValue, readOnly, ); updateTextareaOnSubmit(form, textarea, quill); textarea.dataset.quillInitialized = "true"; return true; } catch (error) { handleEditorInitError(textarea, error); return false; } } /** * Initializes Quill editors for all textareas in the document. * @returns {void} */ function initializeEditors() { loadQuillStylesheet(); const textareas = document.getElementsByTagName("textarea"); if (textareas.length === 0) { return; } const toolbarOptions = getMarkdownToolbarOptions(); let initializedCount = 0; for (const textarea of textareas) { if (setupSingleEditor(textarea, toolbarOptions)) { initializedCount++; } } if (initializedCount > 0) { console.log( `Successfully initialized Quill for ${initializedCount} textareas.`, ); } } // MDAST conversion functions /** * @typedef {Object} MdastNode * @property {string} type - The type of the node. * @property {Array} [children] - Child nodes. * @property {string} [value] - Text value for text nodes. * @property {string} [url] - URL for link and image nodes. * @property {string} [title] - Title for image nodes. * @property {string} [alt] - Alt text for image nodes. * @property {number} [depth] - Depth for heading nodes. * @property {boolean} [ordered] - Whether the list is ordered. * @property {boolean} [spread] - Whether the list is spread. * @property {string} [lang] - Language for code blocks. */ /** * Converts a Quill Delta to a MDAST (Markdown Abstract Syntax Tree). * @param {QuillDelta} delta - The Quill Delta to convert. * @returns {MdastNode} - The root MDAST node. */ function deltaToMdast(delta) { const mdast = createRootNode(); /** @type {MdastNode|null} */ let currentParagraph = null; /** @type {MdastNode|null} */ let currentList = null; let textBuffer = ""; for (const op of delta.ops) { if (isImageInsert(op)) { if (!currentParagraph) { currentParagraph = createParagraphNode(); } currentParagraph.children.push(createImageNode(op)); } if (typeof op.insert !== "string") continue; const text = op.insert; const attributes = op.attributes || {}; // Handle newlines within text content if (text.includes("\n") && text !== "\n") { const lines = text.split("\n"); // Process all lines except the last one as complete lines for (let i = 0; i < lines.length - 1; i++) { const line = lines[i]; if (line.length > 0) { // Add text to current paragraph if (!currentParagraph) { currentParagraph = createParagraphNode(); } const nodes = createTextNodes(line, attributes); currentParagraph.children.push(...nodes); textBuffer = line; } // Process line break currentList = processLineBreak( mdast, currentParagraph, attributes, textBuffer, currentList, ); currentParagraph = null; textBuffer = ""; } // Add the last line to the buffer without processing the line break yet const lastLine = lines[lines.length - 1]; if (lastLine.length > 0) { if (!currentParagraph) { currentParagraph = createParagraphNode(); } const nodes = createTextNodes(lastLine, attributes); currentParagraph.children.push(...nodes); textBuffer = lastLine; } continue; } if (text === "\n") { currentList = processLineBreak( mdast, currentParagraph, attributes, textBuffer, currentList, ); // Reset paragraph and buffer after processing line break currentParagraph = null; textBuffer = ""; continue; } // Process regular text const nodes = createTextNodes(text, attributes); if (!currentParagraph) { currentParagraph = createParagraphNode(); } textBuffer += text; currentParagraph.children.push(...nodes); } if (currentParagraph) { mdast.children.push(currentParagraph); } return mdast; } /** * Creates a root MDAST node. * @returns {MdastNode} - The root node. */ function createRootNode() { return { type: "root", children: [], }; } /** * Creates a paragraph MDAST node. * @returns {MdastNode} - The paragraph node. */ function createParagraphNode() { return { type: "paragraph", children: [], }; } /** * Checks if an operation is an image insertion. * @param {Object} op - The operation to check. * @returns {boolean} - Whether the operation is an image insertion. */ function isImageInsert(op) { return typeof op.insert === "object" && op.insert.image; } /** * Creates an image MDAST node. * @param {Object} op - The operation containing the image. * @returns {MdastNode} - The image node. */ function createImageNode(op) { return { type: "image", url: op.insert.image, title: op.attributes?.alt || "", alt: op.attributes?.alt || "", }; } /** * Creates a text MDAST node with optional formatting. * @param {string} text - The text content. * @param {Object} attributes - The formatting attributes. * @returns {MdastNode[]} - The formatted text nodes. */ function createTextNodes(text, attributes) { let nodes = text.split("\n").flatMap((value, i) => [ ...(i > 0 ? [{ type: "break" }] : []), { type: "text", value, }, ]); if (attributes.bold) { nodes = [wrapNodesWith(nodes, "strong")]; } if (attributes.italic) { nodes = [wrapNodesWith(nodes, "emphasis")]; } if (attributes.link) { nodes = [{ ...wrapNodesWith(nodes, "link"), url: attributes.link }]; } return nodes; } /** * Wraps a node with a formatting container. * @param {MdastNode[]} children - The node to wrap. * @param {string} type - The type of container. * @returns {MdastNode} - The wrapped node. */ function wrapNodesWith(children, type) { return { type: type, children, }; } /** * Processes a line break in the Delta. * @param {MdastNode} mdast - The root MDAST node. * @param {MdastNode|null} currentParagraph - The current paragraph being built. * @param {Object} attributes - The attributes for the line. * @param {string} textBuffer - The text buffer for the current line. * @param {MdastNode|null} currentList - The current list being built. * @returns {MdastNode|null} - The updated current list. */ function processLineBreak( mdast, currentParagraph, attributes, textBuffer, currentList, ) { if (!currentParagraph) { return handleEmptyLineWithAttributes(mdast, attributes, currentList); } if (attributes.header) { processHeaderLineBreak(mdast, textBuffer, attributes); return null; } if (attributes["code-block"]) { processCodeBlockLineBreak(mdast, textBuffer, attributes); return currentList; } if (attributes.list) { return processListLineBreak( mdast, currentParagraph, attributes, currentList, ); } if (attributes.blockquote) { processBlockquoteLineBreak(mdast, currentParagraph); return currentList; } // Default case: regular paragraph mdast.children.push(currentParagraph); return null; } /** * Handles an empty line with special attributes. * @param {MdastNode} mdast - The root MDAST node. * @param {Object} attributes - The attributes for the line. * @param {MdastNode|null} currentList - The current list being built. * @returns {MdastNode|null} - The updated current list. */ function handleEmptyLineWithAttributes(mdast, attributes, currentList) { if (attributes["code-block"]) { mdast.children.push(createEmptyCodeBlock(attributes)); return currentList; } if (attributes.list) { const list = ensureList(mdast, attributes, currentList); list.children.push(createEmptyListItem()); return list; } if (attributes.blockquote) { mdast.children.push(createEmptyBlockquote()); return currentList; } return null; } /** * Creates an empty code block MDAST node. * @param {Object} attributes - The attributes for the code block. * @returns {MdastNode} - The code block node. */ function createEmptyCodeBlock(attributes) { return { type: "code", value: "", lang: attributes["code-block"] === "plain" ? null : attributes["code-block"], }; } /** * Creates an empty list item MDAST node. * @returns {MdastNode} - The list item node. */ function createEmptyListItem() { return { type: "listItem", spread: false, children: [{ type: "paragraph", children: [] }], }; } /** * Creates an empty blockquote MDAST node. * @returns {MdastNode} - The blockquote node. */ function createEmptyBlockquote() { return { type: "blockquote", children: [{ type: "paragraph", children: [] }], }; } /** * Processes a header line break. * @param {MdastNode} mdast - The root MDAST node. * @param {string} textBuffer - The text buffer for the current line. * @param {Object} attributes - The attributes for the line. * @returns {void} */ function processHeaderLineBreak(mdast, textBuffer, attributes) { const lines = textBuffer.split("\n"); if (lines.length > 1) { const lastLine = lines.pop(); const previousLines = lines.join("\n"); if (previousLines) { mdast.children.push({ type: "paragraph", children: [{ type: "text", value: previousLines }], }); } mdast.children.push({ type: "heading", depth: attributes.header, children: [{ type: "text", value: lastLine }], }); } else { mdast.children.push({ type: "heading", depth: attributes.header, children: [{ type: "text", value: textBuffer }], }); } } /** * Processes a code block line break. * @param {MdastNode} mdast - The root MDAST node. * @param {string} textBuffer - The text buffer for the current line. * @param {Object} attributes - The attributes for the line. * @returns {void} */ function processCodeBlockLineBreak(mdast, textBuffer, attributes) { const lang = attributes["code-block"] === "plain" ? null : attributes["code-block"]; // Find the last code block with the same language let lastCodeBlock = null; for (let i = mdast.children.length - 1; i >= 0; i--) { const child = mdast.children[i]; if (child.type === "code" && child.lang === lang) { lastCodeBlock = child; break; } } if (lastCodeBlock) { // Append to existing code block with same language lastCodeBlock.value += `\n${textBuffer}`; } else { // Create new code block mdast.children.push({ type: "code", value: textBuffer, lang, }); } } /** * Ensures a list exists in the MDAST. * @param {MdastNode} mdast - The root MDAST node. * @param {Object} attributes - The attributes for the line. * @param {MdastNode|null} currentList - The current list being built. * @returns {MdastNode} - The list node. */ function ensureList(mdast, attributes, currentList) { const isOrderedList = attributes.list === "ordered"; // If there's no current list or the list type doesn't match if (!currentList || currentList.ordered !== isOrderedList) { // Check if the last child is a list of the correct type const lastChild = mdast.children[mdast.children.length - 1]; if ( lastChild && lastChild.type === "list" && lastChild.ordered === isOrderedList ) { // Use the last list if it matches the type return lastChild; } // Create a new list const newList = { type: "list", ordered: isOrderedList, spread: false, children: [], }; mdast.children.push(newList); return newList; } return currentList; } /** * Processes a list line break. * @param {MdastNode} mdast - The root MDAST node. * @param {MdastNode} currentParagraph - The current paragraph being built. * @param {Object} attributes - The attributes for the line. * @param {MdastNode|null} currentList - The current list being built. * @returns {MdastNode} - The updated list node. */ function processListLineBreak( mdast, currentParagraph, attributes, currentList, ) { const list = ensureList(mdast, attributes, currentList); // Check if this list item already exists to avoid duplication const paragraphContent = JSON.stringify(currentParagraph.children); const isDuplicate = list.children.some( (item) => item.children?.length === 1 && JSON.stringify(item.children[0].children) === paragraphContent, ); if (!isDuplicate) { const listItem = { type: "listItem", spread: false, children: [currentParagraph], }; list.children.push(listItem); } return list; } /** * Processes a blockquote line break. * @param {MdastNode} mdast - The root MDAST node. * @param {MdastNode} currentParagraph - The current paragraph being built. * @returns {void} */ function processBlockquoteLineBreak(mdast, currentParagraph) { // Look for an existing blockquote with identical content to avoid duplication const paragraphContent = JSON.stringify(currentParagraph.children); const existingBlockquote = mdast.children.find( (child) => child.type === "blockquote" && child.children?.length === 1 && JSON.stringify(child.children[0].children) === paragraphContent, ); if (!existingBlockquote) { mdast.children.push({ type: "blockquote", children: [currentParagraph], }); } } // Main execution document.addEventListener("DOMContentLoaded", initializeEditors);