From 94489b96ba3d77287867eb5f4c6ed693f7c574ce Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Sun, 25 Oct 2026 17:11:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(markdown):=20live=20blocks=20=E2=80=94=20a?= =?UTF-8?q?=20renderer=20may=20mount=20a=20widget=20into=20its=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .changeset/markdown-live-blocks.md | 5 ++ docs/de/markdown.md | 17 +++++ docs/en/markdown.md | 17 +++++ docs/ru/markdown.md | 17 +++++ docs/zh/markdown.md | 17 +++++ src/components/Markdown/UidMarkdown.css | 6 ++ src/components/Markdown/UidMarkdown.spec.ts | 45 ++++++++++++++ src/components/Markdown/UidMarkdown.vue | 24 ++++++- .../Markdown/UidMarkdownLiveBlock.vue | 62 +++++++++++++++++++ src/components/Markdown/blockRenderers.ts | 26 ++++++++ src/components/Markdown/index.ts | 2 + 11 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 .changeset/markdown-live-blocks.md create mode 100644 src/components/Markdown/UidMarkdownLiveBlock.vue diff --git a/.changeset/markdown-live-blocks.md b/.changeset/markdown-live-blocks.md new file mode 100644 index 0000000..fd542de --- /dev/null +++ b/.changeset/markdown-live-blocks.md @@ -0,0 +1,5 @@ +--- +"@dskripchenko/ui": minor +--- + +UidMarkdown: live blocks. A block renderer may return `mount(el, ctx)` instead of `html` — the host's widget (an API console, a chart with controls) is mounted into the block. A changed source goes to the widget's `update`, `destroy` runs when the block goes away; rendered markdown mounts with `editable: false`. New types `UidMarkdownBlockMountContext` and `UidMarkdownBlockMountHandle`; the contract matches the WYSIWYG editor's live blocks. diff --git a/docs/de/markdown.md b/docs/de/markdown.md index 17124fb..3c19d66 100644 --- a/docs/de/markdown.md +++ b/docs/de/markdown.md @@ -60,6 +60,23 @@ provideMarkdownBlockRenderers({ mermaid }) - **`html` wird unverändert eingefügt.** Für die Sicherheit ist der Renderer verantwortlich: nur selbst erzeugtes Markup mit maskiertem Benutzertext zurückgeben. - Das Prop `blockRenderers` hat Vorrang vor bereitgestellten Renderern, je Sprache. App-weit: `app.provide(markdownBlockRenderersKey, renderers)`. +### Live-Blöcke + +Statt `html` kann ein Renderer `mount` liefern — ein Widget wird in das Element des Blocks gemountet (eine API-Konsole, ein Diagramm mit Bedienelementen): + +```ts +const api: UidMarkdownBlockRenderer = () => ({ + mount: (el, { code }) => { + const widget = createWidget(el, code) + return { update: (next) => widget.setSource(next), destroy: () => widget.unmount() } + }, +}) +``` + +- `mount(el, { code, language, editable, setCode })` gibt ein Handle `{ update?, destroy? }` oder eine Aufräumfunktion zurück. Gerendertes Markdown ist schreibgeschützt: `editable` ist `false`, `setCode` tut nichts. +- Ändert sich die Quelle und der Block bleibt an seiner Stelle, lebt das Widget weiter und erhält die neue Quelle über `update`; ohne `update` wird es neu gemountet. `destroy` läuft, wenn der Block verschwindet oder UidMarkdown ausgehängt wird. +- Der Vertrag ist derselbe wie bei den Live-Blöcken des WYSIWYG-Editors (`@dskripchenko/wysiwyg`): Ein einmal registrierter Renderer bedient beide. + ## Der Renderer als Funktion Auch `renderMarkdown(source, options)` und `splitMarkdown(source)` werden exportiert — für eine Vorschau außerhalb einer Komponente oder serverseitig. Optionen: `slugger`, `onHeading`, `linkBase`, `stripMdExtension`, `imageBase`, `internalLinksInPlace`, `calloutTitles`. diff --git a/docs/en/markdown.md b/docs/en/markdown.md index 02e0894..4346c7b 100644 --- a/docs/en/markdown.md +++ b/docs/en/markdown.md @@ -60,6 +60,23 @@ provideMarkdownBlockRenderers({ mermaid }) - **`html` is inserted as is.** The renderer is responsible for its safety: return only markup you built with every piece of user text escaped. - The `blockRenderers` prop takes precedence over provided renderers, language by language. App-wide: `app.provide(markdownBlockRenderersKey, renderers)`. +### Live blocks + +Instead of `html` a renderer may return `mount` — a widget is mounted into the block's element (an API console, a chart with controls): + +```ts +const api: UidMarkdownBlockRenderer = () => ({ + mount: (el, { code }) => { + const widget = createWidget(el, code) + return { update: (next) => widget.setSource(next), destroy: () => widget.unmount() } + }, +}) +``` + +- `mount(el, { code, language, editable, setCode })` returns a handle `{ update?, destroy? }` or a cleanup function. Rendered markdown is read-only: `editable` is `false` and `setCode` does nothing. +- When the source changes and the block stays in place, the widget keeps living and gets the new source through `update`; without `update` it is mounted again. `destroy` runs when the block goes away or UidMarkdown unmounts. +- The contract is the one of the WYSIWYG editor's live blocks (`@dskripchenko/wysiwyg`), so a renderer registered once serves both. + ## The renderer as a function `renderMarkdown(source, options)` and `splitMarkdown(source)` are exported too — for a preview outside a component or for server-side use. Options: `slugger`, `onHeading`, `linkBase`, `stripMdExtension`, `imageBase`, `internalLinksInPlace`, `calloutTitles`. diff --git a/docs/ru/markdown.md b/docs/ru/markdown.md index 9d70f9c..513cd57 100644 --- a/docs/ru/markdown.md +++ b/docs/ru/markdown.md @@ -60,6 +60,23 @@ provideMarkdownBlockRenderers({ mermaid }) - **`html` вставляется как есть.** За его безопасность отвечает рендерер: возвращайте только разметку, собранную вами, с экранированным пользовательским текстом. - Prop `blockRenderers` важнее переданных через provide — по каждому языку отдельно. На всё приложение: `app.provide(markdownBlockRenderersKey, renderers)`. +### Живые блоки + +Вместо `html` рендерер может вернуть `mount` — в элемент блока монтируется виджет (консоль API, график с элементами управления): + +```ts +const api: UidMarkdownBlockRenderer = () => ({ + mount: (el, { code }) => { + const widget = createWidget(el, code) + return { update: (next) => widget.setSource(next), destroy: () => widget.unmount() } + }, +}) +``` + +- `mount(el, { code, language, editable, setCode })` возвращает handle `{ update?, destroy? }` или функцию очистки. Отрисованный markdown только для чтения: `editable` равно `false`, `setCode` ничего не делает. +- Если исходник изменился, а блок остался на месте, виджет продолжает жить и получает новый исходник через `update`; без `update` он монтируется заново. `destroy` вызывается, когда блок исчезает или UidMarkdown размонтируется. +- Контракт тот же, что у живых блоков WYSIWYG-редактора (`@dskripchenko/wysiwyg`): один зарегистрированный рендерер обслуживает оба. + ## Рендерер как функция Экспортированы и `renderMarkdown(source, options)`, `splitMarkdown(source)` — для превью вне компонента или на сервере. Опции: `slugger`, `onHeading`, `linkBase`, `stripMdExtension`, `imageBase`, `internalLinksInPlace`, `calloutTitles`. diff --git a/docs/zh/markdown.md b/docs/zh/markdown.md index c0cd895..76845d8 100644 --- a/docs/zh/markdown.md +++ b/docs/zh/markdown.md @@ -60,6 +60,23 @@ provideMarkdownBlockRenderers({ mermaid }) - **`html` 原样插入。** 其安全性由渲染器负责:只返回自己生成、且所有用户文本均已转义的标记。 - `blockRenderers` prop 按语言优先于通过 provide 提供的渲染器。全应用范围:`app.provide(markdownBlockRenderersKey, renderers)`。 +### 实时块 + +渲染器可以返回 `mount` 而不是 `html`——组件被挂载到块的元素中(API 控制台、带控件的图表): + +```ts +const api: UidMarkdownBlockRenderer = () => ({ + mount: (el, { code }) => { + const widget = createWidget(el, code) + return { update: (next) => widget.setSource(next), destroy: () => widget.unmount() } + }, +}) +``` + +- `mount(el, { code, language, editable, setCode })` 返回句柄 `{ update?, destroy? }` 或清理函数。渲染后的 markdown 是只读的:`editable` 为 `false`,`setCode` 不做任何事。 +- 源码改变而块位置不变时,组件继续存在,并通过 `update` 收到新源码;没有 `update` 时重新挂载。块消失或 UidMarkdown 卸载时调用 `destroy`。 +- 该契约与 WYSIWYG 编辑器(`@dskripchenko/wysiwyg`)的实时块相同:注册一次的渲染器可同时用于两者。 + ## 作为函数使用 同时导出 `renderMarkdown(source, options)` 和 `splitMarkdown(source)` —— 用于组件之外的预览或服务端。选项:`slugger`、`onHeading`、`linkBase`、`stripMdExtension`、`imageBase`、`internalLinksInPlace`、`calloutTitles`。 diff --git a/src/components/Markdown/UidMarkdown.css b/src/components/Markdown/UidMarkdown.css index ba31b89..9ef65db 100644 --- a/src/components/Markdown/UidMarkdown.css +++ b/src/components/Markdown/UidMarkdown.css @@ -149,6 +149,12 @@ .uid-markdown__block > svg { max-width: 100%; height: auto; } +/* A live block holds an interactive widget, laid out as the widget wants. */ +.uid-markdown__block--live { + overflow: visible; + text-align: start; +} + .uid-markdown__block--pending { padding: var(--uid-space-lg); border: 1px dashed var(--uid-markdown-border); diff --git a/src/components/Markdown/UidMarkdown.spec.ts b/src/components/Markdown/UidMarkdown.spec.ts index 2b05f6a..73f76ee 100644 --- a/src/components/Markdown/UidMarkdown.spec.ts +++ b/src/components/Markdown/UidMarkdown.spec.ts @@ -158,6 +158,51 @@ describe('UidMarkdown', () => { expect(wrapper.find('.from-provide-b').exists()).toBe(false) }) + it('mounts a live block, updates it with a new source and destroys it', async () => { + const log: string[] = [] + const live: UidMarkdownBlockRenderer = () => ({ + mount: (el, ctx) => { + log.push(`mount:${ctx.code}:${ctx.editable}`) + el.innerHTML = '' + return { + update: (code) => log.push(`update:${code}`), + destroy: () => log.push('destroy'), + } + }, + }) + const wrapper = mount(UidMarkdown, { props: { source: fence('api', 'GET /a'), blockRenderers: { api: live } } }) + await flushPromises() + expect(wrapper.find('.uid-markdown__block--live button.widget').exists()).toBe(true) + expect(log).toEqual(['mount:GET /a:false']) + + // The widget stays and takes the new source. + await wrapper.setProps({ source: fence('api', 'GET /b') }) + await flushPromises() + expect(log).toEqual(['mount:GET /a:false', 'update:GET /b']) + + await wrapper.setProps({ source: 'No blocks' }) + await flushPromises() + expect(log).toContain('destroy') + wrapper.unmount() + }) + + it('remounts a live block without update, and accepts a cleanup function', async () => { + const log: string[] = [] + const live: UidMarkdownBlockRenderer = () => ({ + mount: (_el, ctx) => { + log.push(`mount:${ctx.code}`) + return () => log.push('cleanup') + }, + }) + const wrapper = mount(UidMarkdown, { props: { source: fence('api', '1'), blockRenderers: { api: live } } }) + await flushPromises() + await wrapper.setProps({ source: fence('api', '2') }) + await flushPromises() + expect(log).toEqual(['mount:1', 'cleanup', 'mount:2']) + wrapper.unmount() + expect(log.at(-1)).toBe('cleanup') + }) + it('counts the index among code blocks', () => { const seen: number[] = [] mount(UidMarkdown, { diff --git a/src/components/Markdown/UidMarkdown.vue b/src/components/Markdown/UidMarkdown.vue index 338c7ee..73eff18 100644 --- a/src/components/Markdown/UidMarkdown.vue +++ b/src/components/Markdown/UidMarkdown.vue @@ -2,6 +2,7 @@ import './UidMarkdown.css' import { computed, nextTick, onMounted, shallowRef, watch } from 'vue' import UidCode from '../Code/UidCode.vue' +import UidMarkdownLiveBlock from './UidMarkdownLiveBlock.vue' import { useLocale } from '../../composables/useLocale.js' import { createSlugger, @@ -78,9 +79,10 @@ type Block = kind: 'custom' code: string language: string - state: 'pending' | 'done' | 'error' + state: 'pending' | 'done' | 'error' | 'live' html?: string error?: string + mount?: UidMarkdownBlockResult['mount'] } const blocks = shallowRef([]) @@ -93,8 +95,9 @@ function settle(index: number, gen: number, result: UidMarkdownBlockResult): voi const current = blocks.value[index] if (!current || current.kind !== 'custom') return const next = blocks.value.slice() - next[index] = - result.error !== undefined || result.html === undefined + next[index] = result.mount + ? { ...current, state: 'live', mount: result.mount } + : result.error !== undefined || result.html === undefined ? { ...current, state: 'error', error: result.error ?? '' } : { ...current, state: 'done', html: result.html } blocks.value = next @@ -112,6 +115,7 @@ function render(): void { const titles = { ...locale.value.markdown, ...(props.calloutTitles ?? {}) } const pending: { index: number; run: () => UidMarkdownBlockResult | Promise }[] = [] let codeIndex = 0 + const previous = blocks.value const out: Block[] = splitMarkdown(props.source).map((segment, index) => { if (segment.kind === 'text') { @@ -133,6 +137,12 @@ function render(): void { if (!renderer) { return { kind: 'code', code: segment.code, language: LANGUAGE_ALIASES[segment.language] ?? segment.language } } + // A live block that stays where it was keeps its widget: the widget + // takes the new source (typing in a preview) instead of remounting. + const before = previous[index] + if (before?.kind === 'custom' && before.state === 'live' && before.language === segment.language) { + return { ...before, code: segment.code } + } pending.push({ index, run: () => renderer(segment.code, ctx) }) return { kind: 'custom', code: segment.code, language: segment.language, state: 'pending' } }) @@ -248,6 +258,14 @@ onMounted(() => { :data-language="block.language" v-html="block.html" /> +
+/** + * A live fenced block of UidMarkdown: the renderer's widget mounted into a + * plain element. A new source goes to the widget's `update`; without one, the + * widget is mounted again. + */ +import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue' +import type { UidMarkdownBlockMountHandle, UidMarkdownBlockResult } from './blockRenderers.js' + +const props = defineProps<{ + code: string + language: string + mount: NonNullable +}>() + +const el = shallowRef(null) +let handle: UidMarkdownBlockMountHandle | null = null + +function mountWidget(): void { + if (!el.value) return + el.value.replaceChildren() + const out = props.mount(el.value, { + code: props.code, + language: props.language, + editable: false, + setCode: () => {}, + }) + handle = typeof out === 'function' ? { destroy: out } : (out ?? {}) +} + +function unmountWidget(): void { + handle?.destroy?.() + handle = null +} + +onMounted(mountWidget) +onBeforeUnmount(unmountWidget) + +watch( + () => props.code, + (code) => { + if (handle?.update) { + handle.update(code) + return + } + unmountWidget() + mountWidget() + }, +) + +watch( + () => props.mount, + () => { + unmountWidget() + mountWidget() + }, +) + + + diff --git a/src/components/Markdown/blockRenderers.ts b/src/components/Markdown/blockRenderers.ts index 5f7a434..4e924ae 100644 --- a/src/components/Markdown/blockRenderers.ts +++ b/src/components/Markdown/blockRenderers.ts @@ -13,6 +13,32 @@ export interface UidMarkdownBlockResult { html?: string /** Why the block could not be rendered; the block is then shown as code with this message. */ error?: string + /** + * A live block: the host's widget (an API console, a chart with controls) + * is mounted into the block's element instead of static markup. Return a + * handle or a cleanup function: `update` takes a changed source without + * remounting, `destroy` runs when the block goes away. + */ + mount?: ( + el: HTMLElement, + ctx: UidMarkdownBlockMountContext, + ) => UidMarkdownBlockMountHandle | (() => void) | void +} + +/** What a live block's `mount` is told. The contract is the WYSIWYG editor's too. */ +export interface UidMarkdownBlockMountContext { + /** The block's source. */ + code: string + language: string + /** Always false here: rendered markdown is read-only. */ + editable: boolean + /** Ignored here — rendered markdown has no source to write to. */ + setCode(code: string): void +} + +export interface UidMarkdownBlockMountHandle { + update?(code: string): void + destroy?(): void } /** diff --git a/src/components/Markdown/index.ts b/src/components/Markdown/index.ts index 181ca4f..5a81e74 100644 --- a/src/components/Markdown/index.ts +++ b/src/components/Markdown/index.ts @@ -23,4 +23,6 @@ export type { UidMarkdownBlockRenderer, UidMarkdownBlockRenderers, UidMarkdownBlockResult, + UidMarkdownBlockMountContext, + UidMarkdownBlockMountHandle, } from './blockRenderers.js'