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:
'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: {
type: 'array',
description:

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

@ -31,6 +31,18 @@ export interface BlockDefinition {
isChild?: boolean
/** Body the editor writes between the opening and closing lines when inserting the block. */
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. */
@ -45,6 +57,8 @@ export interface SiteBlock {
config: Record<string, any>
props: BlockProp[]
template: string
/** Empty for a block that names no body editor, which is most of them. */
contentEditor: string
}
const blockSelection = {
@ -278,7 +292,8 @@ class Blocks {
return {
...row,
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 { assetPath } from '@/helpers/assets'
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 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.
*
@ -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.
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.
"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 editBlockContentCommand = editor.addCommand(0, (_accessor, line, block) =>
editBlockContent(line, block)
)
blockLensProvider = monaco.languages.registerCodeLensProvider('markdown', {
provideCodeLenses(model) {
return {
lenses: findBlocks(model.getValue())
.filter((found) => blockDefinition(found.block)?.props?.length > 0)
.map((found) => ({
range: new Range(found.line, 1, found.line, 1),
const text = model.getValue()
const lenses = []
for (const found of findBlocks(text)) {
const definition = blockDefinition(found.block)
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: {
id: editBlockCommand,
title: t('editor.markup.editBlock'),
arguments: [found.line, found.block]
}
})),
dispose() {}
})
}
}
return { lenses, dispose() {} }
}
})
@ -1761,6 +1854,7 @@ onMounted(async () => {
EVENT_BUS.on('insertAsset', insertAssetClb)
EVENT_BUS.on('insertTable', insertTableClb)
EVENT_BUS.on('insertBlock', insertBlockClb)
EVENT_BUS.on('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
// this.$root.$on('editorInsert', opts => {
@ -1800,6 +1894,7 @@ onBeforeUnmount(() => {
EVENT_BUS.off('insertAsset', insertAssetClb)
EVENT_BUS.off('insertTable', insertTableClb)
EVENT_BUS.off('insertBlock', insertBlockClb)
EVENT_BUS.off('replaceBlockContent', replaceBlockContentClb)
EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true)
monacoRef.value?.removeEventListener('dragover', onEditorDragOver)

@ -17,6 +17,10 @@ import { useSiteStore } from '../stores/site'
import LoadingGeneric from './LoadingGeneric.vue'
const overlays = {
BlockContentEditor: defineAsyncComponent({
loader: () => import('./BlockContentEditorOverlay.vue'),
loadingComponent: LoadingGeneric
}),
BlockPicker: defineAsyncComponent({
loader: () => import('./BlockPickerOverlay.vue'),
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
* 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,
* and what sits between the fences is the author's page content, or the blocks of a tabset. Building
* the whole block again from its definition, the way inserting one does, would throw that away.
* A block's parameters and its body are read and written separately, and nothing here ever rewrites a
* whole block. Everything a block's props can say is on the OPENING line, so that is all
* `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. */
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.
*
@ -151,3 +165,78 @@ export function blockOpeningLine(found, definition, values) {
const attributes = [...blockAttributes(definition, values), ...kept].join(' ')
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