diff --git a/backend/locales/en.json b/backend/locales/en.json index a3d1fdfcf..04818470f 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1927,6 +1927,9 @@ "editor.blockContent.drawioLoading": "Loading the draw.io editor...", "editor.blockContent.drawioTitle": "Draw.io editor", "editor.blockContent.drawioUnreachable": "The draw.io editor could not be loaded. Check that this address is reachable from your browser.", + "editor.blockContent.preview": "Preview", + "editor.blockContent.previewEmpty": "Nothing to preview yet — the preview is drawn from the source on the left.", + "editor.blockContent.source": "Source", "editor.blockContent.title": "Edit Block Content", "editor.blockContent.unknownEditor": "This block asks to be edited with something this wiki does not have.", "editor.blockNotEnabled": "This block is not enabled for this site, and will be removed when the page is saved. Blocks are managed in the administration area.", @@ -2222,6 +2225,16 @@ "editor.visual.block.remove": "Remove", "editor.visual.block.removeHint": "Remove this block", "editor.visual.block.removeKeepHint": "Remove this block, keeping what it holds", + "editor.visual.deflist.addDefinition": "Add definition", + "editor.visual.deflist.addDefinitionHint": "Add a definition below the row the cursor is in", + "editor.visual.deflist.addTerm": "Add term", + "editor.visual.deflist.addTermHint": "Add a term below the row the cursor is in", + "editor.visual.deflist.definition": "Definition", + "editor.visual.deflist.definitionPlaceholder": "What the term means", + "editor.visual.deflist.removeHint": "Remove this definition list and everything in it", + "editor.visual.deflist.term": "Term", + "editor.visual.deflist.termPlaceholder": "The word being defined", + "editor.visual.deflist.title": "Definition list", "editor.visual.image.alt": "Alternative Text", "editor.visual.image.altHint": "What stands in for the picture where it cannot be seen. Leave it empty where the image is decoration.", "editor.visual.image.edit": "Edit…", @@ -2786,6 +2799,10 @@ "userProfile.title": "User Profile", "welcome.admin": "Administration Area", "welcome.createHome": "Create the homepage", + "welcome.createHomeAsciidoc": "Using the AsciiDoc Editor", + "welcome.createHomeFailed": "Failed to open the editor.", + "welcome.createHomeMarkdown": "Using the Markdown Editor", + "welcome.createHomeVisual": "Using the Visual Editor", "welcome.homeDefault.content": "Write some content here...", "welcome.homeDefault.description": "Welcome to my wiki!", "welcome.homeDefault.title": "Home", diff --git a/blocks/block-diagram/component.js b/blocks/block-diagram/component.js index 18aced8e6..9a87b2ab3 100644 --- a/blocks/block-diagram/component.js +++ b/blocks/block-diagram/component.js @@ -64,6 +64,15 @@ flowchart LR B -->|Yes| C[Ship it] B -->|No| A \`\`\``, + /* + The body is a source an author types, so it is edited as one: `BlockContentCode` puts the + Mermaid source on the left and this block, drawn from what is being typed, on the right. The + key is resolved to a component by `BlockContentEditorOverlay`. + + It is also the ONLY way to edit this block in the Visual editor: a block whose body is a + single fence is parsed as an atom there, so there is no caret to put inside it. + */ + contentEditor: 'code', props: [ { name: 'caption', diff --git a/blocks/block-infobox/component.js b/blocks/block-infobox/component.js index bd5cf2449..66642c13b 100644 --- a/blocks/block-infobox/component.js +++ b/blocks/block-infobox/component.js @@ -138,6 +138,15 @@ Public Transport: Monorail: false Website: https://montreal.ca \`\`\``, + /* + The body is a source an author types, so it is edited as one: `BlockContentCode` puts the YAML + on the left and this block, drawn from what is being typed, on the right. The key is resolved + to a component by `BlockContentEditorOverlay`. + + It is also the ONLY way to edit this block in the Visual editor: a block whose body is a + single fence is parsed as an atom there, so there is no caret to put inside it. + */ + contentEditor: 'code', props: [ { name: 'name', diff --git a/blocks/block-katex/component.js b/blocks/block-katex/component.js index f625bc910..b8c92ecf4 100644 --- a/blocks/block-katex/component.js +++ b/blocks/block-katex/component.js @@ -55,6 +55,15 @@ export class BlockKatexElement extends LitElement { template: `\`\`\`latex x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} \`\`\``, + /* + The body is a source an author types, so it is edited as one: `BlockContentCode` puts the TeX + on the left and this block, drawn from what is being typed, on the right. The key is resolved + to a component by `BlockContentEditorOverlay`. + + It is also the ONLY way to edit this block in the Visual editor: a block whose body is a + single fence is parsed as an atom there, so there is no caret to put inside it. + */ + contentEditor: 'code', props: [ { name: 'caption', diff --git a/blocks/block-kroki/component.js b/blocks/block-kroki/component.js index 5675222d1..a4279d6fc 100644 --- a/blocks/block-kroki/component.js +++ b/blocks/block-kroki/component.js @@ -96,6 +96,15 @@ digraph G { Hello -> World } \`\`\``, + /* + The body is a source an author types, so it is edited as one: `BlockContentCode` puts the + diagram source on the left and this block, drawn from what is being typed, on the right. The + key is resolved to a component by `BlockContentEditorOverlay`. + + It is also the ONLY way to edit this block in the Visual editor: a block whose body is a + single fence is parsed as an atom there, so there is no caret to put inside it. + */ + contentEditor: 'code', props: [ { name: 'type', diff --git a/blocks/block-mathjax/component.js b/blocks/block-mathjax/component.js index a11d5f818..89fd2bf30 100644 --- a/blocks/block-mathjax/component.js +++ b/blocks/block-mathjax/component.js @@ -141,6 +141,15 @@ export class BlockMathjaxElement extends LitElement { template: `\`\`\`latex x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} \`\`\``, + /* + The body is a source an author types, so it is edited as one: `BlockContentCode` puts the TeX + on the left and this block, drawn from what is being typed, on the right. The key is resolved + to a component by `BlockContentEditorOverlay`. + + It is also the ONLY way to edit this block in the Visual editor: a block whose body is a + single fence is parsed as an atom there, so there is no caret to put inside it. + */ + contentEditor: 'code', props: [ { name: 'caption', diff --git a/blocks/block-plantuml/component.js b/blocks/block-plantuml/component.js index c3ef2f125..cca99de35 100644 --- a/blocks/block-plantuml/component.js +++ b/blocks/block-plantuml/component.js @@ -69,6 +69,15 @@ Alice -> Bob : hello Bob --> Alice : hi @enduml \`\`\``, + /* + The body is a source an author types, so it is edited as one: `BlockContentCode` puts the + PlantUML source on the left and this block, drawn from what is being typed, on the right. The + key is resolved to a component by `BlockContentEditorOverlay`. + + It is also the ONLY way to edit this block in the Visual editor: a block whose body is a + single fence is parsed as an atom there, so there is no caret to put inside it. + */ + contentEditor: 'code', props: [ { name: 'server', diff --git a/frontend/src/components/BlockContentCode.vue b/frontend/src/components/BlockContentCode.vue new file mode 100644 index 000000000..eec820bb4 --- /dev/null +++ b/frontend/src/components/BlockContentCode.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/frontend/src/components/BlockContentDrawio.vue b/frontend/src/components/BlockContentDrawio.vue index e9b7e74c1..8dcdec422 100644 --- a/frontend/src/components/BlockContentDrawio.vue +++ b/frontend/src/components/BlockContentDrawio.vue @@ -65,6 +65,19 @@ const props = defineProps({ params: { type: Object, default: () => ({}) + }, + /* + The other two halves of the editor contract, declared so they stay off this component's root + element rather than because a canvas has any use for them: a drawing is drawn, not highlighted, + and draw.io is the one editor that already knows what it is editing. + */ + block: { + type: Object, + default: () => ({}) + }, + lang: { + type: String, + default: '' } }) diff --git a/frontend/src/components/BlockContentEditorOverlay.vue b/frontend/src/components/BlockContentEditorOverlay.vue index 199691ab8..53bd7f0c6 100644 --- a/frontend/src/components/BlockContentEditorOverlay.vue +++ b/frontend/src/components/BlockContentEditorOverlay.vue @@ -40,6 +40,8 @@ v-if="editorComponent" v-model="state.source" :params="params" + :block="block" + :lang="lang" @save="apply" />
@@ -81,6 +83,11 @@ import LoadingGeneric from './LoadingGeneric.vue' * - a `modelValue` of the block's body as text, and `update:modelValue` when it changes; * - a `params` object of the block's own parameters, for an editor that is configured by one — the * server it talks to, say. Which of them mean anything is the editor's business alone; + * - a `block` object, the block as the API describes it, for an editor that has to name the element + * it is editing the body of — which is how the code editor draws its preview; + * - a `lang` string, the info string of the fence the body came out of, for an editor that + * highlights it. Both editors declare all four whether or not they read them, so the contract is + * one shape rather than four combinations of it; * - optionally `@save`, for an editor with a save gesture of its own, which applies and closes. * An editor without one is applied by the button up here, which is always there either way. * @@ -96,6 +103,10 @@ import LoadingGeneric from './LoadingGeneric.vue' * whole application, and it should not be in the bundle of a wiki that has never drawn one. */ const EDITORS = { + code: defineAsyncComponent({ + loader: () => import('./BlockContentCode.vue'), + loadingComponent: LoadingGeneric + }), drawio: defineAsyncComponent({ loader: () => import('./BlockContentDrawio.vue'), loadingComponent: LoadingGeneric @@ -120,6 +131,7 @@ const { t } = useI18n() const editorKey = siteStore.overlayOpts?.editor ?? '' const block = siteStore.overlayOpts?.block ?? {} const params = siteStore.overlayOpts?.params ?? {} +const lang = siteStore.overlayOpts?.lang ?? '' const replace = siteStore.overlayOpts?.replace ?? null const state = reactive({ @@ -132,10 +144,15 @@ const editorComponent = computed(() => EDITORS[editorKey] ?? null) // METHODS +/* + Always emitted, `replace` or no `replace`. It is the MARKDOWN editor that needs one -- a line range + to write the body back over -- and the Visual editor deliberately sends none, because a block there + is a node and it kept the position of the one it opened. Guarding the emit on it therefore meant + Apply did nothing at all on that side: the only block with a body editor was draw.io, where the + drawing came back and was then dropped on the floor. +*/ function apply() { - if (replace) { - EVENT_BUS.emit('replaceBlockContent', { source: state.source, replace }) - } + EVENT_BUS.emit('replaceBlockContent', { source: state.source, replace }) close() } diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index 94bb500a0..317cda26c 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -780,6 +780,7 @@ function editBlockContent(line, name) { block: definition, params: blockValues(found, definition), source: content.source, + lang: content.language, // -> Where it goes back, and in what: the editor is handed text and hands text back, and the // fence it lives in is this side's business replace: content @@ -794,6 +795,11 @@ function editBlockContent(line, name) { * edit and one undo, and the opening line of the block is not touched at all. */ function replaceBlockContentClb({ source, replace }) { + // -> The Visual editor's own answer carries no range; only one editor is ever mounted, so this is + // belt and braces rather than a case that happens + if (!replace) { + return + } const model = editor.getModel() editor.executeEdits('blockContent', [ { diff --git a/frontend/src/components/EditorVisual.vue b/frontend/src/components/EditorVisual.vue index 09b6f3544..1069c1ef0 100644 --- a/frontend/src/components/EditorVisual.vue +++ b/frontend/src/components/EditorVisual.vue @@ -394,6 +394,7 @@ import 'prosemirror-gapcursor/style/gapcursor.css' import { createVisualEditor } from '@/editor/visual' import { readFencedBody, writeFencedBody } from '@/editor/visual/blockBody' import { + insertDefinitionList as insertDefinitionListCommand, insertNode, insertTable as insertTableCommand, markActive, @@ -889,12 +890,11 @@ function insertIcon(reference) { * A definition list with one empty pair, ready to be typed into. * * The same shape the Markdown editor's own button produces, which is a term and a definition rather - * than a bare `dl` — an empty list is not something markdown can even express. + * than a bare `dl` — an empty list is not something markdown can even express. Where it goes and + * where the caret lands afterwards are the command's business; see it for why that matters. */ function insertDefinitionList() { - const term = schema.nodes.definition_term.createAndFill() - const description = schema.nodes.definition_description.createAndFill() - run(insertNode(schema.nodes.definition_list, null, [term, description])) + run(insertDefinitionListCommand()) } /** The next free footnote label, counting the ones the document already carries. */ @@ -1045,6 +1045,7 @@ function editBlockContent(node, pos) { block: definition, params: { ...node.attrs.blockAttrs }, source: body.source, + lang: body.lang, // -> The Markdown editor puts a line range here; nothing on this side needs one, since the // block is a node and `editingBlockPos` is where it is replace: null @@ -1052,19 +1053,26 @@ function editBlockContent(node, pos) { }) } -/** A block body an editor produced, back onto the node it came from. */ +/** + * A block body an editor produced, back onto the node it came from. + * + * The position is taken into a local BEFORE the field is cleared, and the field is cleared whichever + * way this returns: writing it back through `editingBlockPos` wrote it back through `null`, which is + * position -1 to ProseMirror and an exception rather than an edit. + */ function replaceBlockContentClb({ source }) { - if (editingBlockPos === null || !editor) { + const pos = editingBlockPos + editingBlockPos = null + if (pos === null || !editor) { return } const view = editor.view - const node = view.state.doc.nodeAt(editingBlockPos) - editingBlockPos = null + const node = view.state.doc.nodeAt(pos) if (!node || node.type !== schema.nodes.block_component) { return } view.dispatch( - view.state.tr.setNodeMarkup(editingBlockPos, undefined, { + view.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, body: writeFencedBody(node.attrs.body, source) }) diff --git a/frontend/src/components/WelcomeOverlay.vue b/frontend/src/components/WelcomeOverlay.vue index f3682a5f9..c2daa1ece 100644 --- a/frontend/src/components/WelcomeOverlay.vue +++ b/frontend/src/components/WelcomeOverlay.vue @@ -11,7 +11,7 @@ - Using the Visual Editor + {{ t(`welcome.createHomeVisual`) }} - Using the Markdown Editor + {{ t(`welcome.createHomeMarkdown`) }} - Using the AsciiDoc Editor + {{ t(`welcome.createHomeAsciidoc`) }} @@ -107,7 +107,7 @@ async function createHomePage(editor) { siteStore.overlay = 'Welcome' notify({ type: 'negative', - message: 'Failed to open the editor.', + message: t('welcome.createHomeFailed'), caption: err.message }) } @@ -124,9 +124,22 @@ function loadAdmin() { .welcome { background: #fff radial-gradient(ellipse, #fff, #ddd); color: $grey-9; - height: 100vh; + // -> The panel this is slotted into is what owns the shape: WDialog rounds it and clips to that + // radius, and hands the radius down to its child with `border-radius: inherit`. A radius of its + // own here was the larger of the two, so the panel's own top corners -- the dark first 10px of + // the gradient `.main-overlay` paints on it -- showed in the gap between the two curves. + height: 100%; border: 1px solid #eee; - border-radius: 25px !important; + + // -> This sheet paints over the dialog surface underneath it, so it carries its own colours in + // both themes rather than letting `.main-overlay`'s show through -- which is why it was white + // on a dark wiki, with the menu it opens correctly dark and the two disagreeing. Dark mirrors + // light a step at a time: the sheet, the vignette at its edges, the rule round it, the text. + @at-root .body--dark & { + background: $dark-5 radial-gradient(ellipse, $dark-4, $dark-6); + color: $grey-4; + border-color: $dark-2; + } &-bg { position: absolute; @@ -138,6 +151,13 @@ function loadAdmin() { border-radius: 50%; filter: blur(100px); transform: translate(-50%, -55%); + + // -> The lower half of the blob is the sheet's own colour, so what shows is the glow above it + // and not a pale smear across the middle. $blue-8 rather than the lighter $blue-5: the same + // glow needs less lightness to read against near-black than it does against white. + @at-root .body--dark & { + background: linear-gradient(0, $dark-5 50%, $blue-8 50%); + } } &-content { @@ -179,6 +199,10 @@ function loadAdmin() { color: $blue-7; line-height: 1.2rem; margin-top: 1rem; + + @at-root .body--dark & { + color: $blue-4; + } } &-actions { diff --git a/frontend/src/css/_visual-editor.scss b/frontend/src/css/_visual-editor.scss index 43990a448..7eb35428d 100644 --- a/frontend/src/css/_visual-editor.scss +++ b/frontend/src/css/_visual-editor.scss @@ -712,6 +712,80 @@ } } +/* + A definition list. + + The page draws a `dl` as a term in bold with its definition indented under it, which is all the + distinction a READER needs and none of what an author does: both rows are empty when the list is + made, and an empty `dt` and an empty `dd` are the same nothing. So the editor adds a gutter that + names each row, and shows in the row itself what belongs there while it is empty. + + Everything here is scoped to `.visual-deflist`, the wrapper the node view puts round the list, so + the page's own definition lists are untouched -- and the geometry inside is still `_page-contents`'. +*/ +.visual-deflist { + border: 1px solid $grey-4; + border-radius: 4px; + margin: 0 0 1.15em; + overflow: hidden; + + .body--dark & { + border-color: $grey-8; + } +} + +.visual-deflist-rows { + /* -> The left padding is the gutter the labels sit in; the rows themselves start after it */ + margin: 0; + padding: 0.6rem 0.75rem 0.7rem 7rem; +} + +.visual-deflist dt, +.visual-deflist dd { + position: relative; + + /* + The row's name, in the gutter. Against the row rather than against the list, so a definition's + label follows the indent of the definition it names -- the two read as a pair that way. + */ + &::before { + content: attr(data-label); + position: absolute; + right: calc(100% + 0.6rem); + top: 0.15em; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + white-space: nowrap; + color: $grey-6; + user-select: none; + + .body--dark & { + color: $grey-6; + } + } + + /* + What goes in a row nobody has typed in yet. `pointer-events: none` so that clicking the words + still puts the caret in the row they are standing in for, rather than at the end of the line. + */ + &.is-empty::after { + content: attr(data-placeholder); + position: absolute; + top: 0; + left: 0; + font-weight: 400; + font-style: italic; + color: $grey-5; + pointer-events: none; + + .body--dark & { + color: $grey-7; + } + } +} + /* A footnote's body, labelled with the reference that points at it. */ .visual-footnote { display: flex; diff --git a/frontend/src/editor/visual/commands.js b/frontend/src/editor/visual/commands.js index 6e8637116..6825237ed 100644 --- a/frontend/src/editor/visual/commands.js +++ b/frontend/src/editor/visual/commands.js @@ -236,6 +236,57 @@ export function insertNode(type, attrs = {}, content = null) { } } +/** + * An empty definition list — one term and its definition — with the caret in the term. + * + * Built as nodes rather than parsed from markdown, unlike most of what the toolbar inserts: an empty + * definition list is not something markdown can express, since `: ` on a line with nothing above it + * is a paragraph that starts with a colon. + * + * `insertNode` is deliberately not used, and that is the reason this exists. It inserts through + * `replaceSelectionWith`, which for this node leaves the caret in the paragraph the button was + * pressed in — so the first word an author typed went above the list instead of into it, which is + * most of what made the list feel broken. Here the position is worked out first and the caret put + * where the typing is meant to start. + */ +export function insertDefinitionList() { + return (state, dispatch) => { + const list = schema.nodes.definition_list.createAndFill(null, [ + schema.nodes.definition_term.createAndFill(), + schema.nodes.definition_description.createAndFill() + ]) + if (!list) { + return false + } + if (dispatch) { + const { $from } = state.selection + /* + Where a block construct goes, by the same rule `insertMarkdown` follows: in place of the + textblock the caret is in when that block is empty -- inserting a list into the empty + paragraph at the end of a page should not strand that paragraph above it -- and after that + block otherwise. A selection with no textblock around it at all (a node selected whole) puts + the list at its own position. + */ + const replacing = + $from.depth > 0 && $from.parent.isTextblock && $from.parent.content.size === 0 + let at = $from.pos + if ($from.depth > 0) { + at = replacing ? $from.before() : $from.after() + } + const tr = state.tr + if (replacing) { + tr.replaceWith(at, $from.after(), list) + } else { + tr.insert(at, list) + } + // -> Two positions in: past the list's own opening token, then past the term's + tr.setSelection(TextSelection.near(tr.doc.resolve(at + 2))) + dispatch(tr.scrollIntoView()) + } + return true + } +} + /** A table of `rows` × `cols`, its first row a header. */ export function insertTable(rows = 3, cols = 3) { return (state, dispatch) => { @@ -252,6 +303,94 @@ export function insertTable(rows = 3, cols = 3) { } } +/** + * Whether a row of a definition list has never been typed into. + * + * A different question of each kind: a term holds inline content, while a definition holds blocks, so + * an untouched definition is a paragraph with nothing in it rather than nothing at all. + */ +function rowIsEmpty(row) { + return row.type === schema.nodes.definition_term + ? row.content.size === 0 + : row.childCount === 1 && row.firstChild.content.size === 0 +} + +/** + * Enter inside a definition list: term, definition, term, definition — and out at the end. + * + * The gesture that makes a definition list writable at all. A `dl` is two alternating node types with + * no visible difference between them until they hold text, so the default `splitBlock` — which makes + * another node of the SAME type — left an author typing a second term where they meant to type its + * definition, with no indication that anything was wrong. + * + * So Enter alternates: + * + * - in a term, the definition below it — the caret into the empty one already there, which is the + * shape the toolbar's own button produces, or a new one where there is none; + * - in a definition, the next term, on the same rule; + * - in an EMPTY row that is the last in the list, out of the list entirely, as pressing Enter twice + * leaves any other list. + * + * An empty row in the MIDDLE is left to the first two rules rather than splitting the list in half: + * markdown has no way to write two definition lists in a row without something between them anyway. + * + * A definition that runs to more than one paragraph is not reachable this way, which is deliberate: + * a definition list is one line per definition almost everywhere it is used, and Shift-Enter still + * puts a line break in a long one. + */ +export function splitDefinitionItem(state, dispatch) { + const { $from, empty } = state.selection + if (!empty) { + return false + } + + let depth = $from.depth + while ( + depth > 0 && + $from.node(depth).type !== schema.nodes.definition_term && + $from.node(depth).type !== schema.nodes.definition_description + ) { + depth -= 1 + } + if (depth === 0 || $from.node(depth - 1).type !== schema.nodes.definition_list) { + return false + } + + const row = $from.node(depth) + const list = $from.node(depth - 1) + const index = $from.index(depth - 1) + const isLast = index === list.childCount - 1 + const wanted = + row.type === schema.nodes.definition_term + ? schema.nodes.definition_description + : schema.nodes.definition_term + const next = isLast ? null : list.child(index + 1) + + if (!dispatch) { + return true + } + + const tr = state.tr + const at = $from.after(depth) + if (next && next.type === wanted && rowIsEmpty(next)) { + // -> Into the row that is already there, rather than another empty one beside it + tr.setSelection(TextSelection.near(tr.doc.resolve(at + 1))) + } else if (isLast && rowIsEmpty(row)) { + /* + Out of the list. The whole list goes when the empty row was all of it -- a `dl` with no rows is + not a document the schema allows, and it is not something markdown can write either. + */ + const from = list.childCount === 1 ? $from.before(depth - 1) : $from.before(depth) + tr.replaceWith(from, $from.after(depth - 1), schema.nodes.paragraph.createAndFill()) + tr.setSelection(TextSelection.near(tr.doc.resolve(from + 1))) + } else { + tr.insert(at, wanted.createAndFill()) + tr.setSelection(TextSelection.near(tr.doc.resolve(at + 1))) + } + dispatch(tr.scrollIntoView()) + return true +} + /** Whether a mark is on the selection, or would be on what is typed next. */ export function markActive(state, type) { const { from, $from, to, empty } = state.selection @@ -309,7 +448,12 @@ export function buildKeymap({ collab = false } = {}) { 'Mod-Shift-.': wrapIn(schema.nodes.blockquote), 'Mod-Shift-Enter': toggleTaskChecked, - Enter: chainCommands(splitListItem(schema.nodes.list_item), baseKeymap.Enter), + // -> Definition lists first: their rows alternate, which neither of the other two knows about + Enter: chainCommands( + splitDefinitionItem, + splitListItem(schema.nodes.list_item), + baseKeymap.Enter + ), Tab: chainCommands(goToNextCell(1), sinkListItem(schema.nodes.list_item)), 'Shift-Tab': chainCommands(goToNextCell(-1), liftListItem(schema.nodes.list_item)), diff --git a/frontend/src/editor/visual/nodeviews.js b/frontend/src/editor/visual/nodeviews.js index ee6d0fa50..545e90b75 100644 --- a/frontend/src/editor/visual/nodeviews.js +++ b/frontend/src/editor/visual/nodeviews.js @@ -883,6 +883,143 @@ class AbbreviationView { } } +/** + * A definition list, with a bar of its own and every row saying which half of the pair it is. + * + * The one construct in this editor with nothing to look at. A `dl` is terms and definitions + * alternating, both of them empty when the list is made, and the page styles alone say nothing: an + * author who pressed the toolbar button got a blank patch, could not tell which of the two invisible + * rows the caret was in, and had no way to add a third. So the rows are labelled in a gutter (see + * `DefinitionRowView`), an empty one shows what belongs in it, and the bar offers the two things + * there are to do to a list that already exists. + * + * The bar is chrome, so the `dl` the rows live in is a child of it rather than the node's own DOM. + */ +class DefinitionListView { + constructor(node, view, getPos, context) { + this.node = node + this.view = view + this.getPos = getPos + this.context = context + + this.dom = document.createElement('div') + this.dom.className = 'visual-deflist' + + this.header = blockHeader({ + name: context.t('editor.visual.deflist.title'), + attrs: null, + actions: [ + { + label: context.t('editor.visual.deflist.addTerm'), + title: context.t('editor.visual.deflist.addTermHint'), + run: () => this.addRow(schema.nodes.definition_term) + }, + { + label: context.t('editor.visual.deflist.addDefinition'), + title: context.t('editor.visual.deflist.addDefinitionHint'), + run: () => this.addRow(schema.nodes.definition_description) + }, + { + label: context.t('editor.visual.block.remove'), + title: context.t('editor.visual.deflist.removeHint'), + run: () => this.context.removeNode(this.getPos()) + } + ] + }) + + this.contentDOM = document.createElement('dl') + this.contentDOM.className = 'visual-deflist-rows' + this.dom.append(this.header, this.contentDOM) + } + + /** + * A new row, and the caret in it. + * + * After the row the author is in rather than at the end of the list: a list long enough to need + * another term in the middle of it is exactly the list where being sent to the bottom is wrong. The + * end is the fallback, for a press with the selection somewhere else entirely. + */ + addRow(type) { + const pos = this.getPos() + if (pos === undefined) { + return + } + const row = type.createAndFill() + if (!row) { + return + } + let at = pos + this.node.nodeSize - 1 + const { from } = this.view.state.selection + this.node.forEach((child, offset) => { + const start = pos + 1 + offset + if (from >= start && from <= start + child.nodeSize) { + at = start + child.nodeSize + } + }) + const tr = this.view.state.tr.insert(at, row) + tr.setSelection(TextSelection.near(tr.doc.resolve(at + 1))) + this.view.dispatch(tr.scrollIntoView()) + this.view.focus() + } + + update(node) { + if (node.type !== this.node.type) { + return false + } + this.node = node + return true + } + + ignoreMutation(mutation) { + return this.header.contains(mutation.target) + } + + stopEvent(event) { + return this.header.contains(event.target) + } +} + +/** + * One row of a definition list — the `dt` or the `dd` itself, told what it is. + * + * The element is its own content: there is nowhere to put a label INSIDE a node whose children are + * the document's own text without ProseMirror reading it as something an author typed. So the label + * and the placeholder are data attributes that `_visual-editor.scss` draws as pseudo-elements, and + * the only thing this view does is keep them, and the empty flag, in step with the node. + */ +class DefinitionRowView { + constructor(node, { tag, label, placeholder, isEmpty }) { + this.node = node + this.isEmpty = isEmpty + + this.dom = document.createElement(tag) + // -> Its own content: the row is a textblock, and a wrapper would put a node between the `dl` and + // the rows that the page's own stylesheet does not have + this.contentDOM = this.dom + this.dom.dataset.label = label + this.dom.dataset.placeholder = placeholder + this.apply(node) + } + + apply(node) { + this.dom.classList.toggle('is-empty', this.isEmpty(node)) + } + + update(node) { + if (node.type !== this.node.type) { + return false + } + this.node = node + this.apply(node) + return true + } + + /** The class and the two data attributes are this view's own; the text inside them is not. */ + ignoreMutation(mutation) { + return mutation.type === 'attributes' + } +} + /** A footnote's body, labelled with the reference that points at it. */ class FootnoteDefinitionView { constructor(node) { @@ -1031,6 +1168,22 @@ export function createNodeViews(context) { }, code_block: (node, view, getPos) => new CodeBlockView(node, view, getPos, context), alert: (node, view, getPos) => new AlertView(node, view, getPos, context), + definition_list: (node, view, getPos) => new DefinitionListView(node, view, getPos, context), + definition_term: (node) => + new DefinitionRowView(node, { + tag: 'dt', + label: context.t('editor.visual.deflist.term'), + placeholder: context.t('editor.visual.deflist.termPlaceholder'), + isEmpty: (row) => row.content.size === 0 + }), + definition_description: (node) => + new DefinitionRowView(node, { + tag: 'dd', + label: context.t('editor.visual.deflist.definition'), + placeholder: context.t('editor.visual.deflist.definitionPlaceholder'), + // -> A definition holds blocks, so an untouched one is a paragraph with nothing in it + isEmpty: (row) => row.childCount === 1 && row.firstChild.content.size === 0 + }), abbreviation: (node, view, getPos) => new AbbreviationView(node, view, getPos, context), footnote_definition: (node) => new FootnoteDefinitionView(node), raw_block: (node, view, getPos) => new RawBlockView(node, view, getPos, context),