feat: blocks content editing + definition lists editing for visual editor

pull/8104/head
NGPixel 2 weeks ago
parent 194e7d70e6
commit 54dd821aa0
No known key found for this signature in database

@ -1927,6 +1927,9 @@
"editor.blockContent.drawioLoading": "Loading the draw.io editor...", "editor.blockContent.drawioLoading": "Loading the draw.io editor...",
"editor.blockContent.drawioTitle": "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.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.title": "Edit Block Content",
"editor.blockContent.unknownEditor": "This block asks to be edited with something this wiki does not have.", "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.", "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.remove": "Remove",
"editor.visual.block.removeHint": "Remove this block", "editor.visual.block.removeHint": "Remove this block",
"editor.visual.block.removeKeepHint": "Remove this block, keeping what it holds", "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.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.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…", "editor.visual.image.edit": "Edit…",
@ -2786,6 +2799,10 @@
"userProfile.title": "User Profile", "userProfile.title": "User Profile",
"welcome.admin": "Administration Area", "welcome.admin": "Administration Area",
"welcome.createHome": "Create the homepage", "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.content": "Write some content here...",
"welcome.homeDefault.description": "Welcome to my wiki!", "welcome.homeDefault.description": "Welcome to my wiki!",
"welcome.homeDefault.title": "Home", "welcome.homeDefault.title": "Home",

