diff --git a/prose/markdownParser.js b/prose/markdownParser.js index cbc9b5e..bd81590 100644 --- a/prose/markdownParser.js +++ b/prose/markdownParser.js @@ -1,57 +1,57 @@ import { MarkdownParser } from "prosemirror-markdown"; import markdownit from "markdown-it"; import { writeFreelySchema } from "./schema"; -export const writeAsMarkdownParser = new MarkdownParser( +export const writeFreelyMarkdownParser = new MarkdownParser( writeFreelySchema, markdownit("commonmark", { html: true }), { // blockquote: { block: "blockquote" }, paragraph: { block: "paragraph" }, list_item: { block: "list_item" }, bullet_list: { block: "bullet_list" }, ordered_list: { block: "ordered_list", getAttrs: (tok) => ({ order: +tok.attrGet("start") || 1 }), }, heading: { block: "heading", getAttrs: (tok) => ({ level: +tok.tag.slice(1) }), }, code_block: { block: "code_block", noCloseToken: true }, fence: { block: "code_block", getAttrs: (tok) => ({ params: tok.info || "" }), noCloseToken: true, }, // hr: { node: "horizontal_rule" }, image: { node: "image", getAttrs: (tok) => ({ src: tok.attrGet("src"), title: tok.attrGet("title") || null, alt: (tok.children !== null && typeof tok.children[0] !== 'undefined' ? tok.children[0].content : null), }), }, hardbreak: { node: "hard_break" }, em: { mark: "em" }, strong: { mark: "strong" }, link: { mark: "link", getAttrs: (tok) => ({ href: tok.attrGet("href"), title: tok.attrGet("title") || null, }), }, code_inline: { mark: "code", noCloseToken: true }, html_block: { node: "readmore", getAttrs(token) { // TODO: Give different attributes depending on the token content return {}; }, }, } ); diff --git a/prose/markdownSerializer.js b/prose/markdownSerializer.js index cadfcac..d14deab 100644 --- a/prose/markdownSerializer.js +++ b/prose/markdownSerializer.js @@ -1,124 +1,124 @@ import { MarkdownSerializer } from "prosemirror-markdown"; function backticksFor(node, side) { const ticks = /`+/g; let m; let len = 0; if (node.isText) while ((m = ticks.exec(node.text))) len = Math.max(len, m[0].length); let result = len > 0 && side > 0 ? " `" : "`"; for (let i = 0; i < len; i++) result += "`"; if (len > 0 && side < 0) result += " "; return result; } function isPlainURL(link, parent, index, side) { if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false; const content = parent.child(index + (side < 0 ? -1 : 0)); if ( !content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link ) return false; if (index == (side < 0 ? 1 : parent.childCount - 1)) return true; const next = parent.child(index + (side < 0 ? -2 : 1)); return !link.isInSet(next.marks); } -export const writeAsMarkdownSerializer = new MarkdownSerializer( +export const writeFreelyMarkdownSerializer = new MarkdownSerializer( { readmore(state, node) { state.write("\n"); state.closeBlock(node); }, // blockquote(state, node) { // state.wrapBlock("> ", undefined, node, () => state.renderContent(node)); // }, code_block(state, node) { state.write(`\`\`\`${node.attrs.params || ""}\n`); state.text(node.textContent, false); state.ensureNewLine(); state.write("```"); state.closeBlock(node); }, heading(state, node) { state.write(`${state.repeat("#", node.attrs.level)} `); state.renderInline(node); state.closeBlock(node); }, bullet_list(state, node) { node.attrs.tight = true; state.renderList(node, " ", () => `${node.attrs.bullet || "*"} `); }, ordered_list(state, node) { const start = node.attrs.order || 1; const maxW = String(start + node.childCount - 1).length; const space = state.repeat(" ", maxW + 2); state.renderList(node, space, (i) => { const nStr = String(start + i); return `${state.repeat(" ", maxW - nStr.length) + nStr}. `; }); }, list_item(state, node) { state.renderContent(node); }, paragraph(state, node) { state.renderInline(node); state.closeBlock(node); }, image(state, node) { state.write( `![${state.esc(node.attrs.alt || "")}](${state.esc(node.attrs.src)}${ node.attrs.title ? ` ${state.quote(node.attrs.title)}` : "" })` ); }, hard_break(state, node, parent, index) { for (let i = index + 1; i < parent.childCount; i += 1) if (parent.child(i).type !== node.type) { state.write("\\\n"); return; } }, text(state, node) { state.text(node.text || ""); }, }, { em: { open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true, }, strong: { open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true, }, link: { open(_state, mark, parent, index) { return isPlainURL(mark, parent, index, 1) ? "<" : "["; }, close(state, mark, parent, index) { return isPlainURL(mark, parent, index, -1) ? ">" : `](${state.esc(mark.attrs.href)}${ mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : "" })`; }, }, code: { open(_state, _mark, parent, index) { return backticksFor(parent.child(index), -1); }, close(_state, _mark, parent, index) { return backticksFor(parent.child(index - 1), 1); }, escape: false, }, } ); diff --git a/prose/prose.js b/prose/prose.js index 5a6b618..7c361a0 100644 --- a/prose/prose.js +++ b/prose/prose.js @@ -1,118 +1,118 @@ // class MarkdownView { // constructor(target, content) { // this.textarea = target.appendChild(document.createElement("textarea")) // this.textarea.value = content // } // get content() { return this.textarea.value } // focus() { this.textarea.focus() } // destroy() { this.textarea.remove() } // } import { EditorView } from "prosemirror-view"; import { EditorState, TextSelection } from "prosemirror-state"; import { exampleSetup } from "prosemirror-example-setup"; import { keymap } from "prosemirror-keymap"; -import { writeAsMarkdownParser } from "./markdownParser"; -import { writeAsMarkdownSerializer } from "./markdownSerializer"; +import { writeFreelyMarkdownParser } from "./markdownParser"; +import { writeFreelyMarkdownSerializer } from "./markdownSerializer"; import { writeFreelySchema } from "./schema"; import { getMenu } from "./menu"; let $title = document.querySelector("#title"); let $content = document.querySelector("#content"); // Bugs: // 1. When there's just an empty line and a hard break is inserted with shift-enter then two enters are inserted // which do not show up in the markdown ( maybe bc. they are training enters ) class ProseMirrorView { constructor(target, content) { let typingTimer; let localDraft = localStorage.getItem(window.draftKey); if (localDraft != null) { content = localDraft; } if (content.indexOf("# ") === 0) { let eol = content.indexOf("\n"); let title = content.substring("# ".length, eol); content = content.substring(eol + "\n\n".length); $title.value = title; } - const doc = writeAsMarkdownParser.parse( + const doc = writeFreelyMarkdownParser.parse( // Replace all "solo" \n's with \\\n for correct markdown parsing // Can't use lookahead or lookbehind because it's not supported on Safari content.replace(/([^]{0,1})(\n)([^]{0,1})/g, (match, p1, p2, p3) => { return p1 !== "\n" && p3 !== "\n" ? p1 + "\\\n" + p3 : match; }) ); this.view = new EditorView(target, { state: EditorState.create({ doc, plugins: [ keymap({ "Mod-Enter": () => { document.getElementById("publish").click(); return true; }, "Mod-k": () => { const linkButton = document.querySelector( ".ProseMirror-icon[title='Add or remove link']" ); linkButton.dispatchEvent(new Event("mousedown")); return true; }, }), ...exampleSetup({ schema: writeFreelySchema, menuContent: getMenu(), }), ], }), dispatchTransaction(transaction) { let newState = this.state.apply(transaction); - const newContent = writeAsMarkdownSerializer + const newContent = writeFreelyMarkdownSerializer .serialize(newState.doc) // Replace all \\\ns ( not followed by a \n ) with \n .replace(/(\\\n)(\n{0,1})/g, (match, p1, p2) => p2 !== "\n" ? "\n" + p2 : match ); $content.value = newContent; let draft = ""; if ($title.value != null && $title.value !== "") { draft = "# " + $title.value + "\n\n"; } draft += newContent; clearTimeout(typingTimer); typingTimer = setTimeout(doneTyping, doneTypingInterval); this.updateState(newState); }, }); // Editor is focused to the last position. This is a workaround for a bug: // 1. 1 type something in an existing entry // 2. reload - works fine, the draft is reloaded // 3. reload again - the draft is somehow removed from localStorage and the original content is loaded // When the editor is focused the content is re-saved to localStorage // This is also useful for editing, so it's not a bad thing even const lastPosition = this.view.state.doc.content.size; const selection = TextSelection.create(this.view.state.doc, lastPosition); this.view.dispatch(this.view.state.tr.setSelection(selection)); this.view.focus(); } get content() { return defaultMarkdownSerializer.serialize(this.view.state.doc); } focus() { this.view.focus(); } destroy() { this.view.destroy(); } } let place = document.querySelector("#editor"); let view = new ProseMirrorView(place, $content.value);