mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: blocks content editing + definition lists editing for visual editor
This commit is contained in:
16 files changed
+854
-19
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
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: ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
v-if="editorComponent"
|
||||
v-model="state.source"
|
||||
:params="params"
|
||||
:block="block"
|
||||
:lang="lang"
|
||||
@save="apply" />
|
||||
<div v-else class="p-6">
|
||||
<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 `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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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', [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<w-list padding>
|
||||
<w-item clickable @click="createHomePage(`visual`)" v-if="siteStore.editors.visual">
|
||||
<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>
|
||||
<w-item
|
||||
@@ -19,7 +19,7 @@
|
||||
@click="createHomePage(`markdown`)"
|
||||
v-if="siteStore.editors.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>
|
||||
<w-item
|
||||
@@ -27,7 +27,7 @@
|
||||
@click="createHomePage(`asciidoc`)"
|
||||
v-if="flagsStore.experimental && siteStore.editors.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>
|
||||
</w-list>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user