feat: add draw.io block

scarlett 3.0.0-beta.543
NGPixel 5 days ago
parent a857f96bb0
commit 7cf47610d1
No known key found for this signature in database

@ -42,6 +42,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description: description:
'Body the editor writes between the opening and closing lines when inserting the block, for a block whose content is other blocks. Empty for a block that takes none.' 'Body the editor writes between the opening and closing lines when inserting the block, for a block whose content is other blocks. Empty for a block that takes none.'
}, },
contentEditor: {
type: 'string',
description:
"Names an editor for the block's body, which the markdown editor offers as an \"Edit Content\" lens above the block alongside \"Edit Block Parameters\". A key the frontend resolves to a component, for a block whose body is a fenced source the props form cannot describe. Empty for a block that names none, which is most of them."
},
props: { props: {
type: 'array', type: 'array',
description: description:

@ -1673,6 +1673,11 @@
"editor.assets.uploadFailed": "File upload failed.", "editor.assets.uploadFailed": "File upload failed.",
"editor.backToEditor": "Back to Editor", "editor.backToEditor": "Back to Editor",
"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.",
"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.title": "Edit Block Content",
"editor.blockContent.unknownEditor": "This block asks to be edited with something this wiki does not have.",
"editor.blockParams.title": "Edit Block Parameters - {name}", "editor.blockParams.title": "Edit Block Parameters - {name}",
"editor.blockPicker.blockUnavailable": "This block is not available on this site. Blocks are managed in the administration area.", "editor.blockPicker.blockUnavailable": "This block is not available on this site. Blocks are managed in the administration area.",
"editor.blockPicker.insert": "Insert Block", "editor.blockPicker.insert": "Insert Block",
@ -1758,6 +1763,7 @@
"editor.markup.definitionListTerm": "Term", "editor.markup.definitionListTerm": "Term",
"editor.markup.distractionFreeMode": "Distraction Free Mode", "editor.markup.distractionFreeMode": "Distraction Free Mode",
"editor.markup.editBlock": "Edit Block Parameters", "editor.markup.editBlock": "Edit Block Parameters",
"editor.markup.editBlockContent": "Edit Content",
"editor.markup.editTable": "Edit in Table Editor", "editor.markup.editTable": "Edit in Table Editor",
"editor.markup.header": "Header", "editor.markup.header": "Header",
"editor.markup.headerLevel": "Header {level}", "editor.markup.headerLevel": "Header {level}",

@ -31,6 +31,18 @@ export interface BlockDefinition {
isChild?: boolean isChild?: boolean
/** Body the editor writes between the opening and closing lines when inserting the block. */ /** Body the editor writes between the opening and closing lines when inserting the block. */
template?: string template?: string
/**
* Names an editor for the block's BODY, which the markdown editor then offers as a second lens
* above the block "Edit Content", beside "Edit Block Parameters".
*
* For a block whose body is a fenced source the props form has nothing to say about: a diagram, a
* drawing. The value is a key the frontend resolves to a component, not a component or a URL, so
* that what a block declares stays a plain literal the manifest can be read out of.
*
* Absent for every other block, and absent is the answer: a body nobody named an editor for is
* edited in the page like any other content.
*/
contentEditor?: string
} }
/** A block row as exposed by the API, with what its component says it can be given. */ /** A block row as exposed by the API, with what its component says it can be given. */
@ -45,6 +57,8 @@ export interface SiteBlock {
config: Record<string, any> config: Record<string, any>
props: BlockProp[] props: BlockProp[]
template: string template: string
/** Empty for a block that names no body editor, which is most of them. */
contentEditor: string
} }
const blockSelection = { const blockSelection = {
@ -278,7 +292,8 @@ class Blocks {
return { return {
...row, ...row,
props: definition?.props ?? [], props: definition?.props ?? [],
template: definition?.template ?? '' template: definition?.template ?? '',
contentEditor: definition?.contentEditor ?? ''
} }
}) })
} }

