feat: edit block parameters dialog

scarlett
NGPixel 4 weeks ago
parent 29d816401c
commit ad861ae377
No known key found for this signature in database

@ -1696,6 +1696,7 @@
"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.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",
"editor.blockPicker.loadFailed": "Failed to load the list of blocks.",
@ -1763,6 +1764,7 @@
"editor.markup.blockquoteWarning": "Warning Blockquote",
"editor.markup.bold": "Bold",
"editor.markup.distractionFreeMode": "Distraction Free Mode",
"editor.markup.editBlock": "Edit Block Parameters",
"editor.markup.editTable": "Edit in Table Editor",
"editor.markup.header": "Header",
"editor.markup.headerLevel": "Header {level}",

@ -0,0 +1,99 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card style="width: 550px">
<w-card-section class="card-header">
<w-icon
:name="`img:/_assets/icons/ultraviolet-${definition.isCustom ? 'plugin' : definition.icon}.svg`"
size="sm"
class="mr-2" />
<!-- -> The block is named in the title rather than over the form: one line of chrome above a
short form is enough, and which block this is belongs with what is being done to it. -->
<span>{{ t('editor.blockParams.title', { name: definition.name }) }}</span>
</w-card-section>
<w-card-section>
<block-props-form :fields="definition.props ?? []" :values="state.values" />
</w-card-section>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
:label="t(`common.actions.apply`)"
color="primary"
padding="xs md"
:disabled="!canApply"
@click="apply" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { blockPropsFilled } from '@/helpers/blocks'
import BlockPropsForm from '@/components/BlockPropsForm.vue'
/**
* What a block already in the page was given, for changing.
*
* The same form the picker fills in for a new block see `BlockPropsForm` over the values read
* back out of the page source. What comes back is only that: the caller knows which line the block
* was on and what else was written on it, and is the one to put the answer back.
*
* The values are copied on the way in, so closing without applying leaves the page as it was.
*/
// PROPS
const props = defineProps({
/** The block as the API describes it: its name, its icon and the props it declares. */
definition: {
type: Object,
required: true
},
/** What the page currently gives it, by prop name. */
values: {
type: Object,
required: true
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
values: { ...props.values }
})
// COMPUTED
// -> A required prop emptied out would leave a block that cannot draw anything
const canApply = computed(() => blockPropsFilled(props.definition, state.values))
// METHODS
function apply() {
onDialogOK({ ...state.values })
}
</script>

@ -88,45 +88,10 @@
</div>
<template v-else>
<div class="w-section-header">{{ state.selected.name }}</div>
<!--
A block with nothing to fill in is not a broken form: it is inserted as it stands. A
custom block reports no props at all, since only the compiled manifest carries them.
-->
<div
v-if="state.selected.props.length < 1"
class="text-caption mt-4 px-4 text-black/60 dark:text-white/70">
{{ t('editor.blockPicker.noProps') }}
</div>
<w-form v-else class="gap-4 px-4 pt-4">
<template v-for="prop of state.selected.props" :key="prop.name">
<w-select
v-if="prop.type === `select`"
v-model="state.values[prop.name]"
:options="prop.options ?? []"
outlined
dense
options-dense
:label="prop.label ?? prop.name"
:aria-label="prop.label ?? prop.name"
:required="prop.required"
:hint="prop.hint" />
<w-toggle
v-else-if="prop.type === `boolean`"
v-model="state.values[prop.name]"
dense
:label="prop.label ?? prop.name" />
<w-input
v-else
v-model="state.values[prop.name]"
outlined
dense
:type="prop.type === `number` ? `number` : `text`"
:label="prop.label ?? prop.name"
:aria-label="prop.label ?? prop.name"
:required="prop.required"
:hint="prop.hint" />
</template>
</w-form>
<block-props-form
class="px-4 pt-4"
:fields="state.selected.props"
:values="state.values" />
<!-- -> The markup itself, since that is what lands in the page -->
<div class="w-section-header mt-6">{{ t('editor.blockPicker.markdown') }}</div>
<!-- The same 16px all round, so it sits inside the panel the way the fields do -->
@ -145,7 +110,9 @@ import { computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/notify'
import { blockMarkdown } from '@/helpers/blocks'
import { blockMarkdown, blockPropsFilled } from '@/helpers/blocks'
import BlockPropsForm from '@/components/BlockPropsForm.vue'
import { useSiteStore } from '@/stores/site'
@ -186,15 +153,10 @@ const blocks = computed(() => state.blocks.filter((block) => block.isEnabled))
const markdown = computed(() => (state.selected ? blockMarkdown(state.selected, state.values) : ''))
const canInsert = computed(() => {
if (!state.selected) {
return false
}
// -> A required prop with nothing in it would insert a block that cannot draw anything
return state.selected.props
.filter((prop) => prop.required)
.every((prop) => String(state.values[prop.name] ?? '').length > 0)
})
// -> A required prop with nothing in it would insert a block that cannot draw anything
const canInsert = computed(
() => Boolean(state.selected) && blockPropsFilled(state.selected, state.values)
)
// METHODS

@ -0,0 +1,75 @@
<template>
<div v-if="fields.length < 1" class="text-caption text-black/60 dark:text-white/70">
{{ t('editor.blockPicker.noProps') }}
</div>
<w-form v-else class="gap-4">
<template v-for="field of fields" :key="field.name">
<w-select
v-if="field.type === `select`"
v-model="values[field.name]"
:options="field.options ?? []"
outlined
dense
options-dense
:label="field.label ?? field.name"
:aria-label="field.label ?? field.name"
:required="field.required"
:hint="field.hint" />
<w-toggle
v-else-if="field.type === `boolean`"
v-model="values[field.name]"
dense
:label="field.label ?? field.name" />
<w-input
v-else
v-model="values[field.name]"
outlined
dense
:type="field.type === `number` ? `number` : `text`"
:label="field.label ?? field.name"
:aria-label="field.label ?? field.name"
:required="field.required"
:hint="field.hint" />
</template>
</w-form>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
/**
* The form a block's props make: one field per prop, in the order the block declares them.
*
* Shared by the block picker, which fills it in for a block about to be inserted, and the parameters
* dialog the editor's lens opens over one already in the page. The two ask the same thing of an
* author and must offer the same controls, so the fields are described once here.
*
* A block with nothing to fill in is not a broken form: it is inserted, or left, as it stands. A
* custom block reports no props at all, since only the compiled manifest carries them.
*
* It writes into the `values` object it is given rather than emitting: what a caller wants back is
* "what is in the form now", and both of them already keep that object as their own state a
* `v-model` per field would be the same object, one indirection further away.
*
* Padding is the caller's: this sits in a panel in one and a card in the other.
*/
// PROPS
defineProps({
/** The props the block declares, as the API describes them. */
fields: {
type: Array,
required: true
},
/** Values by prop name, written into as the author types. */
values: {
type: Object,
required: true
}
})
// I18N
const { t } = useI18n()
</script>

@ -309,6 +309,7 @@
<script setup>
import {
computed,
defineAsyncComponent,
reactive,
ref,
shallowRef,
@ -325,6 +326,7 @@ import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen'
import { assetPath } from '@/helpers/assets'
import { blockMarkdown } from '@/helpers/blocks'
import { blockOpeningLine, blockValues, findBlocks } from '@/helpers/markdownBlocks'
import { findEditableTables } from '@/helpers/markdownTable'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
@ -384,6 +386,15 @@ let md
let pasteCaptureNode = null
/** The "Edit Table" lens provider, which is registered against the language rather than this editor. */
let tableLensProvider = null
/** The "Edit Block Parameters" lens provider, registered the same way. */
let blockLensProvider = null
/**
* The blocks this site has, as the API describes them their props included.
*
* Read once with the list of disabled ones, since it is the same request. What the lens needs from it
* is the props: a block whose definition is not here is one this editor cannot offer a form for.
*/
let siteBlocks = []
const monacoRef = ref(null)
const editorPreviewContainerRef = ref(null)
@ -618,6 +629,49 @@ function editTable(line) {
})
}
/** The block as this site describes it, or undefined for one it does not list. */
function blockDefinition(name) {
return siteBlocks.find((block) => block.block === name)
}
/**
* The parameters dialog, over a block already in the page what the lens above one opens.
*
* The block is looked up again here rather than taken from the lens, for the reason `editTable` gives:
* a lens is provided once and then moves with the text, so the line it carries is from whenever the
* document last settled. The name it was drawn for is carried along and has to match too where a
* table spans lines and can be found by containment, a block's opening line is a single line, and an
* edit above it would otherwise put a form for one block over another.
*/
function editBlock(line, name) {
const found = findBlocks(editor.getModel().getValue()).find(
(entry) => entry.line === line && entry.block === name
)
const definition = found && blockDefinition(found.block)
if (!definition) {
return
}
dialog({
component: defineAsyncComponent(() => import('./BlockParamsDialog.vue')),
componentProps: { definition, values: blockValues(found, definition) }
}).onOk((values) => {
/*
The opening line and nothing else, so the body between the fences is left exactly as it was
which for a tabset is every tab in it. One undo takes the whole change back, and the caret lands
on the line that moved rather than wherever it was before the dialog opened.
*/
const model = editor.getModel()
editor.executeEdits('block', [
{
range: new Range(found.line, 1, found.line, model.getLineMaxColumn(found.line)),
text: blockOpeningLine(found, definition, values)
}
])
editor.setPosition(new Position(found.line, 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.
*
@ -900,23 +954,25 @@ async function toggleMarkup({ start, end }) {
}
/**
* Read which blocks this site has switched off, once, before the first preview is drawn.
* Read the blocks this site has, once, before the first preview is drawn.
*
* Order matters more than it looks: a component only has to be fetched once to be defined for the
* rest of the session, so a list that arrives after the first render is too late to keep a disabled
* block from drawing.
* block from drawing. The lens over a block wants the same list a moment later, and asking twice for
* it would be asking the same question twice.
*/
async function loadDisabledBlocks() {
async function loadSiteBlocks() {
try {
const blocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? []
siteBlocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? []
disabledBlockTags.value = new Set(
blocks.filter((block) => !block.isEnabled).map((block) => `block-${block.block}`)
siteBlocks.filter((block) => !block.isEnabled).map((block) => `block-${block.block}`)
)
} catch (err) {
/*
Left empty, which draws everything as it did before. The preview being too generous is the
better failure: the server strips a disabled block on save either way, so the cost is a preview
that flatters the page, against hiding blocks the site really does have.
that flatters the page, against hiding blocks the site really does have. The lens is the other
way round with no definitions to build a form from, it simply does not appear.
*/
console.warn(`Could not read which blocks this site has enabled: ${err.message}`)
}
@ -1178,7 +1234,7 @@ onMounted(async () => {
})
// -> Awaited here so it is settled well before the first preview render at the end of this hook
await loadDisabledBlocks()
await loadSiteBlocks()
md = new MarkdownRenderer(editorStore.editors.markdown)
@ -1252,6 +1308,34 @@ onMounted(async () => {
}
})
/*
"Edit Block Parameters" over every block in the page, for the same reason the table has one: what
a block was given is a list of quoted attributes on one line, which is a poor thing to edit by
hand and an easy thing to offer a form for.
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.
*/
const editBlockCommand = editor.addCommand(0, (_accessor, line, block) => editBlock(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),
command: {
id: editBlockCommand,
title: t('editor.markup.editBlock'),
arguments: [found.line, found.block]
}
})),
dispose() {}
}
}
})
// -> Define Formatting Actions
editor.addAction({
contextMenuGroupId: 'markdown.extension.editing',
@ -1484,6 +1568,7 @@ onBeforeUnmount(() => {
monacoRef.value?.removeEventListener('drop', onEditorDrop)
// -> Registered against the markdown language, not this editor, so nothing else takes it down
tableLensProvider?.dispose()
blockLensProvider?.dispose()
// -> Before the editor goes: the binding is holding the model, and leaving the room is what takes
// this author's avatar out of everyone else's header
stopCollabSession()

@ -3,32 +3,49 @@
*
* Shared rather than living in the block picker, because the picker is not the only way a block gets
* inserted the toolbar has a shortcut for the tabset, which has to produce exactly what picking
* Tabs from the list would have produced.
* Tabs from the list would have produced, and the "Edit Block Parameters" lens rewrites the opening
* line of a block already in the page.
*
* `::block-name{prop="value"}` is what the renderer turns into `<block-name prop="value">`, the
* element the component registers itself as.
*/
/**
* What an author filled in, as MDC attributes one `name="value"` per prop worth writing out.
*
* Separate from `blockMarkdown` because editing an existing block reuses only this half: its body is
* whatever the author has since written between the two fences, and rebuilding the whole block from
* the definition would throw that away.
*
* @param {{ props?: Array }} block A block as the API describes it.
* @param {Record<string, unknown>} [values] What the author filled in, by prop name.
* @returns {string[]} The attributes, in the order the block declares its props.
*/
export function blockAttributes(block, values = {}) {
/*
Only what is worth writing out: anything given a value that is not already the block's own
default. A block reading its default from its own code does not need to be told it in every page.
*/
const written = (block.props ?? []).filter((prop) => {
const value = values[prop.name]
if (value === undefined || value === null || value === '') {
return false
}
return String(value) !== String(prop.default ?? '')
})
// -> A double quote in a value would close the attribute; MDC has no escape for it, so it goes
return written.map((prop) => `${prop.name}="${String(values[prop.name]).replaceAll('"', "'")}"`)
}
/**
* A block, opening and closing lines included.
*
* @param {{ block: string, props?: Array, template?: string }} block A block as the API describes it.
* @param {Record<string, unknown>} [values] What the author filled in, by prop name.
* @returns {string} The markup, opening and closing lines included.
*/
export function blockMarkdown(block, values = {}) {
const attributes = (block.props ?? [])
.filter((prop) => {
/*
Only what is worth writing out: anything given a value that is not already the block's own
default. A block reading its default from its own code does not need to be told it in every
page.
*/
const value = values[prop.name]
if (value === undefined || value === null || value === '') {
return false
}
return String(value) !== String(prop.default ?? '')
})
// -> A double quote in a value would close the attribute; MDC has no escape for it, so it goes
.map((prop) => `${prop.name}="${String(values[prop.name]).replaceAll('"', "'")}"`)
.join(' ')
const attributes = blockAttributes(block, values).join(' ')
const suffix = attributes ? `{${attributes}}` : ''
/*
@ -42,3 +59,19 @@ export function blockMarkdown(block, values = {}) {
}
return `::block-${block.block}${suffix}\n::`
}
/**
* Whether every prop the block insists on has been given something.
*
* Asked by both the picker's Insert button and the parameters dialog's Apply: a required prop left
* empty is a block that cannot draw anything.
*
* @param {{ props?: Array }} block A block as the API describes it.
* @param {Record<string, unknown>} values What the author filled in, by prop name.
* @returns {boolean}
*/
export function blockPropsFilled(block, values) {
return (block.props ?? [])
.filter((prop) => prop.required)
.every((prop) => String(values[prop.name] ?? '').length > 0)
}

@ -0,0 +1,153 @@
import { blockAttributes } from '@/helpers/blocks'
/**
* The blocks already in a page's source, read back and rewritten.
*
* The counterpart to `blocks.js`, which writes a block out: this finds the ones a page already
* 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.
*/
/** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */
const FENCE = /^ {0,3}(`{3,}|~{3,})/
/**
* A block component opening a line: `::block-name`, with its attributes if it was given any.
*
* Anchored to the start of the line because that is MDC's own rule for a block `:block-name{…}`
* mid-sentence is an inline component, which has no body and is not what the picker writes. Three or
* more colons is the same block fenced to hold blocks of its own, so the count is captured and put
* back rather than assumed.
*/
const OPENING = /^(:{2,})block-([a-z0-9-]+)[ \t]*(?:\{(.*)\})?[ \t]*$/
/**
* One entry in an attribute list: `name`, `name=value`, `name="value"`, or a `.class` / `#id`
* shorthand. Ordered so a quoted value wins over the unquoted reading, which would stop at the space.
*/
const ATTRIBUTE = /([.#][^\s"'=]+)|([^\s"'=]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s}]*)))?/g
/**
* Split an attribute list into what it says.
*
* `name` is null for a `.class` or `#id`, which belongs to no prop; `value` is null for a bare name,
* which MDC reads as true. `raw` is what was written, kept so that anything this block does not
* declare survives a rewrite untouched see `blockOpeningLine`.
*
* @param {string} source The inside of the braces.
* @returns {Array<{ name: string|null, value: string|null, raw: string }>}
*/
function parseAttributes(source) {
return [...source.matchAll(ATTRIBUTE)].map((match) => ({
name: match[1] ? null : match[2],
value: match[1] ? null : (match[3] ?? match[4] ?? match[5] ?? null),
raw: match[0]
}))
}
/**
* Every block in the source, in the order they appear. Line numbers are 1-based, to be handed
* straight to the editor.
*
* A block inside a fenced code block is a code sample and not a block, so those are skipped the
* same reading `findEditableTables` takes of the same lines. Nesting needs no tracking of its own:
* every opening line stands on its own, whatever it is written inside.
*
* @param {string} text The page source.
* @returns {Array<{ block: string, line: number, fence: string, attributes: Array }>}
*/
export function findBlocks(text) {
const lines = text.split('\n')
const blocks = []
let fence = null
for (let index = 0; index < lines.length; index++) {
const edge = FENCE.exec(lines[index])
if (fence) {
if (edge && edge[1][0] === fence[0] && edge[1].length >= fence.length) {
fence = null
}
continue
}
if (edge) {
fence = edge[1]
continue
}
const opening = OPENING.exec(lines[index])
if (opening) {
blocks.push({
block: opening[2],
line: index + 1,
fence: opening[1],
attributes: parseAttributes(opening[3] ?? '')
})
}
}
return blocks
}
/**
* What the form should open on: the block's props, filled in from what the page gave them.
*
* A prop the source says nothing about starts at the block's own default, which is what the block
* will do if left alone the same footing the picker starts a new block on.
*
* @param {{ attributes: Array }} found A block from `findBlocks`.
* @param {{ props?: Array }} definition The same block as the API describes it.
* @returns {Record<string, unknown>} Values by prop name.
*/
export function blockValues(found, definition) {
const written = new Map(
found.attributes.filter((attribute) => attribute.name).map((a) => [a.name, a.value])
)
return Object.fromEntries(
(definition.props ?? []).map((prop) => {
if (!written.has(prop.name)) {
return [prop.name, prop.default ?? '']
}
const value = written.get(prop.name)
switch (prop.type) {
/*
-> A bare `hideToolbar` is true, and so is any value but the word false which is exactly
how the blocks themselves read a boolean attribute, since MDC writes every prop as a
string and an attribute that is merely present would otherwise be true whatever it says.
*/
case 'boolean':
return [prop.name, value === null ? true : value !== 'false']
case 'number': {
const number = Number(value)
return [prop.name, Number.isFinite(number) ? number : (prop.default ?? '')]
}
default:
return [prop.name, value ?? '']
}
})
)
}
/**
* The opening line to write back, from what the form now holds.
*
* Anything in the original list that the block does not declare is carried over as it was written:
* a `.class`, or an attribute belonging to a version of the block that had a prop this one has not.
* None of them survive being saved the renderer allows a block exactly the attributes its
* definition declares but dropping them here would edit a line the author is still writing.
*
* @param {{ block: string, fence: string, attributes: Array }} found A block from `findBlocks`.
* @param {{ props?: Array }} definition The same block as the API describes it.
* @param {Record<string, unknown>} values What the form holds, by prop name.
* @returns {string} The line, with no trailing newline.
*/
export function blockOpeningLine(found, definition, values) {
const declared = new Set((definition.props ?? []).map((prop) => prop.name))
const kept = found.attributes
.filter((attribute) => !attribute.name || !declared.has(attribute.name))
.map((attribute) => attribute.raw)
const attributes = [...blockAttributes(definition, values), ...kept].join(' ')
return `${found.fence}block-${found.block}${attributes ? `{${attributes}}` : ''}`
}
Loading…
Cancel
Save