diff --git a/less/prose-editor.less b/less/prose-editor.less index 6a43c29..fc8e8ba 100644 --- a/less/prose-editor.less +++ b/less/prose-editor.less @@ -1,451 +1,452 @@ body#pad.classic { header { display: flex; justify-content: space-between; align-items: center; } #editor { top: 4em; } #title { top: 4.25rem; bottom: unset; height: auto; font-weight: bold; font-size: 2em; padding-top: 0; padding-bottom: 0; border: 0; } #tools { #belt { float: none; } } #target { ul { a { padding: 0 0.5em !important; } } } } .ProseMirror { position: relative; height: calc(~"100% - 1.6em"); overflow-y: auto; box-sizing: border-box; -moz-box-sizing: border-box; font-size: 1.2em; word-wrap: break-word; white-space: pre-wrap; -webkit-font-variant-ligatures: none; font-variant-ligatures: none; padding: 0.5em 0; line-height: 1.5; outline: none; } .ProseMirror pre { white-space: pre-wrap; } .ProseMirror li { position: relative; } .ProseMirror-hideselection *::selection { background: transparent; } .ProseMirror-hideselection *::-moz-selection { background: transparent; } .ProseMirror-hideselection { caret-color: transparent; } .ProseMirror-selectednode { outline: 2px solid #8cf; } /* Make sure li selections wrap around markers */ li.ProseMirror-selectednode { outline: none; } li.ProseMirror-selectednode:after { content: ""; position: absolute; left: -32px; right: -2px; top: -2px; bottom: -2px; border: 2px solid #8cf; pointer-events: none; } .ProseMirror-textblock-dropdown { min-width: 3em; } .ProseMirror-menu { margin: 0 -4px; line-height: 1; } .ProseMirror-tooltip .ProseMirror-menu { width: -webkit-fit-content; width: fit-content; white-space: pre; } .ProseMirror-menuitem { margin-right: 3px; display: inline-block; div { cursor: pointer; } } .ProseMirror-menuseparator { border-right: 1px solid #ddd; margin-right: 3px; } .ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu { font-size: 90%; white-space: nowrap; } .ProseMirror-menu-dropdown { vertical-align: 1px; cursor: pointer; position: relative; padding-right: 15px; } .ProseMirror-menu-dropdown-wrap { padding: 1px 0 1px 4px; display: inline-block; position: relative; } .ProseMirror-menu-dropdown:after { content: ""; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 4px solid currentColor; opacity: .6; position: absolute; right: 4px; top: calc(50% - 2px); } .ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { position: absolute; background: white; color: #666; border: 1px solid #aaa; padding: 2px; } .ProseMirror-menu-dropdown-menu { z-index: 15; min-width: 6em; } .ProseMirror-menu-dropdown-item { cursor: pointer; padding: 2px 8px 2px 4px; } .ProseMirror-menu-dropdown-item:hover { background: #f2f2f2; } .ProseMirror-menu-submenu-wrap { position: relative; margin-right: -4px; } .ProseMirror-menu-submenu-label:after { content: ""; border-top: 4px solid transparent; border-bottom: 4px solid transparent; border-left: 4px solid currentColor; opacity: .6; position: absolute; right: 4px; top: calc(50% - 4px); } .ProseMirror-menu-submenu { display: none; min-width: 4em; left: 100%; top: -3px; } .ProseMirror-menu-active { background: #eee; border-radius: 4px; } .ProseMirror-menu-active { background: #eee; border-radius: 4px; } .ProseMirror-menu-disabled { opacity: .3; } .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { display: block; } .ProseMirror-menubar { position: relative; min-height: 1em; color: #666; padding: 0.5em; top: 0; left: 0; right: 0; background: rgba(255, 255, 255, 0.8); z-index: 10; -moz-box-sizing: border-box; box-sizing: border-box; overflow: visible; } .ProseMirror-icon { display: inline-block; line-height: .8; vertical-align: -2px; /* Compensate for padding */ padding: 2px 8px; cursor: pointer; } .ProseMirror-menu-disabled.ProseMirror-icon { cursor: default; } .ProseMirror-icon svg { fill: currentColor; height: 1em; } .ProseMirror-icon span { vertical-align: text-top; } .ProseMirror-gapcursor { display: none; pointer-events: none; position: absolute; } .ProseMirror-gapcursor:after { content: ""; display: block; position: absolute; top: -2px; width: 20px; border-top: 1px solid black; animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; } @keyframes ProseMirror-cursor-blink { to { visibility: hidden; } } .ProseMirror-focused .ProseMirror-gapcursor { display: block; } /* Add space around the hr to make clicking it easier */ .ProseMirror-example-setup-style hr { - padding: 2px 10px; + padding: 4px 10px; border: none; margin: 1em 0; + background: initial; } .ProseMirror-example-setup-style hr:after { content: ""; display: block; height: 1px; - background-color: silver; + background-color: #ccc; line-height: 2px; } .ProseMirror ul, .ProseMirror ol { padding-left: 30px; } .ProseMirror blockquote { padding-left: 1em; border-left: 3px solid #eee; margin-left: 0; margin-right: 0; } .ProseMirror-example-setup-style img { cursor: default; max-width: 100%; } .ProseMirror-prompt { background: white; padding: 1em; border: 1px solid silver; position: fixed; border-radius: 0.25em; z-index: 11; box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); } .ProseMirror-prompt h5 { margin: 0 0 0.75em; font-family: @sansFont; font-size: 100%; color: #444; } .ProseMirror-prompt input[type="text"], .ProseMirror-prompt textarea { background: #eee; border: none; outline: none; } .ProseMirror-prompt input[type="text"] { margin: 0.25em 0; } .ProseMirror-prompt-close { position: absolute; left: 2px; top: 1px; color: #666; border: none; background: transparent; padding: 0; } .ProseMirror-prompt-close:after { content: "✕"; font-size: 12px; } .ProseMirror-invalid { background: #ffc; border: 1px solid #cc7; border-radius: 4px; padding: 5px 10px; position: absolute; min-width: 10em; } .ProseMirror-prompt-buttons { margin-top: 5px; display: none; } #editor, .editor { position: fixed; top: 0; right: 0; bottom: 0; left: 0; color: black; background-clip: padding-box; padding: 5px 0; margin: 4em auto 23px auto; } .ProseMirror p:first-child, .ProseMirror h1:first-child, .ProseMirror h2:first-child, .ProseMirror h3:first-child, .ProseMirror h4:first-child, .ProseMirror h5:first-child, .ProseMirror h6:first-child { margin-top: 10px; } .ProseMirror p { margin-bottom: 1em; } textarea { width: 100%; height: 123px; border: 1px solid silver; box-sizing: border-box; -moz-box-sizing: border-box; padding: 3px 10px; border: none; outline: none; font-family: inherit; font-size: inherit; } .ProseMirror-menubar-wrapper { height: 100%; box-sizing: border-box; } .ProseMirror-menubar-wrapper, #markdown textarea { display: block; margin-bottom: 4px; } .editorreadmore { color: @textLinkColor; text-decoration: underline; text-align: center; width: 100%; } @media all and (min-width: 50em) { #editor { margin-left: 10%; margin-right: 10%; } } @media all and (min-width: 60em) { #editor { margin-left: 15%; margin-right: 15%; } } @media all and (min-width: 70em) { #editor { margin-left: 20%; margin-right: 20%; } } @media all and (min-width: 85em) { #editor { margin-left: 25%; margin-right: 25%; } } @media all and (min-width: 105em) { #editor { margin-left: 30%; margin-right: 30%; } } diff --git a/prose/markdownParser.js b/prose/markdownParser.js index bd81590..1ee5a07 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 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" }, + 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 d14deab..9615633 100644 --- a/prose/markdownSerializer.js +++ b/prose/markdownSerializer.js @@ -1,124 +1,128 @@ 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 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); }, + horizontal_rule: function horizontal_rule(state, node) { + state.write(node.attrs.markup || "---"); + 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 7c361a0..3096c9e 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 { 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 = 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 = 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); + return writeFreelyMarkdownSerializer.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); diff --git a/prose/schema.js b/prose/schema.js index 67302df..0a87662 100644 --- a/prose/schema.js +++ b/prose/schema.js @@ -1,21 +1,20 @@ import { schema } from "prosemirror-markdown"; import { Schema } from "prosemirror-model"; export const writeFreelySchema = new Schema({ nodes: schema.spec.nodes .remove("blockquote") - .remove("horizontal_rule") .addToEnd("readmore", { inline: false, content: "", group: "block", draggable: true, toDOM: (node) => [ "div", { class: "editorreadmore" }, "Read more...", ], parseDOM: [{ tag: "div.editorreadmore" }], }), marks: schema.spec.marks, });