@ -0,0 +1,257 @@
import { LitElement, html, css } from 'lit'
import { deflateRaw } from 'pako'
import { DarkMode } from '../shared/theme.js'
/**
* The draw.io that answers when the block names no server.
*
* Two hosts, because draw.io publishes two deployments of the same application: `viewer` is the one
* that opens a diagram read-only from a link, `embed` the one the editor talks to over postMessage
* (see `BlockContentDrawio.vue`). A self-hosted draw.io is one deployment doing both, which is why the
* `server` prop replaces both and defaults to neither.
*/
const PUBLIC_VIEWER = 'https://viewer.diagrams.net'
/** How many bytes are turned into characters at a time, below. */
const CHUNK_SIZE = 0x8000
/**
* A drawing as draw.io writes it into a link fragment.
*
* This is `Graph.compress` from draw.io itself, and every step of it matters to the other end:
* percent-encode, raw deflate (no zlib header, unlike Kroki's), then base64. The percent-encoding
* comes FIRST and is not a transport detail draw.io decompresses and then `decodeURIComponent`s, so
* a diagram deflated without it comes back mangled at every non-ASCII character.
*
* `btoa` takes a string, and spreading a whole drawing into `String.fromCharCode` at once overflows
* the stack somewhere in the tens of thousands of bytes hence a chunk at a time, as `block-kroki`
* does for the same reason.
*/
function compressForUrl(xml) {
const bytes = deflateRaw(encodeURIComponent(xml))
let binary = ''
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE))
}
return btoa(binary)
}
/**
* Block Draw.io
*/
export class BlockDrawioElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'drawio',
name: 'Draw.io',
description: 'A diagram drawn in draw.io, stored as its own XML and edited on a canvas.',
icon: 'web-design',
/*
Fenced, and `xml` because that is what a draw.io document is so an author reading the page
source gets it highlighted, and markdown keeps its hands off it. Without the fence a drawing is
a document full of `<` and `_` and lines beginning with spaces, every one of which means
something to markdown.
*/
template: `\`\`\`xml
<mxfile>
<diagram name="Page-1">
<mxGraphModel dx="800" dy="600" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
</root>
</mxGraphModel>
</diagram>
</mxfile>
\`\`\``,
/*
The body is a drawing, so it is edited on a canvas rather than typed: this names the editor the
markdown editor offers above the block, as a second lens beside "Edit Block Parameters". The key
is resolved to a component by `BlockContentEditorOverlay`; nothing about draw.io reaches the
block system itself.
*/
contentEditor: 'drawio',
props: [
{
name: 'server',
type: 'string',
label: 'Server',
hint: 'A self-hosted draw.io to draw and display with. The public diagrams.net services when left empty.'
},
{
name: 'height',
type: 'number',
label: 'Height',
hint: 'How tall the diagram is, in pixels.',
default: 420
},
{
name: 'caption',
type: 'string',
label: 'Caption',
hint: 'Shown under the diagram.'
},
{
name: 'align',
type: 'select',
label: 'Alignment',
options: ['left', 'center'],
default: 'left'
}
]
}
static get styles() {
return css`
:host {
display: block;
}
/* -> The gap below the block. On this element rather than :host: see block-index. */
.diagram,
.error {
margin-bottom: 16px;
}
.diagram {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.diagram.is-center {
align-items: center;
}
/*
The frame carries the border rather than the iframe: draw.io's lightbox paints its own
background over the whole box, so a border on the iframe itself is drawn under it at the
corners.
*/
.frame {
width: 100%;
max-width: 100%;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 5px;
overflow: hidden;
background-color: #fff;
}
:host([dark]) .frame {
border-color: rgba(255, 255, 255, 0.12);
background-color: #18191a;
}
iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.caption {
font-size: 0.8em;
opacity: 0.7;
}
.error {
padding: 12px;
border-radius: 5px;
border: 1px solid #c10015;
color: #c10015;
font-size: 0.9em;
}
`
}
static get properties() {
return {
/** A self-hosted draw.io, standing in for both public services. */
server: { type: String },
/** How tall the frame is, in pixels. */
height: { type: Number },
/** Shown under the diagram. */
caption: { type: String },
/** `left` or `center`. */
align: { type: String },
_source: { state: true }
}
}
constructor() {
super()
this.server = ''
this.height = 420
this.caption = ''
this.align = 'left'
this._source = ''
/*
The lightbox reads its theme from the URL at load, so the frame is rebuilt when the reader
switches which for a read-only diagram costs nothing, unlike doing the same to the editor.
*/
this._darkMode = new DarkMode(this)
}
connectedCallback() {
super.connectedCallback()
this._readSource()
}
/**
* The drawing, out of the body markdown left behind.
*
* `textContent` on the `<pre>` is what undoes the escaping the fence went through the same three
* lines every source block in this wiki uses.
*/
_readSource() {
const fence = this.querySelector('pre')
this._source = ((fence ?? this).textContent ?? '').trim()
}
/**
* The link the frame opens.
*
* `lightbox=1` is draw.io's read-only viewer: no editing, no menus, just the drawing with zoom and
* a layers control. `edit=_blank` is deliberately absent the pencil it adds opens the diagram in
* a copy of draw.io that has nowhere to save to, and the way to change a drawing here is the page.
*/
_url() {
const server = (this.server || '').trim().replace(/\/+$/, '') || PUBLIC_VIEWER
const params = new URLSearchParams({
lightbox: '1',
nav: '1',
ui: this._darkMode.isDark ? 'dark' : 'kennedy'
})
return `${server}/?${params.toString()}#R${encodeURIComponent(compressForUrl(this._source))}`
}
render() {
if (!this._source) {
return html`
<div class="error">
This diagram is empty. Draw one with the Edit Content link above the block in the editor.
</div>
`
}
const height = Number(this.height) > 0 ? Number(this.height) : 420
return html`
<div class="diagram ${this.align === 'center' ? 'is-center' : ''}">
<div class="frame" style="height: ${height}px">
<iframe
src=${this._url()}
title=${this.caption || 'Diagram'}
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen></iframe>
</div>
${this.caption ? html`<div class="caption">${this.caption}</div>` : ''}
</div>
`
}
}
window.customElements.define('block-drawio', BlockDrawioElement)

