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 @@
+
+
+
+ {{ t('editor.blockContent.source') }}
+
+
+
+ {{ t('editor.blockContent.preview') }}
+
+
+ {{ t('editor.blockContent.previewEmpty') }}
+
+
+
+
+
+
+
+
+
+
+
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),