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