@ -0,0 +1,233 @@
<template>
<div class="block-content-drawio">
<iframe
ref="frameEl"
class="block-content-drawio-frame"
:src="editorUrl"
:title="t('editor.blockContent.drawioTitle')" />
<!--
Covers the frame until draw.io says hello, and stays covering it if it never does. The editor is
loaded from another host, so "nothing appeared" is a real outcome an offline wiki, a blocked
origin, a self-hosted deployment that has moved and a blank white rectangle explains none of
that.
-->
<div v-if="!state.ready" class="block-content-drawio-veil">
<template v-if="state.timedOut">
<w-icon name="la:plug" size="42px" />
<div class="mt-3 text-body1">{{ t('editor.blockContent.drawioUnreachable') }}</div>
<div class="text-caption mt-1 opacity-70">{{ origin }}</div>
</template>
<template v-else>
<w-spinner size="42px" color="primary" />
<div class="mt-3 text-caption">{{ t('editor.blockContent.drawioLoading') }}</div>
</template>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDark } from '@/composables/dark'
/**
* The draw.io drawing surface, as a body editor for `block-drawio`.
*
* Everything in this file is draw.io's own: nothing else in the app knows this protocol, and the
* block system it plugs into knows only that it takes a string of text and gives one back. See
* `BlockContentEditorOverlay` for what an editor component has to be.
*
* draw.io is embedded rather than reimplemented, and it is embedded rather than bundled because it is
* a whole application. The wiki holds an iframe and talks to it over `postMessage`, which is what the
* `embed=1&proto=json` mode of any draw.io deployment offers:
*
* in `{ event: 'init' }` it is ready to be given a drawing
* out `{ action: 'load', xml, … }` here is the drawing
* in `{ event: 'autosave', xml }` the drawing changed
* in `{ event: 'save', xml }` the author pressed its Save button
*
* `autosave` is what keeps this component's model current, so the overlay's own Apply always has the
* drawing as it stands; `save` is the same thing plus "and close", which is what the author means by
* pressing it. Nothing is ever uploaded: an embedded draw.io does its work in the browser, and the XML
* arrives here rather than at whoever is hosting the editor.
*/
// PROPS
const props = defineProps({
/** The block's body: a draw.io XML document, or empty for a drawing that does not exist yet. */
modelValue: {
type: String,
default: ''
},
/** The block's own parameters. Only `server` means anything here. */
params: {
type: Object,
default: () => ({})
}
})
// EMITS
const emit = defineEmits(['update:modelValue', 'save'])
/**
* The draw.io that answers when the block names none.
*
* `embed.diagrams.net` is the deployment draw.io publishes for exactly this, and it is a different
* host from the `viewer.diagrams.net` the block reads a finished drawing from which is why the
* block's `server` prop has a default on neither side and means "the public services" when empty.
*/
const PUBLIC_EDITOR = 'https://embed.diagrams.net'
/** How long draw.io has to say `init` before the veil stops being a spinner and starts explaining. */
const READY_TIMEOUT = 20000
// DARK MODE
// -> The reader's effective theme, not the site's default: a user who has chosen light on a dark wiki
// should be drawing on a light canvas
const dark = useDark()
// I18N
const { t } = useI18n()
// DATA
const frameEl = ref(null)
const state = reactive({
ready: false,
timedOut: false
})
let readyTimer = null
// COMPUTED
const server = computed(() => String(props.params.server || '').trim() || PUBLIC_EDITOR)
/** Where messages are sent, and the only place they are accepted from. */
const origin = computed(() => {
try {
return new URL(server.value).origin
} catch {
return ''
}
})
const editorUrl = computed(() => {
/*
`ui` is settled once, when the frame is built, and deliberately does not follow the reader
switching theme underneath it: the parameter is read at load, so keeping the two in step would
mean reloading draw.io and reloading it mid-drawing is the one thing that would lose work.
*/
const params = new URLSearchParams({
embed: '1',
proto: 'json',
spin: '1',
libraries: '1',
// -> The overlay's own Cancel is the way out, and two of them beside each other say different
// things about the unsaved drawing
noExitBtn: '1',
saveAndExit: '0',
ui: dark.isActive ? 'dark' : 'kennedy'
})
return `${server.value.replace(/\/+$/, '')}/?${params.toString()}`
})
// METHODS
function post(message) {
frameEl.value?.contentWindow?.postMessage(JSON.stringify(message), origin.value || '*')
}
/**
* One message from the frame.
*
* Both the window it came from and the origin it came from are checked. The window because any page
* may post to any other, and this listener is on `window`; the origin because the frame's own
* `contentWindow` is still whatever has been navigated into it.
*/
function onMessage(event) {
if (event.source !== frameEl.value?.contentWindow) {
return
}
if (origin.value && event.origin !== origin.value) {
return
}
let message = null
try {
message = JSON.parse(event.data)
} catch {
// -> Not ours: draw.io speaks JSON here, and its other protocols do not
return
}
switch (message.event) {
case 'init':
state.ready = true
clearTimeout(readyTimer)
// -> `autosave` is what makes the frame report every change, which is what Apply relies on
post({ action: 'load', autosave: 1, xml: props.modelValue })
break
case 'autosave':
emit('update:modelValue', message.xml ?? '')
break
case 'save':
emit('update:modelValue', message.xml ?? '')
emit('save')
break
}
}
// LIFECYCLE
onMounted(() => {
window.addEventListener('message', onMessage)
readyTimer = setTimeout(() => {
state.timedOut = true
}, READY_TIMEOUT)
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
clearTimeout(readyTimer)
})
</script>
<style lang="scss">
.block-content-drawio {
position: relative;
display: flex;
min-height: 0;
flex: 1 1 auto;
&-frame {
flex: 1 1 auto;
border: 0;
width: 100%;
height: 100%;
}
/* -> Over the frame rather than instead of it: draw.io is loading behind this the whole time */
&-veil {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem;
@at-root .body--light & {
background-color: #fff;
}
@at-root .body--dark & {
background-color: $dark-5;
}
}
}
</style>

