mirror of https://github.com/requarks/wiki
parent
29d816401c
commit
ad861ae377
@ -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>
|
||||
@ -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>
|
||||
@ -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…
Reference in new issue