From e2414094f66c4b5c4044c386f762cefa27137528 Mon Sep 17 00:00:00 2001 From: linonetwo Date: Mon, 24 Mar 2025 00:38:03 +0800 Subject: [PATCH] feat: example ast round trip --- .../prosemirror/ast/from-prosemirror.js | 118 ++++++--- .../prosemirror/ast/to-prosemirror.js | 226 +++++++++++++----- plugins/tiddlywiki/prosemirror/widget.js | 27 ++- 3 files changed, 269 insertions(+), 102 deletions(-) diff --git a/plugins/tiddlywiki/prosemirror/ast/from-prosemirror.js b/plugins/tiddlywiki/prosemirror/ast/from-prosemirror.js index 00bd23701..a552340f9 100644 --- a/plugins/tiddlywiki/prosemirror/ast/from-prosemirror.js +++ b/plugins/tiddlywiki/prosemirror/ast/from-prosemirror.js @@ -1,40 +1,89 @@ /*\ -title: $:/plugins/tiddlywiki/prosemirror/ast/wikiAstFromProsemirrorAst.js +title: $:/plugins/tiddlywiki/prosemirror/ast/from-prosemirror.js type: application/javascript module-type: library -Get the Prosemirror AST from a Wiki AST +Get the Wiki AST from a Prosemirror AST \*/ +function doc(context, node) { + return convertNodes(context, node.content); +} + +function paragraph(context, node) { + return { + type: "element", + tag: "p", + rule: "parseblock", + children: convertNodes(context, node.content) + }; +} + +function text(context, node) { + return { + type: "text", + text: node.text + } +} + +function heading(context, node) { + return { + type: "element", + tag: "h" + node.attrs.level, + rule: "heading", + attributes: { + // TODO: restore class if any + }, + children: convertNodes(context, node.content) + }; +} + +function bullet_list(context, node) { + return { + type: "element", + tag: "ul", + rule: "list", + children: convertNodes(context, node.content) + }; +} + +function ordered_list(context, node) { + return { + type: "element", + tag: "ol", + rule: "list", + children: convertNodes(context, node.content) + }; +} + +function list_item(context, node) { + return { + type: "element", + tag: "li", + rule: "list", + children: convertNodes(context, node.content) + }; +} /** * Key is `node.type`, value is node converter function. */ const builders = { - // auto parse basic element nodes - // eslint-disable-next-line unicorn/prefer-object-from-entries - ...(htmlTags).reduce( - (previousValue, currentValue) => { - previousValue[currentValue] = element; - return previousValue; - }, - // eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions - {}, - ), - [ELEMENT_CODE_BLOCK]: codeblock, - [ELEMENT_LIC]: lic, + doc, + paragraph, text, - widget, - macro: widget, - set, + heading, + bullet_list, + ordered_list, + list_item, }; -function wikiAstFromProsemirrorAst(input) { +function wikiAstFromProseMirrorAst(input) { return convertNodes(builders, Array.isArray(input) ? input : [input]); } -exports.wikiAstFromProsemirrorAst = wikiAstFromProsemirrorAst; +exports.from = wikiAstFromProseMirrorAst; function convertNodes(builders, nodes) { if (nodes === undefined || nodes.length === 0) { @@ -42,27 +91,22 @@ function convertNodes(builders, nodes) { } return nodes.reduce((accumulator, node) => { - return [...accumulator, ...convertWikiAstNode(builders, node)]; + return [...accumulator, ...convertANode(builders, node)]; }, []); } -function convertWikiAstNode(builders, node) { - // only text and root node don't have a `type` field, deal with it first - if (isText(node)) { - return [builders.text(builders, node)]; - } - if (isElement(node)) { - const builder = builders[node.type]; - if (typeof builder === 'function') { - const builtSlateNodeOrNodes = builder(builders, node); - return Array.isArray(builtSlateNodeOrNodes) - ? builtSlateNodeOrNodes.map((child) => ({ ...getSlatePlateASTAdditionalProperties(node), ...child })) - : ([{ ...getSlatePlateASTAdditionalProperties(node), ...builtSlateNodeOrNodes }]); - } - } - // it might be a root or pure parent node, reduce it - if ('children' in node) { - return convertNodes(builders, node.children); +function restoreMetadata(node) { + // TODO: restore attributes, orderedAttributes, isBlock + return {}; +} +function convertANode(builders, node) { + var builder = builders[node.type]; + if (typeof builder === 'function') { + var convertedNode = builder(builders, node); + var arrayOfNodes = (Array.isArray(convertedNode) + ? convertedNode : [convertedNode]); + return arrayOfNodes.map((child) => ({ ...restoreMetadata(node), ...child })); } + console.warn(`WikiAst get Unknown node type: ${JSON.stringify(node)}`); return []; } diff --git a/plugins/tiddlywiki/prosemirror/ast/to-prosemirror.js b/plugins/tiddlywiki/prosemirror/ast/to-prosemirror.js index 3d5b9c0e3..21263c3f7 100644 --- a/plugins/tiddlywiki/prosemirror/ast/to-prosemirror.js +++ b/plugins/tiddlywiki/prosemirror/ast/to-prosemirror.js @@ -1,83 +1,189 @@ /*\ -title: $:/plugins/tiddlywiki/prosemirror/ast/wikiAstToProsemirrorAst.js +title: $:/plugins/tiddlywiki/prosemirror/ast/to-prosemirror.js type: application/javascript module-type: library Get the Prosemirror AST from a Wiki AST \*/ -function wikiAstToProsemirrorAst(node, options) { - return convertNodes({ ...initialContext, ...options }, Array.isArray(node) ? node : [node]); + +/** + * Many node shares same type `element` in wikiAst, we need to distinguish them by tag. + */ +const elementBuilders = { + p: function(context, node) { + return { + type: "paragraph", + content: convertNodes(context, node.children) + }; + }, + h1: function(context, node) { + return { + type: "heading", + attrs: { level: 1 }, + content: convertNodes(context, node.children) + }; + }, + h2: function(context, node) { + return { + type: "heading", + attrs: { level: 2 }, + content: convertNodes(context, node.children) + }; + }, + h3: function(context, node) { + return { + type: "heading", + attrs: { level: 3 }, + content: convertNodes(context, node.children) + }; + }, + h4: function(context, node) { + return { + type: "heading", + attrs: { level: 4 }, + content: convertNodes(context, node.children) + }; + }, + h5: function(context, node) { + return { + type: "heading", + attrs: { level: 5 }, + content: convertNodes(context, node.children) + }; + }, + h6: function(context, node) { + return { + type: "heading", + attrs: { level: 6 }, + content: convertNodes(context, node.children) + }; + }, + ul: function(context, node) { + return { + type: "bullet_list", + content: convertNodes(context, node.children) + }; + }, + ol: function(context, node) { + return { + type: "ordered_list", + content: convertNodes(context, node.children) + }; + }, + li: function(context, node) { + // In ProseMirror, list items must contain block content (not bare text) + // TODO: find solution to https://discuss.prosemirror.net/t/removing-the-default-paragraph-p-inside-a-list-item-li/2745/17 + const processedContent = convertNodes(context, node.children); + const wrappedContent = wrapTextNodesInParagraphs(context, processedContent); + + return { + type: "list_item", + content: wrappedContent + }; + } +}; + +/** + * Helper function to ensure text nodes in list items are wrapped in paragraphs + * ProseMirror requires list items to contain block content, not bare text + */ +function wrapTextNodesInParagraphs(context, nodes) { + if (!nodes || nodes.length === 0) { + return []; + } + + const result = []; + let currentTextNodes = []; + + function flushTextNodes() { + if (currentTextNodes.length > 0) { + result.push({ + type: "paragraph", + content: currentTextNodes + }); + currentTextNodes = []; + } + } + + nodes.forEach(node => { + // If it's a text node, collect it + if (node.type === "text") { + currentTextNodes.push(node); + } else { + // If we encounter a non-text node, flush any collected text nodes + flushTextNodes(); + // Add the non-text node as is + result.push(node); + } + }); + + // Flush any remaining text nodes + flushTextNodes(); + + return result; } -exports.wikiAstToProsemirrorAst = wikiAstToProsemirrorAst; +function element(context, node) { + const builder = elementBuilders[node.tag]; + if (builder) { + return builder(context, node); + } else { + console.warn(`Unknown element tag: ${node.tag}`); + return []; + } +} -const initialContext = { - builders, - marks: {}, +function text(context, node) { + return { + type: "text", + text: node.text + }; +} + +/** + * Key is wikiAst node type, value is node converter function. + */ +const builders = { + element, + text }; +function wikiAstToProsemirrorAst(node, options) { + const context = { ...builders, ...options }; + const result = convertNodes(context, Array.isArray(node) ? node : [node]); + + // Wrap in a doc if needed + if (result.length > 0 && result[0].type !== "doc") { + return { + type: "doc", + content: result + }; + } + + return result; +} + +exports.to = wikiAstToProsemirrorAst; + function convertNodes(context, nodes) { if (nodes === undefined || nodes.length === 0) { - return [{ text: '' }]; + return []; } return nodes.reduce((accumulator, node) => { - return [...accumulator, ...prosemirrorNode(context, node)]; + return [...accumulator, ...convertANode(context, node)]; }, []); } -function prosemirrorNode(context, node) { - const id = context.idCreator?.(); - const withId = (nodeToAddId) => (id === undefined ? nodeToAddId : { ...nodeToAddId, id }); - if ('rule' in node && node.rule !== undefined && node.rule in context.builders) { - const builder = context.builders[node.rule]; - if (typeof builder === 'function') { - // basic elements - const builtProsemirrorNodeOrNodes = builder(context, node); - return Array.isArray(builtProsemirrorNodeOrNodes) - ? builtProsemirrorNodeOrNodes.map((child) => withId(child)) - : ([withId(builtProsemirrorNodeOrNodes)]); - } - } else if ('text' in node) { - // text node - return [withId({ text: node.text })]; - } else { - console.warn(`WikiAst get Unknown node type: ${JSON.stringify(node)}`); - return []; +function convertANode(context, node) { + var builder = context[node.type]; + if (typeof builder === 'function') { + var convertedNode = builder(context, node); + var arrayOfNodes = (Array.isArray(convertedNode) + ? convertedNode : [convertedNode]); + return arrayOfNodes; } + console.warn(`ProseMirror get Unknown node type: ${JSON.stringify(node)}`); return []; } - -const builders = { - element, - text, -}; - -/** Slate node is compact, we need to filter out some keys from wikiast */ -const textLevelKeysToOmit = ['type', 'start', 'end']; - -function text(context, text) { - return { - text: '', // provides default text - ...omit(text, textLevelKeysToOmit), - ...context.marks, - }; -} - -const elementBuilders = { ul, ol: ul, li, ...marks }; - -function element(context, node) { - const { tag, children } = node; - if (typeof elementBuilders[tag] === 'function') { - return elementBuilders[tag](context, node); - } - const result = { - type: tag, - children: convertNodes(context, children), - }; - if (node.rule) { - result.rule = node.rule; - } - return result; -} diff --git a/plugins/tiddlywiki/prosemirror/widget.js b/plugins/tiddlywiki/prosemirror/widget.js index fe1924cd9..8279baa47 100644 --- a/plugins/tiddlywiki/prosemirror/widget.js +++ b/plugins/tiddlywiki/prosemirror/widget.js @@ -9,6 +9,8 @@ module-type: library var Widget = require("$:/core/modules/widgets/widget.js").widget; var debounce = require("$:/core/modules/utils/debounce.js").debounce; +var wikiAstFromProseMirrorAst = require("$:/plugins/tiddlywiki/prosemirror/ast/from-prosemirror.js").from; +var wikiAstToProseMirrorAst = require("$:/plugins/tiddlywiki/prosemirror/ast/to-prosemirror.js").to; var { EditorState } = require("prosemirror-state"); var { EditorView } = require("prosemirror-view"); @@ -39,15 +41,26 @@ ProsemirrorWidget.prototype.render = function(parent,nextSibling) { // Mix the nodes from prosemirror-schema-list into the basic schema to // create a schema with list support. - const mySchema = new Schema({ - nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"), + var mySchema = new Schema({ + // nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"), + nodes: addListNodes(schema.spec.nodes, "block*", "block"), marks: schema.spec.marks }) var self = this; + var wikiAst = $tw.wiki.parseText(null, `* This is an unordered list +* It has two items + +# This is a numbered list +## With a subitem +# And a third item`).tree; + var doc = wikiAstToProseMirrorAst(wikiAst); + // DEBUG: console doc + console.log(`initial doc`, doc); this.view = new EditorView(container, { state: EditorState.create({ - doc: mySchema.node("doc", null, [mySchema.node("paragraph")]), + // doc: mySchema.node("doc", null, [mySchema.node("paragraph")]), + doc: mySchema.nodeFromJSON(doc), plugins: exampleSetup({schema: mySchema}) }), dispatchTransaction: function(transaction) { @@ -62,8 +75,12 @@ ProsemirrorWidget.prototype.render = function(parent,nextSibling) { }; ProsemirrorWidget.prototype.saveEditorContent = function() { - const content = this.view.state.doc.toJSON(); - console.log(JSON.stringify(content)); + var content = this.view.state.doc.toJSON(); + console.log(`ProseMirror: ${JSON.stringify(content)}`); + var wikiast = wikiAstFromProseMirrorAst(content); + console.log(`WikiAST: ${JSON.stringify(wikiast)}`); + var wikiText = $tw.utils.serializeParseTree(wikiast); + console.log(`WikiText: ${wikiText}`); } // Debounced save function for performance