@ -0,0 +1,184 @@
<template>
<w-layout class="block-content-editor" view="hHh lpR fFf" container>
<w-header class="card-header px-4 py-2">
<w-icon
:name="`img:/_assets/icons/ultraviolet-${block.isCustom ? 'plugin' : block.icon}.svg`"
left
size="md" />
<div>
<span>{{ t('editor.blockContent.title') }}</span>
<div class="text-caption">{{ block.name }}</div>
</div>
<w-space />
<w-btn-group push>
<w-btn
push
color="white"
text-color="grey-7"
:label="t(`common.actions.cancel`)"
:aria-label="t(`common.actions.cancel`)"
icon="la:times"
@click="close" />
<w-btn
push
color="positive"
text-color="white"
:label="t(`common.actions.apply`)"
:aria-label="t(`common.actions.apply`)"
icon="la:check"
@click="apply" />
</w-btn-group>
</w-header>
<w-page-container>
<!--
No padding around the editor: every one of these is a whole working surface -- a canvas, a
source pane -- and wants the screen it is given rather than a card's worth of it.
-->
<w-page class="block-content-editor-body">
<component
:is="editorComponent"
v-if="editorComponent"
v-model="state.source"
:params="params"
@save="apply" />
<div v-else class="p-6">
<w-card class="bg-negative rounded text-white" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="shrink-0 pr-0">
<w-icon name="la:ban" size="lg" />
</w-card-section>
<w-card-section>
<span>{{ t('editor.blockContent.unknownEditor') }}</span>
<div class="text-caption text-red-1">{{ editorKey }}</div>
</w-card-section>
</w-card-section>
</w-card>
</div>
</w-page>
</w-page-container>
</w-layout>
</template>
<script setup>
import { computed, defineAsyncComponent, onBeforeUnmount, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSiteStore } from '@/stores/site'
import LoadingGeneric from './LoadingGeneric.vue'
/**
* The body of a block, in whatever editor the block named for it.
*
* The chrome and the plumbing are the block system's; what goes in the middle is not. A block
* declares `contentEditor: '<key>'` in its `static definition`, the markdown editor offers an "Edit
* Content" lens above it, and this resolves that key against the registry below so adding an editor
* is adding one component and one line to that map, and nothing anywhere else has to know what it
* edits.
*
* What an editor component has to be, and the whole of it:
*
* - 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;
* - 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.
*
* Text in and text out: the fence it lives in, the line it goes back on and the undo it lands in are
* the markdown editor's, and it receives the result over the event bus the way the table editor and
* the file manager hand theirs back.
*/
/**
* The editors a block may name, by the key it names them with.
*
* Async, so a block nobody on this page uses costs nothing to have an embedded drawing editor is a
* whole application, and it should not be in the bundle of a wiki that has never drawn one.
*/
const EDITORS = {
drawio: defineAsyncComponent({
loader: () => import('./BlockContentDrawio.vue'),
loadingComponent: LoadingGeneric
})
}
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
/*
Read once, on the way in. The overlay is opened by a store patch and closes by clearing it, so
holding the block and its parameters here keeps them from going out from under the editor as it
closes -- and the source is a copy, so leaving without applying leaves the page as it was.
*/
const editorKey = siteStore.overlayOpts?.editor ?? ''
const block = siteStore.overlayOpts?.block ?? {}
const params = siteStore.overlayOpts?.params ?? {}
const replace = siteStore.overlayOpts?.replace ?? null
const state = reactive({
source: siteStore.overlayOpts?.source ?? ''
})
// COMPUTED
const editorComponent = computed(() => EDITORS[editorKey] ?? null)
// METHODS
function apply() {
if (replace) {
EVENT_BUS.emit('replaceBlockContent', { source: state.source, replace })
}
close()
}
function close() {
siteStore.$patch({ overlay: '' })
}
// -> Cleared whichever way the overlay was left, so a body left behind in the options is not the one
// the next block opens on
onBeforeUnmount(() => {
siteStore.overlayOpts = {}
})
</script>
<style lang="scss">
.block-content-editor {
/*
A foreground to go with the surface, as the table editor needs for the same reason: nothing in
here sits on a `w-card`, and that is what declares the app's text colour -- so without this
everything that merely inherits it stays black on the dark overlay.
*/
@at-root .body--light & {
color: var(--color-black);
}
@at-root .body--dark & {
color: var(--color-white);
}
/*
The editor fills the overlay. `min-height: 0` because this is a flex item of the page container:
without it the box is sized by its content, and an editor asking for 100% of a box that is as tall
as itself resolves to nothing at all.
*/
&-body {
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
> * {
flex: 1 1 auto;
min-height: 0;
}
}
}
</style>

