diff --git a/less/prose-editor.less b/less/prose-editor.less index 7f62df7..b774de4 100644 --- a/less/prose-editor.less +++ b/less/prose-editor.less @@ -1,507 +1,507 @@ @classicHorizMargin: 2rem; body#pad.classic { header { display: flex; justify-content: space-between; align-items: center; } #editor { top: 4em; bottom: 1em; } #title { top: 4.25rem; bottom: unset; height: auto; font-weight: bold; font-size: 2em; padding: 0; border: 0; } #tools { #belt { float: none; } } #target { ul { a { padding: 0 0.5em !important; } } } } .norm { font-family: @serifFont; } .sans { font-family: @sansFont; } .wrap { font-family: @monoFont; } #title { margin-left: @classicHorizMargin; margin-right: @classicHorizMargin; max-width: 42rem; } .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 @classicHorizMargin; 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 { font-family: @sansFont; 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; margin-left: @classicHorizMargin; margin-right: @classicHorizMargin; } .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: 4px 10px; border: none; margin: 1em 0; background: initial; } .ProseMirror-example-setup-style hr:after { content: ""; display: block; height: 1px; background-color: #ccc; line-height: 2px; } .ProseMirror ul, .ProseMirror ol { padding-left: 30px; } .ProseMirror blockquote { padding-left: 1em; border-left: 4px solid #ddd; color: #767676; 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; } .dark #editor { color: white; } .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 { +.editorreadmore, .editor-comment { color: @textLinkColor; text-decoration: underline; text-align: center; width: 100%; } .editor-html-block { font-family: @monoFont; } @media all and (min-width: 50em) { #photo-upload label { display: inline; } .ProseMirror-menubar, #title, #photo-upload { margin-left: 10%; margin-right: 10%; } .ProseMirror { padding-left: 10%; padding-right: 10%; } } @media all and (min-width: 60em) { .ProseMirror-menubar, #title, #photo-upload { margin-left: 15%; margin-right: 15%; } .ProseMirror { padding-left: 15%; padding-right: 15%; } } @media all and (min-width: 70em) { .ProseMirror-menubar, #title, #photo-upload { margin-left: 20%; margin-right: 20%; } .ProseMirror { padding-left: 20%; padding-right: 20%; } } @media all and (min-width: 85em) { .ProseMirror-menubar, #title, #photo-upload { margin-left: 25%; margin-right: 25%; } .ProseMirror { padding-left: 25%; padding-right: 25%; } } @media all and (min-width: 105em) { .ProseMirror-menubar, #title, #photo-upload { margin-left: 30%; margin-right: 30%; } .ProseMirror { padding-left: 30%; padding-right: 30%; } } diff --git a/prose/markdownParser.js b/prose/markdownParser.js index a5e2b3c..612759e 100644 --- a/prose/markdownParser.js +++ b/prose/markdownParser.js @@ -1,79 +1,110 @@ import { MarkdownParser } from "prosemirror-markdown"; import markdownit from "markdown-it"; import { writeFreelySchema } from "./schema"; const md = markdownit("commonmark", { html: true }); // Map HTML comment shortcodes to their own token types so they are handled // as special blocks rather than generic HTML. const SHORTCODE_TOKENS = { "": "readmore_block", "": "emailsub_block", }; md.core.ruler.push("shortcodes", (state) => { - for (let i = 0; i < state.tokens.length; i++) { - const token = state.tokens[i]; + const tokens = state.tokens; + // Iterate backwards so splicing at index i doesn't shift unvisited indices. + for (let i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]; + if (token.type === "html_block") { - const mapped = SHORTCODE_TOKENS[token.content.trim()]; - if (mapped) token.type = mapped; + const content = token.content.trim(); + const mapped = SHORTCODE_TOKENS[content]; + if (mapped) { + token.type = mapped; + continue; + } + /* NOTE: future discussion support + // comment is an inline node — when it appears as a standalone html_block, + // wrap it in a paragraph so ProseMirror can place the inline node correctly. + if (content === "") { + const open = new state.Token("paragraph_open", "p", 1); + const inlineTok = new state.Token("inline", "", 0); + inlineTok.children = [new state.Token("comment_token", "", 0)]; + const close = new state.Token("paragraph_close", "p", -1); + tokens.splice(i, 1, open, inlineTok, close); + } + */ + continue; + } + + /* NOTE: future discussion support + // Handle appearing as html_inline inside an existing paragraph. + if (token.type === "inline" && token.children) { + for (const child of token.children) { + if (child.type === "html_inline" && child.content.trim() === "") { + child.type = "comment_token"; + } + } } + */ } }); export const writeFreelyMarkdownParser = new MarkdownParser( writeFreelySchema, md, { 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 }, readmore_block: { node: "readmore" }, emailsub_block: { node: "emailsub" }, + comment_token: { node: "comment" }, html_block: { node: "html_block", getAttrs: (tok) => ({ content: tok.content }), }, html_inline: { node: "html_inline", getAttrs: (tok) => ({ content: tok.content }), }, } ); diff --git a/prose/markdownSerializer.js b/prose/markdownSerializer.js index 3e0175b..f25e23e 100644 --- a/prose/markdownSerializer.js +++ b/prose/markdownSerializer.js @@ -1,139 +1,142 @@ 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); }, emailsub(state, node) { state.write("\n"); state.closeBlock(node); }, + comment(state, node) { + state.write(""); + }, html_block(state, node) { state.write(node.attrs.content); state.closeBlock(node); }, html_inline(state, node) { state.write(node.attrs.content); }, blockquote(state, node) { state.wrapBlock("> ", null, 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/menu.js b/prose/menu.js index c534f61..0e45217 100644 --- a/prose/menu.js +++ b/prose/menu.js @@ -1,32 +1,49 @@ import { MenuItem } from "prosemirror-menu"; import { buildMenuItems } from "prosemirror-example-setup"; +import { NodeSelection } from "prosemirror-state"; import { writeFreelySchema } from "./schema"; function canInsert(state, nodeType, attrs) { let $from = state.selection.$from; for (let d = $from.depth; d >= 0; d--) { let index = $from.index(d); if ($from.node(d).canReplaceWith(index, index, nodeType, attrs)) return true; } return false; } const ReadMoreItem = new MenuItem({ label: "Read more", select: (state) => canInsert(state, writeFreelySchema.nodes.readmore), run(state, dispatch) { dispatch( state.tr.replaceSelectionWith(writeFreelySchema.nodes.readmore.create()) ); }, }); export const getMenu = () => { - const menuContent = [ - ...buildMenuItems(writeFreelySchema).fullMenu, - [ReadMoreItem], - ]; - return menuContent; + const builtItems = buildMenuItems(writeFreelySchema); + const { toggleLink } = builtItems; + + const patchedLink = new MenuItem({ + ...toggleLink.spec, + select(state) { + if ( + state.selection instanceof NodeSelection && + state.selection.node.type === writeFreelySchema.nodes.comment + ) { + return false; + } + return toggleLink.spec.select ? toggleLink.spec.select(state) : true; + }, + }); + + const fullMenu = builtItems.fullMenu.map((group) => + group.map((item) => (item === toggleLink ? patchedLink : item)) + ); + + return [...fullMenu, [ReadMoreItem]]; }; diff --git a/prose/schema.js b/prose/schema.js index 1d66123..3dc0305 100644 --- a/prose/schema.js +++ b/prose/schema.js @@ -1,55 +1,63 @@ import { schema } from "prosemirror-markdown"; import { Schema } from "prosemirror-model"; export const writeFreelySchema = new Schema({ nodes: schema.spec.nodes .addToEnd("readmore", { inline: false, content: "", group: "block", draggable: true, toDOM: (node) => [ "div", { class: "editorreadmore" }, "Read more...", ], parseDOM: [{ tag: "div.editorreadmore" }], }) .addToEnd("emailsub", { inline: false, content: "", group: "block", draggable: true, toDOM: () => [ "div", { id: "emailsub", contenteditable: "false" }, ["form", {}, ["p", {}, "Enter your email to subscribe to updates."], ["input", { type: "email", name: "email", placeholder: "me@example.com" }], ["input", { type: "submit", id: "subscribe-btn", value: "Subscribe" }], ], ], parseDOM: [{ tag: "div#emailsub" }], }) + .addToEnd("comment", { + inline: true, + content: "", + group: "inline", + draggable: false, + toDOM: () => ["a", { class: "editor-comment" }, "Discuss..."], + parseDOM: [{ tag: "a.editor-comment" }], + }) .addToEnd("html_block", { attrs: { content: { default: "" } }, content: "", group: "block", marks: "", draggable: true, toDOM: (node) => [ "div", { class: "editor-html-block", contenteditable: "false" }, node.attrs.content, ], parseDOM: [{ tag: "div.editor-html-block", getAttrs: (dom) => ({ content: dom.textContent }) }], }) .addToEnd("html_inline", { attrs: { content: { default: "" } }, inline: true, content: "", group: "inline", toDOM: (node) => ["code", { class: "editor-html-inline" }, node.attrs.content], parseDOM: [{ tag: "code.editor-html-inline", getAttrs: (dom) => ({ content: dom.textContent }) }], }), marks: schema.spec.marks, });