@ -64,6 +64,15 @@ flowchart LR
B -->|Yes| C[Ship it] B -->|Yes| C[Ship it]
B -->|No| A 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: [ props: [
{ {
name: 'caption', name: 'caption',

@ -138,6 +138,15 @@ Public Transport:
Monorail: false Monorail: false
Website: https://montreal.ca 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: [ props: [
{ {
name: 'name', name: 'name',

@ -55,6 +55,15 @@ export class BlockKatexElement extends LitElement {
template: `\`\`\`latex template: `\`\`\`latex
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} 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: [ props: [
{ {
name: 'caption', name: 'caption',

@ -96,6 +96,15 @@ digraph G {
Hello -> World 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: [ props: [
{ {
name: 'type', name: 'type',

@ -141,6 +141,15 @@ export class BlockMathjaxElement extends LitElement {
template: `\`\`\`latex template: `\`\`\`latex
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} 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: [ props: [
{ {
name: 'caption', name: 'caption',

@ -69,6 +69,15 @@ Alice -> Bob : hello
Bob --> Alice : hi Bob --> Alice : hi
@enduml @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: [ props: [
{ {
name: 'server', name: 'server',

@ -0,0 +1,325 @@
<template>
<div class="block-content-code">
<section class="block-content-code-pane">
<header class="block-content-code-bar">{{ t('editor.blockContent.source') }}</header>
<div ref="monacoEl" class="block-content-code-editor" />
</section>
<section class="block-content-code-pane block-content-code-pane--preview">
<header class="block-content-code-bar">{{ t('editor.blockContent.preview') }}</header>
<div class="block-content-code-preview page-contents">
<div v-if="state.empty" class="block-content-code-empty">
{{ t('editor.blockContent.previewEmpty') }}
</div>
<!--
Kept in the tree rather than torn out, so the element the block drew is not rebuilt from
nothing every time the source goes momentarily empty.
-->
<div v-show="!state.empty" ref="previewEl" />
</div>
</section>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import * as monaco from 'monaco-editor'
import { debounce } from 'es-toolkit/function'
import { useCommonStore } from '@/stores/common'
/**
* Source on the left, the block itself on the right — the body editor for every block whose content
* is code an author types.
*
* Which is most of them that have a body at all: a Mermaid diagram, a PlantUML or Kroki source, a TeX
* formula, an infobox's YAML. All of them store their body as one fenced source, all of them read it
* back out of a `<pre>` in their light DOM, and none of them could be edited in the Visual editor at
* all before this — a block holding a single fence is parsed as an ATOM there (`bodyIsContent` in
* `editor/visual/parse.js`), so there was no caret to put in it and nothing but the Parameters form
* to open over it.
*
* `BlockContentDrawio` is the other shape of body editor and the reason the registry exists: a
* drawing is edited on a canvas. This one is the shape everything else takes. See
* `BlockContentEditorOverlay` for the contract both implement.
*
* **The preview is the real block, not a rendering of one.** The same custom element the page draws,
* given the same attributes and the same `<pre>` the renderer would have left it — which is what the
* Visual editor's node view does for a block in the document, for the same reason: a second
* implementation of what a diagram looks like is a second thing to be wrong.
*/
// PROPS
const props = defineProps({
/** The block's body: the source between the fences, without them. */
modelValue: {
type: String,
default: ''
},
/** The block's own parameters, which the preview is drawn with. */
params: {
type: Object,
default: () => ({})
},
/** The block as the API describes it. `block` is the half that matters — it names the element. */
block: {
type: Object,
default: () => ({})
},
/**
* The info string of the fence the source came out of (`mermaid`, `latex`, `yaml`, …), for the
* highlighting. Only the ones Monaco knows are used; see `monacoLanguage`.
*/
lang: {
type: String,
default: ''
}
})
// EMITS
const emit = defineEmits(['update:modelValue', 'save'])
/** How long the author stops typing for before the block is drawn again. */
const PREVIEW_DELAY = 600
// STORES
const commonStore = useCommonStore()
// I18N
const { t } = useI18n()
// DATA
const monacoEl = ref(null)
const previewEl = ref(null)
const state = reactive({
empty: !props.modelValue.trim()
})
let editor = null
// METHODS
/**
* The fence's language as a Monaco one, or plain text.
*
* Most of these languages are nobody's but their own tool's — Monaco has no Mermaid, no PlantUML, no
* Kroki and no TeX — so the list is asked rather than assumed. An id Monaco does not know is not an
* error there, it simply highlights nothing, but asking is what keeps `yaml` and `xml` working
* without a map of every name that happens to match.
*/
function monacoLanguage() {
const lang = props.lang.trim().toLowerCase()
const known = monaco.languages.getLanguages().some((entry) => entry.id === lang)
return known ? lang : 'plaintext'
}
/**
* The block, drawn from the source as it stands.
*
* A fresh element every time, because a block reads its body once — `firstUpdated`, in every one of
* them — so the only way to redraw one is to make another. Cheap for a diagram; for the two that talk
* to a server it is a request, which is what the debounce below is for.
*/
function renderPreview() {
const source = props.modelValue.trim()
state.empty = !source
if (!source || !previewEl.value) {
return
}
const name = props.block?.block ?? ''
if (!name) {
return
}
const tag = `block-${name}`
const element = document.createElement(tag)
/*
Only the parameters that are actually set, which is what the page itself carries: `blockValues`
fills in a blank for every prop the author left alone, and handing those to the element as empty
attributes is not the same as leaving them off.
*/
for (const [key, value] of Object.entries(props.params ?? {})) {
if (value === undefined || value === null || value === '') {
continue
}
// -> A prop name a block declares is not necessarily a valid attribute name; a malformed one
// should cost its own parameter and not the whole preview
try {
element.setAttribute(key, String(value))
} catch {
/* ignored */
}
}
/*
The `<pre>` is not decoration: every one of these blocks takes its source from
`querySelector('pre')` and falls back to its own text, and the fallback is the path that means
"this was not fenced" — `block-diagram` says so in its error message. So the preview hands it the
same shape the renderer does.
*/
const pre = document.createElement('pre')
pre.textContent = source
element.append(pre)
previewEl.value.replaceChildren(element)
// -> Nothing draws until the element is upgraded, and a block is only fetched when a page uses it
if (!customElements.get(tag)) {
commonStore.loadBlocks([tag])
}
}
const renderPreviewSoon = debounce(renderPreview, PREVIEW_DELAY)
// LIFECYCLE
onMounted(() => {
// -> The markdown editor's theme, defined again here because that component may never have mounted
monaco.editor.defineTheme('wikijs', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#070a0d',
'editor.lineHighlightBackground': '#0d1117',
'editorLineNumber.foreground': '#546e7a',
'editorGutter.background': '#0d1117'
}
})
editor = monaco.editor.create(monacoEl.value, {
automaticLayout: true,
fontSize: 14,
language: monacoLanguage(),
lineNumbersMinChars: 3,
minimap: { enabled: false },
padding: { top: 10, bottom: 10 },
scrollBeyondLastLine: false,
tabSize: 2,
theme: 'wikijs',
value: props.modelValue,
// -> Off: a diagram's source is structured by its indentation, and wrapping a long line of it
// where the pane happens to end reads as a line of its own
wordWrap: 'off'
})
editor.onDidChangeModelContent(() => {
emit('update:modelValue', editor.getValue())
renderPreviewSoon()
})
/*
The same gesture as everywhere else in the app, and the overlay's Apply does the same thing. An
editor with a save of its own is what `@save` is for -- see the contract in the overlay.
*/
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
emit('save')
})
editor.focus()
renderPreview()
})
/*
The parameters can change under the preview: the Visual editor's own Parameters form is a different
overlay, but a block opened here twice in a session is not, and the preview has to be drawn with
what it is now rather than with what it was.
*/
watch(() => props.params, renderPreviewSoon, { deep: true })
onBeforeUnmount(() => {
renderPreviewSoon.cancel()
/*
The editor first and its model second, as the two other Monaco sites in the app do. Monaco keeps
models in a registry of its own, so one left behind outlives the component that made it -- but
disposing it while the editor still holds it takes the editor's own teardown down with it
(`Model is disposed!`, out of an observer reading the version id as it unwinds).
*/
const model = editor?.getModel()
editor?.dispose()
model?.dispose()
editor = null
})
</script>
<style lang="scss">
.block-content-code {
display: flex;
min-height: 0;
flex: 1 1 auto;
// -> Side by side is the point of this screen, but there is no room for two columns on a phone
@media (max-width: $breakpoint-sm-max) {
flex-direction: column;
}
&-pane {
display: flex;
flex-direction: column;
flex: 1 1 50%;
min-width: 0;
min-height: 0;
&--preview {
@at-root .body--light & {
background-color: #fff;
border-left: 1px solid $grey-4;
}
@at-root .body--dark & {
background-color: $dark-5;
border-left: 1px solid $dark-2;
}
@media (max-width: $breakpoint-sm-max) {
border-left: 0;
}
}
}
&-bar {
flex: 0 0 auto;
padding: 0.35rem 0.75rem;
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
@at-root .body--light & {
background-color: $grey-3;
color: $grey-7;
border-bottom: 1px solid $grey-4;
}
@at-root .body--dark & {
background-color: $dark-4;
color: $grey-5;
border-bottom: 1px solid $dark-2;
}
}
/*
Both halves are flex items of a column with a bar above them, so each needs `min-height: 0` of its
own: without it the box is sized by its content, and Monaco asking for the height of a box as tall
as itself resolves to nothing at all.
*/
&-editor {
flex: 1 1 auto;
min-height: 0;
}
&-preview {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 1rem;
}
&-empty {
font-style: italic;
opacity: 0.6;
}
}
</style>

@ -65,6 +65,19 @@ const props = defineProps({
params: { params: {
type: Object, type: Object,
default: () => ({}) 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: ''
} }
}) })

@ -40,6 +40,8 @@
v-if="editorComponent" v-if="editorComponent"
v-model="state.source" v-model="state.source"
:params="params" :params="params"
:block="block"
:lang="lang"
@save="apply" /> @save="apply" />
<div v-else class="p-6"> <div v-else class="p-6">
<w-card class="bg-negative rounded text-white" flat> <w-card class="bg-negative rounded text-white" flat>
@ -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 `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 * - 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; * 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. * - 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. * 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. * whole application, and it should not be in the bundle of a wiki that has never drawn one.
*/ */
const EDITORS = { const EDITORS = {
code: defineAsyncComponent({
loader: () => import('./BlockContentCode.vue'),
loadingComponent: LoadingGeneric
}),
drawio: defineAsyncComponent({ drawio: defineAsyncComponent({
loader: () => import('./BlockContentDrawio.vue'), loader: () => import('./BlockContentDrawio.vue'),
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric
@ -120,6 +131,7 @@ const { t } = useI18n()
const editorKey = siteStore.overlayOpts?.editor ?? '' const editorKey = siteStore.overlayOpts?.editor ?? ''
const block = siteStore.overlayOpts?.block ?? {} const block = siteStore.overlayOpts?.block ?? {}
const params = siteStore.overlayOpts?.params ?? {} const params = siteStore.overlayOpts?.params ?? {}
const lang = siteStore.overlayOpts?.lang ?? ''
const replace = siteStore.overlayOpts?.replace ?? null const replace = siteStore.overlayOpts?.replace ?? null
const state = reactive({ const state = reactive({
@ -132,10 +144,15 @@ const editorComponent = computed(() => EDITORS[editorKey] ?? null)
// METHODS // 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() { function apply() {
if (replace) {
EVENT_BUS.emit('replaceBlockContent', { source: state.source, replace }) EVENT_BUS.emit('replaceBlockContent', { source: state.source, replace })
}
close() close()
} }

@ -780,6 +780,7 @@ function editBlockContent(line, name) {
block: definition, block: definition,
params: blockValues(found, definition), params: blockValues(found, definition),
source: content.source, source: content.source,
lang: content.language,
// -> Where it goes back, and in what: the editor is handed text and hands text back, and the // -> 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 // fence it lives in is this side's business
replace: content 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. * edit and one undo, and the opening line of the block is not touched at all.
*/ */
function replaceBlockContentClb({ source, replace }) { 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() const model = editor.getModel()
editor.executeEdits('blockContent', [ editor.executeEdits('blockContent', [
{ {

@ -394,6 +394,7 @@ import 'prosemirror-gapcursor/style/gapcursor.css'
import { createVisualEditor } from '@/editor/visual' import { createVisualEditor } from '@/editor/visual'
import { readFencedBody, writeFencedBody } from '@/editor/visual/blockBody' import { readFencedBody, writeFencedBody } from '@/editor/visual/blockBody'
import { import {
insertDefinitionList as insertDefinitionListCommand,
insertNode, insertNode,
insertTable as insertTableCommand, insertTable as insertTableCommand,
markActive, markActive,
@ -889,12 +890,11 @@ function insertIcon(reference) {
* A definition list with one empty pair, ready to be typed into. * 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 * 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() { function insertDefinitionList() {
const term = schema.nodes.definition_term.createAndFill() run(insertDefinitionListCommand())
const description = schema.nodes.definition_description.createAndFill()
run(insertNode(schema.nodes.definition_list, null, [term, description]))
} }
/** The next free footnote label, counting the ones the document already carries. */ /** The next free footnote label, counting the ones the document already carries. */
@ -1045,6 +1045,7 @@ function editBlockContent(node, pos) {
block: definition, block: definition,
params: { ...node.attrs.blockAttrs }, params: { ...node.attrs.blockAttrs },
source: body.source, source: body.source,
lang: body.lang,
// -> The Markdown editor puts a line range here; nothing on this side needs one, since the // -> 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 // block is a node and `editingBlockPos` is where it is
replace: null 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 }) { function replaceBlockContentClb({ source }) {
if (editingBlockPos === null || !editor) { const pos = editingBlockPos
editingBlockPos = null
if (pos === null || !editor) {
return return
} }
const view = editor.view const view = editor.view
const node = view.state.doc.nodeAt(editingBlockPos) const node = view.state.doc.nodeAt(pos)
editingBlockPos = null
if (!node || node.type !== schema.nodes.block_component) { if (!node || node.type !== schema.nodes.block_component) {
return return
} }
view.dispatch( view.dispatch(
view.state.tr.setNodeMarkup(editingBlockPos, undefined, { view.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs, ...node.attrs,
body: writeFencedBody(node.attrs.body, source) body: writeFencedBody(node.attrs.body, source)
}) })

@ -11,7 +11,7 @@
<w-list padding> <w-list padding>
<w-item clickable @click="createHomePage(`visual`)" v-if="siteStore.editors.visual"> <w-item clickable @click="createHomePage(`visual`)" v-if="siteStore.editors.visual">
<blueprint-icon icon="google-presentation" /> <blueprint-icon icon="google-presentation" />
<w-item-section class="pr-2">Using the Visual Editor</w-item-section> <w-item-section class="pr-2">{{ t(`welcome.createHomeVisual`) }}</w-item-section>
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section> <w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
</w-item> </w-item>
<w-item <w-item
@ -19,7 +19,7 @@
@click="createHomePage(`markdown`)" @click="createHomePage(`markdown`)"
v-if="siteStore.editors.markdown"> v-if="siteStore.editors.markdown">
<blueprint-icon icon="markdown" /> <blueprint-icon icon="markdown" />
<w-item-section class="pr-2">Using the Markdown Editor</w-item-section> <w-item-section class="pr-2">{{ t(`welcome.createHomeMarkdown`) }}</w-item-section>
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section> <w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
</w-item> </w-item>
<w-item <w-item
@ -27,7 +27,7 @@
@click="createHomePage(`asciidoc`)" @click="createHomePage(`asciidoc`)"
v-if="flagsStore.experimental && siteStore.editors.asciidoc"> v-if="flagsStore.experimental && siteStore.editors.asciidoc">
<blueprint-icon icon="asciidoc" /> <blueprint-icon icon="asciidoc" />
<w-item-section class="pr-2">Using the AsciiDoc Editor</w-item-section> <w-item-section class="pr-2">{{ t(`welcome.createHomeAsciidoc`) }}</w-item-section>
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section> <w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
</w-item> </w-item>
</w-list> </w-list>
@ -107,7 +107,7 @@ async function createHomePage(editor) {
siteStore.overlay = 'Welcome' siteStore.overlay = 'Welcome'
notify({ notify({
type: 'negative', type: 'negative',
message: 'Failed to open the editor.', message: t('welcome.createHomeFailed'),
caption: err.message caption: err.message
}) })
} }
@ -124,9 +124,22 @@ function loadAdmin() {
.welcome { .welcome {
background: #fff radial-gradient(ellipse, #fff, #ddd); background: #fff radial-gradient(ellipse, #fff, #ddd);
color: $grey-9; 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: 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 { &-bg {
position: absolute; position: absolute;
@ -138,6 +151,13 @@ function loadAdmin() {
border-radius: 50%; border-radius: 50%;
filter: blur(100px); filter: blur(100px);
transform: translate(-50%, -55%); 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 { &-content {
@ -179,6 +199,10 @@ function loadAdmin() {
color: $blue-7; color: $blue-7;
line-height: 1.2rem; line-height: 1.2rem;
margin-top: 1rem; margin-top: 1rem;
@at-root .body--dark & {
color: $blue-4;
}
} }
&-actions { &-actions {

@ -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. */ /* A footnote's body, labelled with the reference that points at it. */
.visual-footnote { .visual-footnote {
display: flex; display: flex;

@ -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. */ /** A table of `rows` × `cols`, its first row a header. */
export function insertTable(rows = 3, cols = 3) { export function insertTable(rows = 3, cols = 3) {
return (state, dispatch) => { 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. */ /** Whether a mark is on the selection, or would be on what is typed next. */
export function markActive(state, type) { export function markActive(state, type) {
const { from, $from, to, empty } = state.selection const { from, $from, to, empty } = state.selection
@ -309,7 +448,12 @@ export function buildKeymap({ collab = false } = {}) {
'Mod-Shift-.': wrapIn(schema.nodes.blockquote), 'Mod-Shift-.': wrapIn(schema.nodes.blockquote),
'Mod-Shift-Enter': toggleTaskChecked, '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)), Tab: chainCommands(goToNextCell(1), sinkListItem(schema.nodes.list_item)),
'Shift-Tab': chainCommands(goToNextCell(-1), liftListItem(schema.nodes.list_item)), 'Shift-Tab': chainCommands(goToNextCell(-1), liftListItem(schema.nodes.list_item)),

@ -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. */ /** A footnote's body, labelled with the reference that points at it. */
class FootnoteDefinitionView { class FootnoteDefinitionView {
constructor(node) { constructor(node) {
@ -1031,6 +1168,22 @@ export function createNodeViews(context) {
}, },
code_block: (node, view, getPos) => new CodeBlockView(node, view, getPos, context), code_block: (node, view, getPos) => new CodeBlockView(node, view, getPos, context),
alert: (node, view, getPos) => new AlertView(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), abbreviation: (node, view, getPos) => new AbbreviationView(node, view, getPos, context),
footnote_definition: (node) => new FootnoteDefinitionView(node), footnote_definition: (node) => new FootnoteDefinitionView(node),
raw_block: (node, view, getPos) => new RawBlockView(node, view, getPos, context), raw_block: (node, view, getPos) => new RawBlockView(node, view, getPos, context),

Loading…
Cancel
Save