@ -346,7 +346,13 @@ import { useMinWidth } from '@/composables/screen'
import { isVisible } from '@/helpers/anchors' import { isVisible } from '@/helpers/anchors'
import { assetPath } from '@/helpers/assets' import { assetPath } from '@/helpers/assets'
import { blockMarkdown } from '@/helpers/blocks' import { blockMarkdown } from '@/helpers/blocks'
import { blockOpeningLine, blockValues, findBlocks } from '@/helpers/markdownBlocks' import {
blockOpeningLine,
blockValues,
findBlockContent,
findBlocks,
writeBlockContent
} from '@/helpers/markdownBlocks'
import { findEditableTables } from '@/helpers/markdownTable' import { findEditableTables } from '@/helpers/markdownTable'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue' import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
@ -737,6 +743,67 @@ function editBlock(line, name) {
}) })
} }
/**
* The block's BODY, in whatever editor its definition named what the "Edit Content" lens opens.
*
* A second lens rather than a second tab of the parameters dialog, because the two are different
* shapes of thing: the parameters are a short form over one line, and a body is a whole screen of
* drawing or source. A block only has this one when it says so and when its body is a single fenced
* source to hand over, which is what `findBlockContent` answers.
*
* Looked up again at the moment of the click, for the reason `editTable` gives: a lens carries a line
* number from whenever the document last settled.
*/
function editBlockContent(line, name) {
const text = editor.getModel().getValue()
const found = findBlocks(text).find((entry) => entry.line === line && entry.block === name)
const definition = found && blockDefinition(found.block)
const content = definition?.contentEditor ? findBlockContent(text, found) : null
if (!content) {
return
}
siteStore.$patch({
overlay: 'BlockContentEditor',
overlayOpts: {
/*
What the block declared, which is the key the overlay resolves to an editor component. The
block's own parameters go with it, since an editor may be configured by them -- where the
drawing is edited, say -- and only the editor knows which of them it cares about.
*/
editor: definition.contentEditor,
block: definition,
params: blockValues(found, definition),
source: content.source,
// -> 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
}
})
}
/**
* A block body an editor produced, back over the fence it came from.
*
* The fences are rewritten along with the text -- see `writeBlockContent` -- so the whole thing is one
* edit and one undo, and the opening line of the block is not touched at all.
*/
function replaceBlockContentClb({ source, replace }) {
const model = editor.getModel()
editor.executeEdits('blockContent', [
{
range: new Range(
replace.startLine,
1,
replace.endLine,
model.getLineMaxColumn(replace.endLine)
),
text: writeBlockContent(replace, source)
}
])
editor.setPosition(new Position(replace.startLine, 1))
editor.focus()
}
/** /**
* The table the overlay built: over the lines it was read from, or at the cursor when it is a new one. * The table the overlay built: over the lines it was read from, or at the cursor when it is a new one.
* *
@ -1556,23 +1623,49 @@ onMounted(async () => {
It appears only over a block this editor holds a definition for and that has something to fill in. It appears only over a block this editor holds a definition for and that has something to fill in.
A child block -- a `::block-tab` inside a tabset -- is one it never does: those are left out of A child block -- a `::block-tab` inside a tabset -- is one it never does: those are left out of
the list the API answers with, having no switch of their own to be listed against. the list the API answers with, having no switch of their own to be listed against.
"Edit Content" joins it over a block whose definition NAMES an editor for its body and whose body
is a single fenced source to hand that editor. Both lenses come from this one provider rather than
a provider each, which is what fixes the order they appear in: two providers over the same line are
merged in whatever order the registry holds them, and a block would get its two links either way
round. Content first, since it is the block itself -- its parameters are how it is drawn.
*/ */
const editBlockCommand = editor.addCommand(0, (_accessor, line, block) => editBlock(line, block)) const editBlockCommand = editor.addCommand(0, (_accessor, line, block) => editBlock(line, block))
const editBlockContentCommand = editor.addCommand(0, (_accessor, line, block) =>
editBlockContent(line, block)
)
blockLensProvider = monaco.languages.registerCodeLensProvider('markdown', { blockLensProvider = monaco.languages.registerCodeLensProvider('markdown', {
provideCodeLenses(model) { provideCodeLenses(model) {
return { const text = model.getValue()
lenses: findBlocks(model.getValue()) const lenses = []
.filter((found) => blockDefinition(found.block)?.props?.length > 0) for (const found of findBlocks(text)) {
.map((found) => ({ const definition = blockDefinition(found.block)
range: new Range(found.line, 1, found.line, 1), if (!definition) {
continue
}
const range = new Range(found.line, 1, found.line, 1)
if (definition.contentEditor && findBlockContent(text, found)) {
lenses.push({
range,
command: {
id: editBlockContentCommand,
title: t('editor.markup.editBlockContent'),
arguments: [found.line, found.block]
}
})
}
if (definition.props?.length > 0) {
lenses.push({
range,
command: { command: {
id: editBlockCommand, id: editBlockCommand,
title: t('editor.markup.editBlock'), title: t('editor.markup.editBlock'),
arguments: [found.line, found.block] arguments: [found.line, found.block]
} }
})), })
dispose() {} }
} }
return { lenses, dispose() {} }
} }
}) })
@ -1761,6 +1854,7 @@ onMounted(async () => {
EVENT_BUS.on('insertAsset', insertAssetClb) EVENT_BUS.on('insertAsset', insertAssetClb)
EVENT_BUS.on('insertTable', insertTableClb) EVENT_BUS.on('insertTable', insertTableClb)
EVENT_BUS.on('insertBlock', insertBlockClb) EVENT_BUS.on('insertBlock', insertBlockClb)
EVENT_BUS.on('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.on('reloadEditorContent', reloadEditorContent) EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
// this.$root.$on('editorInsert', opts => { // this.$root.$on('editorInsert', opts => {
@ -1800,6 +1894,7 @@ onBeforeUnmount(() => {
EVENT_BUS.off('insertAsset', insertAssetClb) EVENT_BUS.off('insertAsset', insertAssetClb)
EVENT_BUS.off('insertTable', insertTableClb) EVENT_BUS.off('insertTable', insertTableClb)
EVENT_BUS.off('insertBlock', insertBlockClb) EVENT_BUS.off('insertBlock', insertBlockClb)
EVENT_BUS.off('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.off('reloadEditorContent', reloadEditorContent) EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true) pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true)
monacoRef.value?.removeEventListener('dragover', onEditorDragOver) monacoRef.value?.removeEventListener('dragover', onEditorDragOver)

@ -17,6 +17,10 @@ import { useSiteStore } from '../stores/site'
import LoadingGeneric from './LoadingGeneric.vue' import LoadingGeneric from './LoadingGeneric.vue'
const overlays = { const overlays = {
BlockContentEditor: defineAsyncComponent({
loader: () => import('./BlockContentEditorOverlay.vue'),
loadingComponent: LoadingGeneric
}),
BlockPicker: defineAsyncComponent({ BlockPicker: defineAsyncComponent({
loader: () => import('./BlockPickerOverlay.vue'), loader: () => import('./BlockPickerOverlay.vue'),
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric

@ -7,14 +7,28 @@ import { blockAttributes } from '@/helpers/blocks'
* carries so the editor can offer to edit their parameters, reads what they were given back into the * carries so the editor can offer to edit their parameters, reads what they were given back into the
* form's shape, and writes the answer over the line it came from. * form's shape, and writes the answer over the line it came from.
* *
* Only the OPENING line is ever read or replaced. Everything a block's props can say is on that line, * A block's parameters and its body are read and written separately, and nothing here ever rewrites a
* and what sits between the fences is the author's page content, or the blocks of a tabset. Building * whole block. Everything a block's props can say is on the OPENING line, so that is all
* the whole block again from its definition, the way inserting one does, would throw that away. * `blockOpeningLine` replaces what sits between the fences is the author's, page content or the
* blocks of a tabset, and rebuilding the block from its definition the way inserting one does would
* throw that away. `findBlockContent` is the other half, for the one shape of body an editor can be
* offered for: a block that declares a `contentEditor` and holds a single fenced source.
*/ */
/** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */ /** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */
const FENCE = /^ {0,3}(`{3,}|~{3,})/ const FENCE = /^ {0,3}(`{3,}|~{3,})/
/** The same line, with the info string after it — which for these is the language the body is in. */
const FENCE_OPENING = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)/
/**
* The line that closes a block component: nothing but colons.
*
* MDC closes a block with the fence it was opened with, so the count is compared by the caller rather
* than baked in here a `::` inside a `:::` block closes something nested, not the block itself.
*/
const CLOSING = /^ {0,3}(:{2,})[ \t]*$/
/** /**
* A block component opening a line: `::block-name`, with its attributes if it was given any. * A block component opening a line: `::block-name`, with its attributes if it was given any.
* *
@ -151,3 +165,78 @@ export function blockOpeningLine(found, definition, values) {
const attributes = [...blockAttributes(definition, values), ...kept].join(' ') const attributes = [...blockAttributes(definition, values), ...kept].join(' ')
return `${found.fence}block-${found.block}${attributes ? `{${attributes}}` : ''}` return `${found.fence}block-${found.block}${attributes ? `{${attributes}}` : ''}`
} }
/**
* The fenced source in a block's body, for a block that declares a `contentEditor`.
*
* That kind of block holds one fenced code block and nothing else a diagram, a drawing which is
* how every source block in this wiki is written: the fence is what keeps markdown off the text, so
* `-->` stays two dashes and a line opening with `#` stays a line and not a heading.
*
* The whole fenced block is reported, its two fence lines included, and `writeBlockContent` puts one
* back the same way. Editing only the lines BETWEEN them cannot express an empty body there are no
* lines there to replace and would have to reach for an insert at a position instead.
*
* @param {string} text The page source.
* @param {{ line: number, fence: string }} found The block, from `findBlocks`.
* @returns {{ language: string, fence: string, source: string, startLine: number, endLine: number }|null}
* The fenced body, or null for a block that does not hold exactly one.
*/
export function findBlockContent(text, found) {
const lines = text.split('\n')
let opening = null
for (let index = found.line; index < lines.length; index++) {
const line = lines[index]
if (!opening) {
// -> The block ended before any fence began: its body is prose, which this cannot edit
const closing = CLOSING.exec(line)
if (closing && closing[1].length >= found.fence.length) {
return null
}
const edge = FENCE_OPENING.exec(line)
if (edge) {
opening = { fence: edge[1], language: edge[2] ?? '', startLine: index + 1 }
}
continue
}
/*
A closing fence is the same character and at least as long. `FENCE` alone is too loose here,
since it would also match a second opening -- the character has to be compared.
*/
const edge = FENCE.exec(line)
if (edge && edge[1][0] === opening.fence[0] && edge[1].length >= opening.fence.length) {
return {
language: opening.language,
fence: opening.fence,
source: lines.slice(opening.startLine, index).join('\n'),
startLine: opening.startLine,
endLine: index + 1
}
}
}
// -> An unterminated fence is a block still being written; there is nothing whole to hand an editor
return null
}
/**
* The fenced body to write back, from what an editor now holds.
*
* The fence character and the language are the ones that were there, so nothing about the block moves
* except the text an editor was given. The fence is LENGTHENED where the new text contains a run of
* that character as long as it otherwise a body carrying its own fence would close this one early
* and the rest of it would land in the page as markdown.
*
* @param {{ language: string, fence: string }} content The body, from `findBlockContent`.
* @param {string} source What the editor produced.
* @returns {string} The lines to put back, fences included and no trailing newline.
*/
export function writeBlockContent(content, source) {
const marker = content.fence[0]
const longest = Math.max(
0,
...[...source.matchAll(new RegExp(`\\${marker}{3,}`, 'g'))].map((match) => match[0].length)
)
const fence = marker.repeat(Math.max(content.fence.length, longest + 1))
return `${fence}${content.language}\n${source}\n${fence}`
}

Loading…
Cancel
Save