');
+ expect(html).toContain('
');
+ // The outer structure wrapper lives in the locked frame, not in the body.
+ expect(html).not.toContain('g-md-hc-structure');
+ });
+
+ it('round-trips structure document html preserving block identity and css', () => {
+ const structure = {css: '', content: '
', themeIds: []};
+ const blocks = [
+ {id: 'a', css: '& { color: red; }', content: '
', themeIds: []},
+ {id: 'b', css: '& { color: blue; }', content: '
', themeIds: []},
+ ];
+
+ const html = assembleStructureHtml(structure, blocks);
+ const result = parseStructureHtml(html, structure, blocks);
+
+ expect(result.content).toBe('
');
+ expect(result.blocks).toMatchObject([
+ {id: 'a', css: '& { color: red; }', content: '
'},
+ {id: 'b', css: '& { color: blue; }', content: '
'},
+ ]);
+ });
+
+ it('edits block markup and drops blocks removed from the document', () => {
+ const blocks = [
+ {id: 'a', css: 'a-css', content: '
', themeIds: []},
+ {id: 'b', css: 'b-css', content: '
', themeIds: []},
+ ];
+ const structure = {css: '', content: '', themeIds: []};
+
+ const result = parseStructureHtml(
+ '
',
+ structure,
+ blocks,
+ );
+
+ expect(result.blocks).toHaveLength(1);
+ expect(result.blocks[0]).toMatchObject({id: 'a', css: 'a-css'});
+ expect(result.blocks[0]?.content).toBe('
');
+ });
+
+ it('treats non-block markup as structure content', () => {
+ const result = parseStructureHtml(
+ '
',
+ {css: '', content: '', themeIds: []},
+ [{id: 'a', css: '', content: '
old
', themeIds: []}],
+ );
+
+ expect(result.content).toBe('
');
+ expect(result.blocks).toHaveLength(1);
+ expect(result.blocks[0]?.content).toBe('
Body
');
+ });
+
+ it('preserves the surviving block metadata when the first wrapper is deleted', () => {
+ const blocks = [
+ {id: 'a', css: 'a-css', content: '
One
', themeIds: ['a-theme']},
+ {id: 'b', css: 'b-css', content: '
Two
', themeIds: ['b-theme']},
+ ];
+
+ const result = parseStructureHtml(
+ '
',
+ {css: '', content: '', themeIds: []},
+ blocks,
+ );
+
+ expect(result.blocks).toEqual([{...blocks[1], content: '
Edited
'}]);
+ });
+
+ it('keeps block identity and styles when wrappers are reordered', () => {
+ const blocks = [
+ {id: 'a', css: 'a-css', content: '
One
', themeIds: []},
+ {id: 'b', css: 'b-css', content: '
Two
', themeIds: []},
+ ];
+
+ const result = parseStructureHtml(
+ '
' +
+ '
',
+ {css: '', content: '', themeIds: []},
+ blocks,
+ );
+
+ expect(result.blocks).toEqual([blocks[1], blocks[0]]);
+ });
+
+ it('gives duplicated wrappers distinct block identities', () => {
+ const block = {id: 'a', css: 'a-css', content: '
One
', themeIds: []};
+ const wrapper = '
';
+
+ const result = parseStructureHtml(wrapper + wrapper, {css: '', content: '', themeIds: []}, [
+ block,
+ ]);
+
+ expect(result.blocks).toHaveLength(2);
+ expect(result.blocks[0]).toEqual(block);
+ expect(result.blocks[1]).toMatchObject({css: block.css, content: block.content});
+ expect(result.blocks[1]?.id).not.toBe(block.id);
+ });
+
+ it('preserves SVG, comments and escaped text outside block wrappers', () => {
+ const content =
+ 'Text <strong> & symbols' +
+ '
';
+
+ const result = parseStructureHtml(content, {css: '', content: '', themeIds: []}, []);
+
+ expect(result.content).toBe(content);
+ expect(result.blocks).toEqual([]);
+ });
+
+ it('assembles the combined structure stylesheet with resolved selectors', () => {
+ const structure = {css: '.g-md-hc-structure { display: grid; }', content: '', themeIds: []};
+ const blocks = [{id: 'a', css: '& { padding: 12px; }', content: '', themeIds: []}];
+
+ const css = assembleStructureCss(structure, blocks);
+
+ expect(css).toContain('.g-md-hc-structure { display: grid; }');
+ expect(css).toContain('.g-md-hc-block.g-md-hc-block-1 { padding: 12px; }');
+ });
+
+ it('keeps incomplete CSS available in the structure code editor', () => {
+ const structure = {css: '& { color:', content: '', themeIds: []};
+ const blocks = [{id: 'a', css: '& { padding:', content: '', themeIds: []}];
+
+ expect(assembleStructureCss(structure, blocks)).toBe('& { color:\n\n& { padding:');
+ });
+
+ it('keeps valid preview styles when another stylesheet is incomplete', () => {
+ const structure = {css: '& { display: grid; }', content: '', themeIds: []};
+ const blocks = [
+ {id: 'a', css: '& { color:', content: '', themeIds: []},
+ {id: 'b', css: '& { padding: 12px; }', content: '', themeIds: []},
+ ];
+ const css = buildPreviewCss({structure, blocks, scopeSelector: '.instance'});
+
+ expect(css).toContain(
+ '.instance .g-md-hc-structure.g-md-hc-structure-1 { display: grid; }',
+ );
+ expect(css).toContain('.instance .g-md-hc-block.g-md-hc-block-2 { padding: 12px; }');
+ expect(css).not.toContain('color:');
+ expect(buildPreviewCss({structure, blocks})).not.toContain('color:');
+ });
+
+ it('applies border quick style patches from the combined border dropdown', () => {
+ expect(
+ getNextQuickStyle(
+ {background: {light: '#ffffff'}},
+ {borderStyle: 'dashed', borderRadius: '12px'},
+ ),
+ ).toEqual({
+ background: {light: '#ffffff'},
+ borderStyle: 'dashed',
+ borderRadius: '12px',
+ });
+ expect(
+ getNextQuickStyle(
+ {borderStyle: 'dashed', borderRadius: '12px'},
+ {borderStyle: undefined},
+ ),
+ ).toEqual({borderRadius: '12px'});
+ });
+
+ describe('themed colors', () => {
+ it('sets a color for one theme without touching the other', () => {
+ expect(setThemedColor(undefined, 'light', '#ffffff')).toEqual({light: '#ffffff'});
+ expect(setThemedColor({light: '#ffffff'}, 'dark', '#1c1c20')).toEqual({
+ light: '#ffffff',
+ dark: '#1c1c20',
+ });
+ });
+
+ it('clears a theme and drops the entry when both sides are empty', () => {
+ expect(setThemedColor({light: '#ffffff', dark: '#1c1c20'}, 'dark', undefined)).toEqual({
+ light: '#ffffff',
+ });
+ expect(setThemedColor({light: '#ffffff'}, 'light', undefined)).toBeUndefined();
+ });
+
+ it('serializes themed colors to light/dark CSS variables', () => {
+ const html = buildYfmHtmlConstructorHtml({
+ attrs: {
+ structure: {css: '', content: '', themeIds: []},
+ blocks: [
+ {
+ id: 'block',
+ css: '',
+ content: '
Block',
+ themeIds: [],
+ quickStyle: {
+ background: {light: '#ffffff', dark: '#1c1c20'},
+ textColor: {dark: '#ffffff'},
+ },
+ },
+ ],
+ },
+ } as unknown as Node);
+
+ expect(html).toContain(
+ '--g-md-hc-background-light: #ffffff; --g-md-hc-background-dark: #1c1c20; --g-md-hc-text-color-dark: #ffffff;',
+ );
+ });
+
+ it('normalizes a legacy bare color string to both themes', () => {
+ expect(normalizeHtmlConstructorQuickStyle({background: '#ffffff'})).toEqual({
+ background: {light: '#ffffff', dark: '#ffffff'},
+ });
+ });
+
+ it('normalizes the themed object shape and ignores invalid colors', () => {
+ expect(
+ normalizeHtmlConstructorQuickStyle({
+ background: {light: '#ffffff', dark: 'not-a-color'},
+ textColor: {dark: '#2f6fe0'},
+ }),
+ ).toEqual({
+ background: {light: '#ffffff'},
+ textColor: {dark: '#2f6fe0'},
+ });
+ });
+ });
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/drag.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/drag.ts
new file mode 100644
index 000000000..0c032e0c0
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/drag.ts
@@ -0,0 +1,159 @@
+import {useEffect, useRef, useState} from 'react';
+import type {PointerEvent as ReactPointerEvent} from 'react';
+
+import type {HtmlConstructorBlock} from '../types';
+
+export const BLOCK_ID_ATTR = 'data-yfm-html-constructor-block-id';
+
+export const getBlockDragAttrs = (id: string) => ({[BLOCK_ID_ATTR]: id});
+
+export type DropPlacement = 'before' | 'after';
+
+export interface DropTarget {
+ id: string;
+ placement: DropPlacement;
+}
+
+const getDropPlacement = (rect: DOMRect, clientX: number, clientY: number): DropPlacement => {
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+ const useHorizontalAxis = Math.abs(clientX - centerX) > Math.abs(clientY - centerY);
+
+ if (useHorizontalAxis) {
+ return clientX < centerX ? 'before' : 'after';
+ }
+
+ return clientY < centerY ? 'before' : 'after';
+};
+
+const getPointerDropTarget = (
+ {clientX, clientY}: PointerEvent,
+ draggedId: string,
+): DropTarget | null => {
+ const target = document
+ .elementFromPoint(clientX, clientY)
+ ?.closest
(`[${BLOCK_ID_ATTR}]`);
+ const targetId = target?.getAttribute(BLOCK_ID_ATTR) ?? null;
+
+ if (!target || !targetId || targetId === draggedId) return null;
+
+ return {
+ id: targetId,
+ placement: getDropPlacement(target.getBoundingClientRect(), clientX, clientY),
+ };
+};
+
+const getReorderedBlocks = (
+ blocks: HtmlConstructorBlock[],
+ draggedId: string,
+ targetId: string,
+ placement: DropPlacement,
+) => {
+ if (draggedId === targetId) return null;
+
+ const dragged = blocks.find((block) => block.id === draggedId);
+ if (!dragged) return null;
+
+ const withoutDragged = blocks.filter((block) => block.id !== draggedId);
+ const targetIndex = withoutDragged.findIndex((block) => block.id === targetId);
+ if (targetIndex === -1) return null;
+
+ const insertIndex = placement === 'before' ? targetIndex : targetIndex + 1;
+ const next = [
+ ...withoutDragged.slice(0, insertIndex),
+ dragged,
+ ...withoutDragged.slice(insertIndex),
+ ];
+
+ // Dropping right next to the block's current slot (e.g. "before the next
+ // block") keeps the same order. Report it as no move so the UI doesn't show a
+ // drop indicator that wouldn't actually reorder anything.
+ const isSameOrder = next.every((block, index) => block.id === blocks[index]?.id);
+ return isSameOrder ? null : next;
+};
+
+export const useHtmlBlockDrag = ({
+ blocks,
+ onMove,
+}: {
+ blocks: HtmlConstructorBlock[];
+ onMove: (blocks: HtmlConstructorBlock[]) => void;
+}) => {
+ const [draggedBlockId, setDraggedBlockId] = useState(null);
+ const [dropTarget, setDropTarget] = useState(null);
+ const dragStateRef = useRef<{
+ draggedId: string;
+ target: DropTarget | null;
+ reordered: HtmlConstructorBlock[] | null;
+ } | null>(null);
+ const cleanupDragListenersRef = useRef<(() => void) | null>(null);
+
+ const clearDragging = () => {
+ setDraggedBlockId(null);
+ setDropTarget(null);
+ dragStateRef.current = null;
+ };
+
+ const stopListening = () => {
+ cleanupDragListenersRef.current?.();
+ cleanupDragListenersRef.current = null;
+ };
+
+ const beginBlockDrag = (blockId: string, event: ReactPointerEvent) => {
+ if (event.button !== 0) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+
+ stopListening();
+
+ dragStateRef.current = {draggedId: blockId, target: null, reordered: null};
+ setDraggedBlockId(blockId);
+ setDropTarget(null);
+
+ const handlePointerMove = (pointerEvent: PointerEvent) => {
+ const candidate = getPointerDropTarget(pointerEvent, blockId);
+ const reordered = candidate
+ ? getReorderedBlocks(blocks, blockId, candidate.id, candidate.placement)
+ : null;
+ // Only surface a drop target when it would actually change the order,
+ // so the indicator never appears for a drop that does nothing.
+ const nextTarget = reordered ? candidate : null;
+
+ if (dragStateRef.current) {
+ dragStateRef.current.target = nextTarget;
+ dragStateRef.current.reordered = reordered;
+ }
+ setDropTarget(nextTarget);
+ };
+
+ const handlePointerUp = () => {
+ const dragState = dragStateRef.current;
+ if (dragState?.reordered) {
+ onMove(dragState.reordered);
+ }
+ stopListening();
+ clearDragging();
+ };
+
+ const handlePointerCancel = () => {
+ stopListening();
+ clearDragging();
+ };
+
+ cleanupDragListenersRef.current = () => {
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerup', handlePointerUp);
+ window.removeEventListener('pointercancel', handlePointerCancel);
+ };
+
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerup', handlePointerUp);
+ window.addEventListener('pointercancel', handlePointerCancel);
+ };
+
+ useEffect(() => () => stopListening(), []);
+
+ return {beginBlockDrag, draggedBlockId, dropTarget};
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/groupTemplates.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/groupTemplates.test.ts
new file mode 100644
index 000000000..ad7d1f5a7
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/groupTemplates.test.ts
@@ -0,0 +1,79 @@
+import {parseTemplates} from '../templates';
+
+import {buildBlockMenuGroups, buildStructureMenuGroups} from './groupTemplates';
+
+const constructorTemplates = () =>
+ parseTemplates(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+describe('HTML Constructor template menus', () => {
+ it('groups one structure with four structure themes by family', () => {
+ expect(buildStructureMenuGroups(constructorTemplates())).toEqual([
+ {
+ familyId: 'family-a',
+ title: 'Family A',
+ items: [
+ {
+ structure: expect.objectContaining({id: 'structure-a'}),
+ themes: [
+ expect.objectContaining({id: 'structure-theme-1'}),
+ expect.objectContaining({id: 'structure-theme-2'}),
+ expect.objectContaining({id: 'structure-theme-3'}),
+ expect.objectContaining({id: 'structure-theme-4'}),
+ ],
+ },
+ ],
+ },
+ ]);
+ });
+
+ it('groups six base blocks by family for the active structure', () => {
+ const groups = buildBlockMenuGroups(constructorTemplates(), 'structure-a');
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0]).toMatchObject({familyId: 'family-a', title: 'Family A'});
+ expect(groups[0]?.items.map((item) => item.block.id)).toEqual([
+ 'block-1',
+ 'block-2',
+ 'block-3',
+ 'block-4',
+ 'block-5',
+ 'block-6',
+ ]);
+ });
+
+ it('attaches block themes and block states to their base block', () => {
+ const groups = buildBlockMenuGroups(constructorTemplates(), 'structure-a');
+ const block2 = groups[0]?.items.find((item) => item.block.id === 'block-2');
+ const block3 = groups[0]?.items.find((item) => item.block.id === 'block-3');
+
+ expect(block2?.themesByBlockId['block-2']).toEqual([
+ expect.objectContaining({id: 'block-2-theme'}),
+ ]);
+ expect(block3?.states.map((state) => state.id)).toEqual(['block-3', 'block-7']);
+ });
+
+ it('filters by family, block, state or theme title', () => {
+ expect(
+ buildBlockMenuGroups(constructorTemplates(), 'structure-a', 'Block 7')[0]?.items,
+ ).toEqual([expect.objectContaining({block: expect.objectContaining({id: 'block-3'})})]);
+ expect(
+ buildStructureMenuGroups(constructorTemplates(), 'Structure theme 3')[0]?.items[0]
+ ?.themes,
+ ).toEqual(expect.arrayContaining([expect.objectContaining({id: 'structure-theme-3'})]));
+ });
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/groupTemplates.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/groupTemplates.ts
new file mode 100644
index 000000000..88548ba13
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/groupTemplates.ts
@@ -0,0 +1,151 @@
+import type {
+ HtmlConstructorBlockTemplate,
+ HtmlConstructorFamilyTemplate,
+ HtmlConstructorStructureTemplate,
+ HtmlConstructorTemplate,
+ HtmlConstructorThemeTemplate,
+} from '../types';
+
+export type StructureMenuItem = {
+ structure: HtmlConstructorStructureTemplate;
+ themes: HtmlConstructorThemeTemplate[];
+};
+
+export type BlockMenuItem = {
+ block: HtmlConstructorBlockTemplate;
+ states: HtmlConstructorBlockTemplate[];
+ themesByBlockId: Record;
+};
+
+export type TemplateMenuGroup = {
+ familyId?: string;
+ title: string;
+ items: TItem[];
+};
+
+const DEFAULT_FAMILY_TITLE = 'Other';
+
+const getTitle = (template: {id: string; title?: string}) => template.title?.trim() || template.id;
+
+const matches = (value: string, query: string) => value.toLowerCase().includes(query);
+
+const byFamily = (templates: HtmlConstructorTemplate[]) =>
+ new Map(
+ templates.flatMap((template): [string, HtmlConstructorFamilyTemplate][] =>
+ template.type === 'family' ? [[template.id, template]] : [],
+ ),
+ );
+
+const getFamilyTitle = (
+ families: Map,
+ familyId: string | undefined,
+) => (familyId ? getTitle(families.get(familyId) ?? {id: familyId}) : DEFAULT_FAMILY_TITLE);
+
+const addToFamilyGroup = (
+ groups: TemplateMenuGroup[],
+ familyId: string | undefined,
+ title: string,
+ item: TItem,
+) => {
+ let group = groups.find((entry) => entry.familyId === familyId);
+ if (!group) {
+ group = {familyId, title, items: []};
+ groups.push(group);
+ }
+ group.items.push(item);
+};
+
+const getThemesForStructure = (themes: HtmlConstructorThemeTemplate[], structureId: string) =>
+ themes.filter((theme) => theme.structure === structureId && !theme.block);
+
+const isCompatibleWithStructure = (
+ template: HtmlConstructorBlockTemplate | HtmlConstructorThemeTemplate,
+ activeStructureId: string | undefined,
+) => !activeStructureId || !template.structure || template.structure === activeStructureId;
+
+export const buildStructureMenuGroups = (
+ templates: HtmlConstructorTemplate[],
+ filter = '',
+): TemplateMenuGroup[] => {
+ const query = filter.trim().toLowerCase();
+ const families = byFamily(templates);
+ const themes = templates.filter(
+ (template): template is HtmlConstructorThemeTemplate => template.type === 'theme',
+ );
+ const groups: TemplateMenuGroup[] = [];
+
+ for (const structure of templates.filter(
+ (template): template is HtmlConstructorStructureTemplate => template.type === 'structure',
+ )) {
+ const item = {structure, themes: getThemesForStructure(themes, structure.id)};
+ const familyTitle = getFamilyTitle(families, structure.family);
+
+ if (
+ query &&
+ !matches(familyTitle, query) &&
+ !matches(getTitle(structure), query) &&
+ !item.themes.some((theme) => matches(getTitle(theme), query))
+ ) {
+ continue;
+ }
+
+ addToFamilyGroup(groups, structure.family, familyTitle, item);
+ }
+
+ return groups;
+};
+
+export const buildBlockMenuGroups = (
+ templates: HtmlConstructorTemplate[],
+ activeStructureId?: string,
+ filter = '',
+): TemplateMenuGroup[] => {
+ const query = filter.trim().toLowerCase();
+ const families = byFamily(templates);
+ const blocks = templates.filter(
+ (template): template is HtmlConstructorBlockTemplate => template.type === 'block',
+ );
+ const themes = templates.filter(
+ (template): template is HtmlConstructorThemeTemplate => template.type === 'theme',
+ );
+ const groups: TemplateMenuGroup[] = [];
+
+ for (const block of blocks) {
+ if (block.block || !isCompatibleWithStructure(block, activeStructureId)) continue;
+
+ const states = [
+ block,
+ ...blocks.filter(
+ (state) =>
+ state.block === block.id && isCompatibleWithStructure(state, activeStructureId),
+ ),
+ ];
+ const themesByBlockId = Object.fromEntries(
+ states.map((state) => [
+ state.id,
+ themes.filter(
+ (theme) =>
+ theme.block === state.id &&
+ isCompatibleWithStructure(theme, activeStructureId),
+ ),
+ ]),
+ );
+ const item = {block, states, themesByBlockId};
+ const familyTitle = getFamilyTitle(families, block.family);
+
+ if (
+ query &&
+ !matches(familyTitle, query) &&
+ !states.some((state) => matches(getTitle(state), query)) &&
+ !Object.values(themesByBlockId)
+ .flat()
+ .some((theme) => matches(getTitle(theme), query))
+ ) {
+ continue;
+ }
+
+ addToFamilyGroup(groups, block.family, familyTitle, item);
+ }
+
+ return groups;
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/iconLibrary.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/iconLibrary.ts
new file mode 100644
index 000000000..946dfbe6a
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/iconLibrary.ts
@@ -0,0 +1,97 @@
+import {
+ Bell,
+ Book,
+ Briefcase,
+ BucketPaint,
+ Calendar,
+ Camera,
+ ChartColumn,
+ CircleInfo,
+ Clock,
+ Cloud,
+ Code,
+ Comment,
+ Comments,
+ CreditCard,
+ Cubes3,
+ Envelope,
+ File,
+ Flag,
+ Folder,
+ Gear,
+ Gift,
+ Globe,
+ GraduationCap,
+ Headphones,
+ Heart,
+ House,
+ Key,
+ LayoutCells,
+ Lock,
+ Magnifier,
+ Megaphone,
+ Person,
+ PersonPlus,
+ Picture,
+ Rocket,
+ ShieldCheck,
+ Star,
+ Tag,
+ Target,
+ ThumbsUp,
+ Thunderbolt,
+ Wrench,
+} from '@gravity-ui/icons';
+import type {IconData} from '@gravity-ui/uikit';
+
+export interface ConstructorIcon {
+ id: string;
+ data: IconData;
+}
+
+// A curated subset of @gravity-ui/icons offered when replacing an inline SVG.
+// The `id` doubles as a stable key and a fallback tooltip label.
+export const HTML_CONSTRUCTOR_ICONS: ConstructorIcon[] = [
+ {id: 'comment', data: Comment},
+ {id: 'comments', data: Comments},
+ {id: 'shield-check', data: ShieldCheck},
+ {id: 'thunderbolt', data: Thunderbolt},
+ {id: 'chart-column', data: ChartColumn},
+ {id: 'book', data: Book},
+ {id: 'globe', data: Globe},
+ {id: 'lock', data: Lock},
+ {id: 'key', data: Key},
+ {id: 'cubes', data: Cubes3},
+ {id: 'file', data: File},
+ {id: 'folder', data: Folder},
+ {id: 'person', data: Person},
+ {id: 'person-plus', data: PersonPlus},
+ {id: 'headphones', data: Headphones},
+ {id: 'layout-cells', data: LayoutCells},
+ {id: 'star', data: Star},
+ {id: 'heart', data: Heart},
+ {id: 'thumbs-up', data: ThumbsUp},
+ {id: 'bell', data: Bell},
+ {id: 'gear', data: Gear},
+ {id: 'house', data: House},
+ {id: 'magnifier', data: Magnifier},
+ {id: 'calendar', data: Calendar},
+ {id: 'clock', data: Clock},
+ {id: 'envelope', data: Envelope},
+ {id: 'cloud', data: Cloud},
+ {id: 'rocket', data: Rocket},
+ {id: 'flag', data: Flag},
+ {id: 'tag', data: Tag},
+ {id: 'target', data: Target},
+ {id: 'gift', data: Gift},
+ {id: 'graduation-cap', data: GraduationCap},
+ {id: 'megaphone', data: Megaphone},
+ {id: 'briefcase', data: Briefcase},
+ {id: 'credit-card', data: CreditCard},
+ {id: 'circle-info', data: CircleInfo},
+ {id: 'wrench', data: Wrench},
+ {id: 'code', data: Code},
+ {id: 'picture', data: Picture},
+ {id: 'camera', data: Camera},
+ {id: 'palette', data: BucketPaint},
+];
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/index.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/index.ts
new file mode 100644
index 000000000..7df55dd62
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/index.ts
@@ -0,0 +1 @@
+export {WYfmHtmlConstructorNodeView} from './NodeView';
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.test.ts
new file mode 100644
index 000000000..2d0c139be
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.test.ts
@@ -0,0 +1,235 @@
+import {
+ applyElementAttributes,
+ editElementHtml,
+ getEditableTextNode,
+ getElementAttributes,
+ getFirstEditableElement,
+ getTextNodes,
+ isIconSizedSvg,
+ setElementText,
+ setImageSrc,
+ setLinkValues,
+ setSvgIcon,
+ setTextNodeValue,
+} from './textEditing';
+
+describe('YfmHtmlConstructor inline editing', () => {
+ it('lists non-empty text nodes in document order', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Title
\n \nBody bold
';
+
+ expect(getTextNodes(root).map((node) => node.nodeValue)).toEqual([
+ 'Title',
+ 'Body ',
+ 'bold',
+ ]);
+ });
+
+ it('updates a text node value in place', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Hello
';
+
+ const textNode = root.querySelector('p')?.firstChild as Text;
+ setTextNodeValue(textNode, 'Updated');
+
+ expect(root.innerHTML).toBe('Updated
');
+ });
+
+ it('updates link text and href while preserving other attributes', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Before';
+
+ const link = root.querySelector('a') as HTMLAnchorElement;
+ setLinkValues(link, 'After', 'after.html');
+
+ expect(root.innerHTML).toBe('After');
+ });
+
+ it('updates image src while preserving other attributes', () => {
+ const root = document.createElement('div');
+ root.innerHTML = '
';
+
+ const image = root.querySelector('img') as HTMLImageElement;
+ setImageSrc(image, 'after.png');
+
+ expect(root.innerHTML).toBe('
');
+ });
+
+ describe('universal element editing', () => {
+ it('lists every attribute as ordered name/value pairs', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Go';
+
+ const link = root.querySelector('a') as HTMLAnchorElement;
+
+ expect(getElementAttributes(link)).toEqual([
+ {name: 'href', value: 'x.html'},
+ {name: 'title', value: 'Tip'},
+ {name: 'class', value: 'cta'},
+ ]);
+ });
+
+ it('reconciles attributes: updates, adds and removes', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Go';
+
+ const link = root.querySelector('a') as HTMLAnchorElement;
+ applyElementAttributes(link, [
+ {name: 'href', value: 'new.html'},
+ {name: 'title', value: 'Added'},
+ ]);
+
+ expect(root.innerHTML).toBe('Go');
+ });
+
+ it('ignores attribute entries with an empty name', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Hi';
+
+ const span = root.querySelector('span') as HTMLSpanElement;
+ applyElementAttributes(span, [
+ {name: 'class', value: 'x'},
+ {name: '', value: 'orphan'},
+ ]);
+
+ expect(root.innerHTML).toBe('Hi');
+ });
+
+ it('rejects invalid attribute names without changing existing attributes', () => {
+ const element = document.createElement('a');
+ element.setAttribute('href', 'before.html');
+ element.setAttribute('class', 'action');
+
+ expect(
+ applyElementAttributes(element, [
+ {name: 'title', value: 'Added'},
+ {name: 'invalid name', value: 'value'},
+ ]),
+ ).toBe(false);
+ expect(element.outerHTML).toBe('');
+ });
+
+ it('stages text and attribute edits without replacing the live popup anchor', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Hello world
';
+ const element = root.querySelector('p')!;
+
+ expect(
+ editElementHtml(root, element, {
+ text: 'Updated ',
+ attributes: [{name: 'class', value: 'after'}],
+ }),
+ ).toBe('Updated world
');
+ expect(root.innerHTML).toBe('Hello world
');
+ expect(root.firstElementChild).toBe(element);
+ });
+
+ it('rejects the whole edit when an attribute name is invalid', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Original
';
+
+ expect(
+ editElementHtml(root, root.firstElementChild!, {
+ text: 'Changed',
+ attributes: [{name: 'bad name', value: 'value'}],
+ }),
+ ).toBeNull();
+ expect(root.innerHTML).toBe('Original
');
+ });
+
+ it('rejects edits to a replaced target', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Before
';
+ const target = root.firstElementChild!;
+ root.innerHTML = 'After
';
+
+ expect(editElementHtml(root, target, {text: 'Stale edit', attributes: []})).toBeNull();
+ expect(root.innerHTML).toBe('After
');
+ });
+
+ it('finds keyboard targets inside wrappers and skips hidden markup', () => {
+ const root = document.createElement('div');
+ root.innerHTML =
+ 'Hidden
';
+
+ expect(getFirstEditableElement(root)).toBe(root.querySelector('h2'));
+ });
+
+ it('allows keyboard editing when a block contains only media', () => {
+ const root = document.createElement('div');
+ root.innerHTML = '
';
+
+ expect(getFirstEditableElement(root)).toBe(root.querySelector('img'));
+ });
+
+ it('detects an editable direct text node', () => {
+ const root = document.createElement('div');
+ root.innerHTML = 'Click here';
+
+ const link = root.querySelector('a') as HTMLAnchorElement;
+ const {node, canEdit} = getEditableTextNode(link);
+
+ expect(canEdit).toBe(true);
+ expect(node?.nodeValue).toBe('Click ');
+ });
+
+ it('allows authoring text in an empty leaf element', () => {
+ const root = document.createElement('div');
+ root.innerHTML = '';
+
+ const button = root.querySelector('button') as HTMLButtonElement;
+ const {node, canEdit} = getEditableTextNode(button);
+
+ expect(canEdit).toBe(true);
+ expect(node).toBeNull();
+
+ setElementText(button, node, 'Press me');
+ expect(root.innerHTML).toBe('');
+ });
+
+ it('reports no editable text for media and wrappers', () => {
+ const root = document.createElement('div');
+ root.innerHTML =
+ '
child
';
+
+ const [img, wrapper, svg] = [
+ root.querySelector('img') as HTMLImageElement,
+ root.querySelector('div') as HTMLDivElement,
+ root.querySelector('svg') as unknown as SVGSVGElement,
+ ];
+
+ expect(getEditableTextNode(img).canEdit).toBe(false);
+ expect(getEditableTextNode(wrapper).canEdit).toBe(false);
+ expect(getEditableTextNode(svg).canEdit).toBe(false);
+ });
+
+ it('treats small SVGs as icons and large ones as images', () => {
+ const root = document.createElement('div');
+ root.innerHTML =
+ '';
+
+ const [small, large] = Array.from(
+ root.querySelectorAll('svg'),
+ ) as unknown as SVGSVGElement[];
+
+ expect(isIconSizedSvg(small)).toBe(true);
+ expect(isIconSizedSvg(large)).toBe(false);
+ });
+
+ it('replaces an inline svg glyph and returns the new element', () => {
+ const root = document.createElement('div');
+ root.innerHTML =
+ '';
+
+ const svg = root.querySelector('svg') as unknown as SVGSVGElement;
+ const next = setSvgIcon(svg, '');
+
+ expect(next).not.toBeNull();
+ expect(root.querySelector('svg')?.getAttribute('class')).toBe('icon');
+ expect(root.querySelector('svg')?.getAttribute('width')).toBe('20');
+ expect(root.querySelector('svg')?.getAttribute('role')).toBe('img');
+ expect(root.querySelector('svg')?.getAttribute('aria-label')).toBe('Calendar');
+ expect(root.querySelector('rect')).not.toBeNull();
+ });
+ });
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.ts
new file mode 100644
index 000000000..455178a73
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.ts
@@ -0,0 +1,212 @@
+export const getTextNodes = (root: HTMLElement) => {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode(node) {
+ return node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
+ },
+ });
+ const textNodes: Text[] = [];
+
+ while (walker.nextNode()) {
+ textNodes.push(walker.currentNode as Text);
+ }
+
+ return textNodes;
+};
+
+/** Immediate (non-descendant) child text nodes that contain non-whitespace text. */
+export const getDirectTextNodes = (element: Element): Text[] => {
+ const textNodes: Text[] = [];
+
+ for (const child of Array.from(element.childNodes)) {
+ if (child.nodeType === window.Node.TEXT_NODE && child.nodeValue?.trim()) {
+ textNodes.push(child as Text);
+ }
+ }
+
+ return textNodes;
+};
+
+export const setTextNodeValue = (textNode: Text, value: string): void => {
+ textNode.replaceData(0, textNode.length, value);
+};
+
+export const setLinkValues = (link: HTMLAnchorElement, text: string, href: string): void => {
+ link.replaceChildren(text);
+ link.setAttribute('href', href);
+};
+
+export const setImageSrc = (image: HTMLImageElement, src: string): void => {
+ image.setAttribute('src', src);
+};
+
+/** Sets `value` on `textNode` when present, otherwise replaces the element's text. */
+export const setElementText = (element: Element, textNode: Text | null, value: string): void => {
+ if (textNode) {
+ setTextNodeValue(textNode, value);
+ return;
+ }
+ element.replaceChildren(value);
+};
+
+export interface EditableAttribute {
+ name: string;
+ value: string;
+}
+
+/** All attributes of an element as an ordered name/value list. */
+export const getElementAttributes = (element: Element): EditableAttribute[] =>
+ element.getAttributeNames().map((name) => ({name, value: element.getAttribute(name) ?? ''}));
+
+/** Reject invalid attribute names before changing the element. */
+export const applyElementAttributes = (
+ element: Element,
+ attributes: EditableAttribute[],
+): boolean => {
+ const next = element.cloneNode(false) as Element;
+ for (const name of next.getAttributeNames()) next.removeAttribute(name);
+
+ try {
+ for (const {name, value} of attributes) {
+ if (name.trim()) next.setAttribute(name.trim(), value);
+ }
+ } catch {
+ return false;
+ }
+
+ for (const name of element.getAttributeNames()) {
+ if (!next.hasAttribute(name)) element.removeAttribute(name);
+ }
+
+ for (const name of next.getAttributeNames()) {
+ element.setAttribute(name, next.getAttribute(name)!);
+ }
+
+ return true;
+};
+
+export interface ElementEdit {
+ text?: string;
+ attributes: EditableAttribute[];
+ icon?: string;
+}
+
+/** Serialize a staged edit without mutating the preview or its popup anchor. */
+export const editElementHtml = (
+ root: HTMLElement,
+ target: Element,
+ edit: ElementEdit,
+): string | null => {
+ const index = Array.from(root.querySelectorAll('*')).indexOf(target);
+ if (index < 0) return null;
+
+ const draft = root.cloneNode(true) as HTMLElement;
+ let element = draft.querySelectorAll('*')[index];
+ if (edit.icon && element instanceof SVGSVGElement) {
+ const icon = setSvgIcon(element, edit.icon);
+ if (!icon) return null;
+ element = icon;
+ } else {
+ if (!applyElementAttributes(element, edit.attributes)) return null;
+ if (edit.text !== undefined) {
+ const {node, canEdit} = getEditableTextNode(element);
+ if (canEdit) setElementText(element, node, edit.text);
+ }
+ }
+
+ return draft.innerHTML;
+};
+
+// Elements that hold no editable text of their own (replaced/void/media/controls).
+const TEXTLESS_TAGS = new Set([
+ 'IMG',
+ 'VIDEO',
+ 'AUDIO',
+ 'SVG',
+ 'CANVAS',
+ 'IFRAME',
+ 'EMBED',
+ 'OBJECT',
+ 'SOURCE',
+ 'TRACK',
+ 'PICTURE',
+ 'INPUT',
+ 'BR',
+ 'HR',
+ 'SELECT',
+ 'TEXTAREA',
+]);
+
+/**
+ * Decides whether an element exposes an editable text value and, if so, which
+ * text node to mutate:
+ * - a direct text node -> edit it in place;
+ * - an empty leaf element (no children, not a replaced/void tag) -> allow
+ * authoring text (a node is created on commit);
+ * - anything else (media, or a wrapper of other elements) -> no editable text.
+ */
+export const getEditableTextNode = (element: Element): {node: Text | null; canEdit: boolean} => {
+ const direct = getDirectTextNodes(element);
+ if (direct.length) return {node: direct[0], canEdit: true};
+
+ if (TEXTLESS_TAGS.has(element.tagName.toUpperCase())) return {node: null, canEdit: false};
+
+ if (element.children.length === 0) return {node: null, canEdit: true};
+
+ return {node: null, canEdit: false};
+};
+
+export const getFirstEditableElement = (root: HTMLElement): Element | null => {
+ const elements = Array.from(root.querySelectorAll('*')).filter(
+ (element) => !element.closest('script, style, template, noscript, [hidden]'),
+ );
+ return elements.find((element) => getEditableTextNode(element).canEdit) ?? elements[0] ?? null;
+};
+
+/** Inline SVGs at or below this rendered size are treated as icons (glyph picker). */
+export const ICON_SVG_MAX_SIZE = 50;
+
+/**
+ * An inline SVG is an "icon" (and thus offers the glyph picker) when it renders
+ * no larger than {@link ICON_SVG_MAX_SIZE} in both dimensions. Larger SVGs are
+ * treated as illustrations/images and only expose attribute editing. Falls back
+ * to the `width`/`height` attributes when layout metrics are unavailable (e.g.
+ * in tests), and defaults to "icon" when the size is unknown.
+ */
+export const isIconSizedSvg = (svg: SVGSVGElement, maxSize = ICON_SVG_MAX_SIZE): boolean => {
+ const rect = svg.getBoundingClientRect();
+ const width = rect.width || parseFloat(svg.getAttribute('width') ?? '') || 0;
+ const height = rect.height || parseFloat(svg.getAttribute('height') ?? '') || 0;
+
+ if (!width && !height) return true;
+
+ return width <= maxSize && height <= maxSize;
+};
+
+const PRESERVED_SVG_ATTRS = ['class', 'style', 'width', 'height'] as const;
+
+/** Replaces an inline SVG glyph in place, returning the new element (or null). */
+export const setSvgIcon = (svg: SVGSVGElement, iconSvgMarkup: string): SVGSVGElement | null => {
+ const holder = document.createElement('div');
+ holder.innerHTML = iconSvgMarkup.trim();
+ const next = holder.querySelector('svg');
+ if (!next) return null;
+
+ for (const name of PRESERVED_SVG_ATTRS) {
+ const value = svg.getAttribute(name);
+ if (value === null) {
+ next.removeAttribute(name);
+ } else {
+ next.setAttribute(name, value);
+ }
+ }
+
+ for (const name of svg.getAttributeNames()) {
+ if (name === 'id' || name === 'role' || name === 'focusable' || name.startsWith('aria-')) {
+ next.setAttribute(name, svg.getAttribute(name)!);
+ }
+ }
+
+ svg.replaceWith(next);
+
+ return next;
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/QuickStyleControls.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/QuickStyleControls.tsx
new file mode 100644
index 000000000..9ab10391f
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/QuickStyleControls.tsx
@@ -0,0 +1,252 @@
+import type {CSSProperties, FC, ReactNode} from 'react';
+
+import {BucketPaint, ChevronDown, Font} from '@gravity-ui/icons';
+import {Button, Icon, Popup} from '@gravity-ui/uikit';
+
+import {i18n} from 'src/i18n/yfm-html-constructor';
+import {useElementState} from 'src/react-utils/hooks';
+
+import {
+ HTML_CONSTRUCTOR_BACKGROUND_COLORS,
+ HTML_CONSTRUCTOR_BORDER_RADIUS,
+ HTML_CONSTRUCTOR_BORDER_STYLES,
+ HTML_CONSTRUCTOR_COLOR_NAME_KEYS,
+ HTML_CONSTRUCTOR_TEXT_COLORS,
+ setThemedColor,
+} from '../../quickStyle';
+import type {
+ HtmlConstructorBorderStyle,
+ HtmlConstructorColorTheme,
+ HtmlConstructorQuickStyle,
+ HtmlConstructorThemedColor,
+} from '../../types';
+import {cnYfmHtmlConstructor as b, STOP_EVENT_CLASSNAME as stop} from '../const';
+
+type ControlProps = {
+ disabled: boolean;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+const StyleControl: FC = ({
+ disabled,
+ open,
+ onOpenChange,
+ label,
+ preview,
+ children,
+}) => {
+ const [anchor, setAnchor] = useElementState();
+
+ return (
+
+
+
+ {children}
+
+
+ );
+};
+
+const getColorName = (color: string) => {
+ const key = HTML_CONSTRUCTOR_COLOR_NAME_KEYS[color.toLowerCase()];
+ return key ? i18n(key) : color;
+};
+
+export const ColorControl: FC<
+ ControlProps & {
+ kind: 'background' | 'textColor';
+ value?: HtmlConstructorThemedColor;
+ activeTheme: HtmlConstructorColorTheme;
+ paletteTheme: HtmlConstructorColorTheme;
+ onPaletteThemeChange: (theme: HtmlConstructorColorTheme) => void;
+ onChange: (value: HtmlConstructorThemedColor | undefined) => void;
+ }
+> = ({kind, value, activeTheme, paletteTheme, onPaletteThemeChange, onChange, ...control}) => {
+ const isBackground = kind === 'background';
+ const activeColor = value?.[activeTheme];
+ const selectedColor = value?.[paletteTheme] ?? '';
+ const colors = isBackground ? HTML_CONSTRUCTOR_BACKGROUND_COLORS : HTML_CONSTRUCTOR_TEXT_COLORS;
+ const swatchClass = isBackground ? 'floating-swatch' : 'floating-text-swatch';
+ // Keep the palette open when switching between light and dark colors.
+ const selectColor = (color?: string) => onChange(setThemedColor(value, paletteTheme, color));
+
+ const preview = isBackground ? (
+ activeColor ? (
+
+ ) : (
+
+ )
+ ) : (
+
+
+
+ );
+
+ return (
+
+
+
+ {(['light', 'dark'] as const).map((theme) => (
+
+ ))}
+
+
+ {colors.map((color) => (
+
+ ))}
+
+ {isBackground && (
+
+ )}
+
+
+ );
+};
+
+const getRadiusLabel = (value: string) => {
+ if (!value) return i18n('round_default');
+ if (value === '0') return i18n('round_none');
+ if (value === '999px') return i18n('round_pill');
+ return value;
+};
+
+const getBorderLabel = (value: HtmlConstructorBorderStyle | undefined) =>
+ i18n(value ? `border_${value}` : 'border_default');
+
+export const BorderControl: FC<
+ ControlProps & {
+ value?: HtmlConstructorQuickStyle;
+ hasBorder: boolean;
+ hasRound: boolean;
+ onChange: (patch: Partial) => void;
+ }
+> = ({value, hasBorder, hasRound, onChange, ...control}) => (
+
+ }
+ >
+
+ {hasBorder && (
+
+
{i18n('border')}
+ {[undefined, ...HTML_CONSTRUCTOR_BORDER_STYLES].map((borderStyle) => (
+
+ ))}
+
+ )}
+ {hasRound && (
+
+
+ {i18n('rounding')}
+
+ {HTML_CONSTRUCTOR_BORDER_RADIUS.map((radius) => (
+
+ ))}
+
+ )}
+
+
+);
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/ToolbarButton.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/ToolbarButton.tsx
new file mode 100644
index 000000000..d84fc6087
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/ToolbarButton.tsx
@@ -0,0 +1,27 @@
+import type {FC} from 'react';
+
+import {Button, type ButtonButtonProps, Icon, type IconData} from '@gravity-ui/uikit';
+
+import {STOP_EVENT_CLASSNAME} from '../const';
+
+export type ToolbarButtonProps = Pick<
+ ButtonButtonProps,
+ 'onClick' | 'selected' | 'disabled' | 'view'
+> & {
+ icon: IconData;
+ label: string;
+ iconSize?: number;
+};
+
+export const ToolbarButton: FC = ({icon, label, iconSize = 14, ...props}) => (
+
+);
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/toolbarLayout.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/toolbarLayout.test.ts
new file mode 100644
index 000000000..3ccfdcfc8
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/toolbarLayout.test.ts
@@ -0,0 +1,38 @@
+import {type ToolbarAction, splitToolbarActions} from './useToolbarLayout';
+
+const actions: ToolbarAction[] = [
+ {id: 'addBlock', group: 'primary', node: null},
+ {id: 'raw', group: 'primary', node: null},
+ {id: 'background', group: 'style', node: null},
+ {id: 'textColor', group: 'style', node: null},
+ {id: 'border', group: 'style', node: null},
+ {id: 'duplicate', group: 'actions', node: null},
+ {id: 'delete', group: 'actions', node: null},
+];
+const widths = Object.fromEntries(actions.map(({id}) => [id, 32]));
+
+it('keeps actions visible until their widths are measured', () => {
+ expect(splitToolbarActions(actions, {}, 100)).toEqual({visible: actions, hidden: []});
+});
+
+it('keeps the full toolbar when it fits exactly', () => {
+ expect(splitToolbarActions(actions, widths, 282)).toEqual({visible: actions, hidden: []});
+});
+
+it('reserves space for the overflow button and its group separator', () => {
+ const fitting = splitToolbarActions(actions, widths, 246);
+ const narrower = splitToolbarActions(actions, widths, 245);
+ expect(fitting.hidden.map(({id}) => id)).toEqual(['duplicate', 'delete']);
+ expect(narrower.hidden.map(({id}) => id)).toEqual(['border', 'duplicate', 'delete']);
+ expect(narrower.visible.map(({id}) => id)).toEqual([
+ 'addBlock',
+ 'raw',
+ 'background',
+ 'textColor',
+ ]);
+});
+
+it('moves all actions into overflow on a very narrow block and restores them on resize', () => {
+ expect(splitToolbarActions(actions, widths, 60)).toEqual({visible: [], hidden: actions});
+ expect(splitToolbarActions(actions, widths, 400)).toEqual({visible: actions, hidden: []});
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/useToolbarLayout.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/useToolbarLayout.ts
new file mode 100644
index 000000000..812edb056
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/useToolbarLayout.ts
@@ -0,0 +1,118 @@
+import {useLayoutEffect, useRef, useState} from 'react';
+import type {ReactNode} from 'react';
+
+const ITEM_GAP = 4;
+const GROUP_GAP = 21;
+const MORE_BUTTON_WIDTH = 32;
+const HORIZONTAL_PADDING = 16;
+const VIEWPORT_WIDTH_RATIO = 0.9;
+const GROUP_ORDER = ['primary', 'style', 'actions'] as const;
+const HIDE_ORDER = ['duplicate', 'delete', 'border', 'textColor', 'background', 'raw'];
+
+export type ToolbarAction = {
+ id: string;
+ group: (typeof GROUP_ORDER)[number];
+ node: ReactNode;
+};
+
+export const groupToolbarActions = (actions: ToolbarAction[]) =>
+ GROUP_ORDER.map((group) => ({
+ group,
+ actions: actions.filter((action) => action.group === group),
+ })).filter(({actions: items}) => items.length > 0);
+
+const getToolbarWidth = (actions: ToolbarAction[], widths: Record) => {
+ const groups = new Set(actions.map((action) => action.group)).size;
+ return (
+ actions.reduce((sum, action) => sum + widths[action.id], 0) +
+ (actions.length - groups) * ITEM_GAP +
+ Math.max(0, groups - 1) * GROUP_GAP
+ );
+};
+
+export const splitToolbarActions = (
+ actions: ToolbarAction[],
+ widths: Record,
+ availableWidth: number,
+) => {
+ if (
+ !actions.every(({id}) => widths[id]) ||
+ getToolbarWidth(actions, widths) <= availableWidth
+ ) {
+ return {visible: actions, hidden: []};
+ }
+
+ const order = [
+ ...HIDE_ORDER,
+ ...actions
+ .map(({id}) => id)
+ .filter((id) => !HIDE_ORDER.includes(id))
+ .reverse(),
+ ];
+ const hidden = new Set();
+ let visible = actions;
+ for (const id of order) {
+ if (!visible.some((action) => action.id === id)) continue;
+ hidden.add(id);
+ visible = visible.filter((action) => action.id !== id);
+ const overflowWidth = MORE_BUTTON_WIDTH + (visible.length ? GROUP_GAP : 0);
+ if (getToolbarWidth(visible, widths) + overflowWidth <= availableWidth) break;
+ }
+ return {visible, hidden: actions.filter(({id}) => hidden.has(id))};
+};
+
+export const useToolbarLayout = (actions: ToolbarAction[], constrainToParent: boolean) => {
+ const toolbarRef = useRef(null);
+ const rowRef = useRef(null);
+ const [sizes, setSizes] = useState({width: Infinity, items: {} as Record});
+ const actionIds = actions.map(({id}) => id).join('|');
+
+ useLayoutEffect(() => {
+ const measure = () => {
+ const row = rowRef.current;
+ if (!row) return;
+
+ const measured = Array.from(
+ row.querySelectorAll('[data-toolbar-action-id]'),
+ )
+ .map((item) => [item.dataset.toolbarActionId!, item.offsetWidth] as const)
+ .filter(([, width]) => width > 0);
+ const parent = toolbarRef.current?.offsetParent;
+ const parentWidth =
+ constrainToParent && parent instanceof HTMLElement ? parent.clientWidth : Infinity;
+ const width =
+ Math.min(Math.floor(window.innerWidth * VIEWPORT_WIDTH_RATIO), parentWidth) -
+ HORIZONTAL_PADDING;
+
+ setSizes((current) => {
+ if (
+ current.width === width &&
+ measured.every(([id, size]) => current.items[id] === size)
+ ) {
+ return current;
+ }
+ return {width, items: {...current.items, ...Object.fromEntries(measured)}};
+ });
+ };
+
+ measure();
+ window.addEventListener('resize', measure);
+ const observer =
+ typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
+ const parent = toolbarRef.current?.parentElement;
+ if (parent) observer?.observe(parent);
+ if (rowRef.current) observer?.observe(rowRef.current);
+ return () => {
+ observer?.disconnect();
+ window.removeEventListener('resize', measure);
+ };
+ }, [actionIds, constrainToParent]);
+
+ const {visible, hidden} = splitToolbarActions(actions, sizes.items, sizes.width);
+ return {
+ toolbarRef,
+ rowRef,
+ visibleGroups: groupToolbarActions(visible),
+ hiddenGroups: groupToolbarActions(hidden),
+ };
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConfirm.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConfirm.tsx
new file mode 100644
index 000000000..2af7e9776
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConfirm.tsx
@@ -0,0 +1,73 @@
+import {useCallback, useEffect, useRef, useState} from 'react';
+import type {ReactNode} from 'react';
+
+import {Dialog} from '@gravity-ui/uikit';
+
+import {i18n} from 'src/i18n/yfm-html-constructor';
+
+export interface ConfirmOptions {
+ title: string;
+ message: ReactNode;
+ /** Apply button label. Falls back to a generic "Continue". */
+ confirmText?: string;
+ cancelText?: string;
+ /** Use the destructive action style. */
+ danger?: boolean;
+}
+
+export type ConfirmFn = (options: ConfirmOptions) => Promise;
+
+interface PendingConfirm extends ConfirmOptions {
+ resolve: (confirmed: boolean) => void;
+}
+
+/** Pending confirmations are canceled when replaced or unmounted. */
+export const useConfirm = (): {confirm: ConfirmFn; confirmElement: ReactNode} => {
+ const [pending, setPending] = useState(null);
+ const pendingRef = useRef(null);
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ pendingRef.current?.resolve(false);
+ pendingRef.current = null;
+ };
+ }, []);
+
+ const confirm = useCallback((options) => {
+ if (!mountedRef.current) return Promise.resolve(false);
+ pendingRef.current?.resolve(false);
+
+ return new Promise((resolve) => {
+ const next = {...options, resolve};
+ pendingRef.current = next;
+ setPending(next);
+ });
+ }, []);
+
+ const finish = (confirmed: boolean) => {
+ const current = pendingRef.current;
+ if (!current) return;
+ pendingRef.current = null;
+ current.resolve(confirmed);
+ setPending(null);
+ };
+
+ const confirmElement = pending ? (
+
+ ) : null;
+
+ return {confirm, confirmElement};
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConstructorCommands.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConstructorCommands.ts
new file mode 100644
index 000000000..7b9ffb39e
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConstructorCommands.ts
@@ -0,0 +1,98 @@
+import {useMemo} from 'react';
+
+import type {Node, NodeType} from 'prosemirror-model';
+import type {EditorView} from 'prosemirror-view';
+
+import {generateEntityId} from 'src/utils/entity-id';
+import {removeNode} from 'src/utils/remove-node';
+
+import {YfmHtmlConstructorConsts} from '../YfmHtmlConstructorSpecs/const';
+import {type CodeChange, type ConstructorDocument, applyCodeChange} from '../document';
+import {cloneHtmlConstructorBlock, readBlocks, readStructure, regenerateHtmlIds} from '../model';
+import type {HtmlConstructorBlock, HtmlConstructorStructure} from '../types';
+
+export const useConstructorCommands = ({
+ nodeType,
+ getPos,
+ view,
+ onChange,
+}: {
+ nodeType: NodeType;
+ getPos: () => number | undefined;
+ view: EditorView;
+ onChange: (attrs: Partial) => void;
+}) =>
+ useMemo(() => {
+ const resolveNode = () => {
+ if (view.isDestroyed) return null;
+ const pos = getPos();
+ if (pos === undefined) return null;
+ const node = view.state.doc.nodeAt(pos);
+ return node?.type === nodeType ? {node, pos} : null;
+ };
+
+ // Read at dispatch time: an open dialog or confirmation may outlive a document change.
+ const update = (
+ change: (document: ConstructorDocument) => Partial,
+ ) => {
+ const current = resolveNode();
+ if (current)
+ onChange(
+ change({
+ structure: readStructure(current.node),
+ blocks: readBlocks(current.node),
+ }),
+ );
+ };
+ const updateBlocks = (change: (blocks: HtmlConstructorBlock[]) => HtmlConstructorBlock[]) =>
+ update(({blocks}) => ({blocks: change(blocks)}));
+
+ return {
+ replaceDocument: (document: ConstructorDocument) => update(() => document),
+ patchStructure: (patch: Partial) =>
+ update(({structure}) => ({structure: {...structure, ...patch}})),
+ setBlocks: (blocks: HtmlConstructorBlock[]) => updateBlocks(() => blocks),
+ addBlock: (block: HtmlConstructorBlock) => updateBlocks((blocks) => [...blocks, block]),
+ patchBlock: (id: string, patch: Partial) =>
+ updateBlocks((blocks) =>
+ blocks.map((block) => (block.id === id ? {...block, ...patch} : block)),
+ ),
+ replaceBlock: (id: string, replacement: HtmlConstructorBlock) =>
+ updateBlocks((blocks) =>
+ blocks.map((block) => (block.id === id ? replacement : block)),
+ ),
+ duplicateBlock: (id: string) =>
+ updateBlocks((blocks) => {
+ const index = blocks.findIndex((block) => block.id === id);
+ if (index === -1) return blocks;
+ return [
+ ...blocks.slice(0, index + 1),
+ cloneHtmlConstructorBlock(blocks[index]),
+ ...blocks.slice(index + 1),
+ ];
+ }),
+ removeBlock: (id: string) =>
+ updateBlocks((blocks) => blocks.filter((block) => block.id !== id)),
+ commitCode: (change: CodeChange) =>
+ update((document) => applyCodeChange(document, change)),
+ duplicate: () => {
+ const current = resolveNode();
+ if (!current) return;
+ const {node, pos} = current;
+ const structure = readStructure(node);
+ const copy = node.type.create({
+ ...node.attrs,
+ structure: {...structure, content: regenerateHtmlIds(structure.content)},
+ blocks: readBlocks(node).map(cloneHtmlConstructorBlock),
+ [YfmHtmlConstructorConsts.NodeAttrs.EntityId]: generateEntityId(
+ YfmHtmlConstructorConsts.NodeName,
+ ),
+ });
+ view.dispatch(view.state.tr.insert(pos + node.nodeSize, copy));
+ },
+ remove: () => {
+ const current = resolveNode();
+ if (current) removeNode({...current, tr: view.state.tr, dispatch: view.dispatch});
+ },
+ };
+ }, [getPos, nodeType, onChange, view]);
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useInlineHtmlEditing.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useInlineHtmlEditing.tsx
new file mode 100644
index 000000000..64da568a6
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useInlineHtmlEditing.tsx
@@ -0,0 +1,273 @@
+import {useEffect, useRef, useState} from 'react';
+import type {CSSProperties, FocusEvent, KeyboardEvent, MouseEvent} from 'react';
+
+import {Pencil} from '@gravity-ui/icons';
+import {Icon} from '@gravity-ui/uikit';
+
+import {i18n} from 'src/i18n/yfm-html-constructor';
+
+import {InlineElementEditor} from './InlineElementEditor';
+import {STOP_EVENT_CLASSNAME, cnYfmHtmlConstructor} from './const';
+import {getFirstEditableElement} from './textEditing';
+
+const b = cnYfmHtmlConstructor;
+const stop = STOP_EVENT_CLASSNAME;
+const inlineEditButtonSelector = `.${b('inline-edit-button')}`;
+
+interface InlineEditTarget {
+ element: Element;
+ buttonStyle: CSSProperties;
+ outlineStyle: CSSProperties;
+}
+
+const clamp = (value: number, min: number, max: number) =>
+ Math.max(min, Math.min(value, Math.max(min, max)));
+
+const INLINE_EDIT_OUTLINE_PADDING = 4;
+const INLINE_EDIT_BUTTON_SIZE = 28;
+
+const getTargetStyles = (bounds: HTMLElement, target: Element) => {
+ const rect = bounds.getBoundingClientRect();
+ const targetRect = target.getBoundingClientRect();
+ const left = targetRect.left - rect.left;
+ const top = targetRect.top - rect.top;
+
+ const outlineLeft = Math.max(0, left - INLINE_EDIT_OUTLINE_PADDING);
+ const outlineTop = Math.max(0, top - INLINE_EDIT_OUTLINE_PADDING);
+ const outlineWidth = targetRect.width + INLINE_EDIT_OUTLINE_PADDING * 2;
+ const outlineHeight = targetRect.height + INLINE_EDIT_OUTLINE_PADDING * 2;
+
+ return {
+ buttonStyle: {
+ left: clamp(
+ outlineLeft + outlineWidth - INLINE_EDIT_BUTTON_SIZE + 8,
+ 4,
+ rect.width - INLINE_EDIT_BUTTON_SIZE,
+ ),
+ top: clamp(outlineTop - 8, 0, rect.height - INLINE_EDIT_BUTTON_SIZE),
+ },
+ outlineStyle: {
+ left: outlineLeft,
+ top: outlineTop,
+ width: outlineWidth,
+ height: outlineHeight,
+ },
+ };
+};
+
+const getEditableElement = (root: HTMLElement, target: EventTarget | null): Element | null => {
+ if (!(target instanceof Element) || target.closest(inlineEditButtonSelector)) return null;
+
+ const ownerSvg = target.closest('svg');
+ const element = ownerSvg && root.contains(ownerSvg) ? ownerSvg : target;
+
+ if (element === root || !root.contains(element)) return null;
+
+ return element;
+};
+
+const getInlineEditTarget = (
+ root: HTMLElement,
+ bounds: HTMLElement,
+ target: EventTarget | null,
+): InlineEditTarget | null => {
+ const element = getEditableElement(root, target);
+ if (!element) return null;
+
+ return {element, ...getTargetStyles(bounds, element)};
+};
+
+export const useInlineHtmlEditing = ({onCommit}: {onCommit: (html: string) => void}) => {
+ const contentRef = useRef(null);
+ const boundsRef = useRef(null);
+ const [inlineEditTarget, setInlineEditTarget] = useState(null);
+ const [editTarget, setEditTarget] = useState(null);
+ const [editAnchor, setEditAnchor] = useState(null);
+ const focusFrameRef = useRef();
+
+ useEffect(
+ () => () => {
+ if (focusFrameRef.current !== undefined) {
+ cancelAnimationFrame(focusFrameRef.current);
+ }
+ },
+ [],
+ );
+
+ const resolveTarget = (target: EventTarget | null) => {
+ const root = contentRef.current;
+ const bounds = boundsRef.current;
+ if (!root || !bounds) return null;
+ return getInlineEditTarget(root, bounds, target);
+ };
+
+ const handleContentMouseMove = (event: MouseEvent) => {
+ if (editTarget) return;
+
+ const root = contentRef.current;
+ const bounds = boundsRef.current;
+ if (!root || !bounds) return;
+ const element = getEditableElement(root, event.target);
+ if (!element || element === inlineEditTarget?.element) return;
+ setInlineEditTarget({element, ...getTargetStyles(bounds, element)});
+ };
+
+ const handleContentMouseOut = (event: MouseEvent) => {
+ const element = inlineEditTarget?.element;
+ if (!element || !(event.target instanceof Node) || !element.contains(event.target)) return;
+
+ const nextElement = event.relatedTarget;
+ if (
+ nextElement instanceof Node &&
+ (element.contains(nextElement) ||
+ (nextElement instanceof Element && nextElement.closest(inlineEditButtonSelector)))
+ ) {
+ return;
+ }
+
+ setInlineEditTarget(null);
+ };
+
+ const clearInlineEditTarget = () => setInlineEditTarget(null);
+
+ const handleContentFocus = (event: FocusEvent) => {
+ if (editTarget || event.target !== event.currentTarget) return;
+ const root = contentRef.current;
+ if (!root) return;
+ setInlineEditTarget(resolveTarget(getFirstEditableElement(root)));
+ };
+
+ const handleContentBlur = (event: FocusEvent) => {
+ const next = event.relatedTarget;
+ if (next instanceof Node && event.currentTarget.contains(next)) return;
+ clearInlineEditTarget();
+ };
+
+ const cancelEditing = (restoreFocus = true) => {
+ setEditTarget(null);
+ if (focusFrameRef.current !== undefined) cancelAnimationFrame(focusFrameRef.current);
+ if (restoreFocus) {
+ focusFrameRef.current = requestAnimationFrame(() => {
+ boundsRef.current?.focus({preventScroll: true});
+ focusFrameRef.current = undefined;
+ });
+ }
+ };
+
+ const openInlineEditTarget = (target: InlineEditTarget) => {
+ if (!contentRef.current?.contains(target.element)) return false;
+
+ if (focusFrameRef.current !== undefined) {
+ cancelAnimationFrame(focusFrameRef.current);
+ focusFrameRef.current = undefined;
+ }
+
+ setEditTarget(target);
+ setInlineEditTarget(null);
+
+ return true;
+ };
+
+ const handleOpenInlineEdit = (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!inlineEditTarget) return;
+
+ openInlineEditTarget(inlineEditTarget);
+ };
+
+ const handleContentClick = (event: MouseEvent) => {
+ if (editTarget) return;
+ const target = resolveTarget(event.target);
+ if (!target) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ openInlineEditTarget(target);
+ };
+
+ const handleContentKeyDown = (event: KeyboardEvent) => {
+ if (editTarget || event.nativeEvent.isComposing || event.keyCode === 229) return;
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+
+ const root = contentRef.current;
+ if (
+ !root ||
+ !(event.target instanceof Element) ||
+ !event.currentTarget.contains(event.target) ||
+ event.target.closest(inlineEditButtonSelector)
+ ) {
+ return;
+ }
+
+ const isContainer = event.target === event.currentTarget || event.target === root;
+ const target = resolveTarget(isContainer ? getFirstEditableElement(root) : event.target);
+ if (!target) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ openInlineEditTarget(target);
+ };
+
+ const overlay = (
+ <>
+ {inlineEditTarget && !editTarget && (
+ <>
+
+
+ >
+ )}
+ {editTarget && (
+ <>
+
+ {contentRef.current && (
+
+ )}
+ >
+ )}
+ >
+ );
+
+ return {
+ contentRef,
+ boundsRef,
+ containerHandlers: {
+ onClick: handleContentClick,
+ onKeyDown: handleContentKeyDown,
+ onMouseMove: handleContentMouseMove,
+ onMouseOut: handleContentMouseOut,
+ onFocus: handleContentFocus,
+ onBlur: handleContentBlur,
+ onMouseLeave: clearInlineEditTarget,
+ },
+ overlay,
+ };
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/const.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/const.ts
new file mode 100644
index 000000000..939e3058b
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/const.ts
@@ -0,0 +1,24 @@
+import {entityIdAttr} from 'src/utils/entity-id';
+import {nodeTypeFactory} from 'src/utils/schema';
+
+export enum YfmHtmlConstructorAttrs {
+ // @ts-expect-error error TS18055
+ EntityId = entityIdAttr,
+ /** Active structure data. */
+ structure = 'structure',
+ /** Array of rendered HTML constructor blocks. */
+ blocks = 'blocks',
+}
+
+export const yfmHtmlConstructorNodeName = 'yfm_html_constructor';
+export const yfmHtmlConstructorNodeType = nodeTypeFactory(yfmHtmlConstructorNodeName);
+
+export const YfmHtmlConstructorAction = 'createYfmHtmlConstructor';
+
+export const YfmHtmlConstructorConsts = {
+ NodeName: yfmHtmlConstructorNodeName,
+ NodeAttrs: YfmHtmlConstructorAttrs,
+ nodeType: yfmHtmlConstructorNodeType,
+} as const;
+
+export const defaultYfmHtmlConstructorEntityId = yfmHtmlConstructorNodeName + '#0';
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/index.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/index.ts
new file mode 100644
index 000000000..cf5d0297a
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/index.ts
@@ -0,0 +1,184 @@
+import type {Node} from 'prosemirror-model';
+
+import type {ExtensionAuto, ExtensionNodeSpec} from '#core';
+
+import {
+ blockClass,
+ blockSelector,
+ hashToScopeId,
+ htmlConstructorBlockClass,
+ htmlConstructorScopeClassName,
+ htmlConstructorStructureClass,
+ replaceCssAnchor,
+ scopeCss,
+ structureClass,
+ structureSelector,
+ templateCssToRules,
+} from '../css';
+import {HTML_CONSTRUCTOR_VARIABLES_CSS} from '../cssVariables';
+import {emptyHtmlConstructorStructure, readBlocks, readStructure} from '../model';
+import {htmlConstructorQuickStyleToCss} from '../quickStyle';
+import type {HtmlConstructorBlock, HtmlConstructorStructure} from '../types';
+
+import {
+ YfmHtmlConstructorConsts,
+ defaultYfmHtmlConstructorEntityId,
+ yfmHtmlConstructorNodeName,
+} from './const';
+
+export {
+ yfmHtmlConstructorNodeName,
+ yfmHtmlConstructorNodeType,
+ YfmHtmlConstructorAttrs,
+ YfmHtmlConstructorConsts,
+} from './const';
+
+export type YfmHtmlConstructorSpecsOptions = {
+ nodeView?: ExtensionNodeSpec['view'];
+ /** Experimental: scope each instance's generated CSS. @see YfmHtmlConstructorExtensionOptions */
+ scopeStyles?: boolean;
+};
+
+export {emptyHtmlConstructorStructure} from '../model';
+
+const escapeHtmlAttr = (value: string) =>
+ value
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+
+const styleAttr = (style: string) => (style ? ` style="${escapeHtmlAttr(style)}"` : '');
+
+const indent = (text: string, by = ' ') =>
+ text
+ .split('\n')
+ .map((line) => (line ? by + line : line))
+ .join('\n');
+
+const buildCss = (
+ structure: HtmlConstructorStructure,
+ blocks: HtmlConstructorBlock[],
+ scopeSelector?: string,
+) => {
+ const rules = [
+ structure.css.trim() &&
+ replaceCssAnchor(templateCssToRules(structure.css), structureSelector()).trim(),
+ ...blocks.map(
+ (block, index) =>
+ block.css.trim() &&
+ replaceCssAnchor(templateCssToRules(block.css), blockSelector(index)).trim(),
+ ),
+ ].filter(Boolean);
+
+ const usesVariables =
+ Boolean(structure.quickStyle) || blocks.some((block) => Boolean(block.quickStyle));
+
+ // Nothing to style and no quick-style variables to resolve — skip the
+ // contract stylesheet entirely so plain blocks stay markup-only.
+ if (!rules.length && !usesVariables) return '';
+
+ let ruleCss = rules.join('\n').replace(/\n{2,}/g, '\n');
+ // The generic contract stylesheet stays unscoped (it's identical for every
+ // instance); only the per-instance rules are isolated.
+ if (scopeSelector && ruleCss) ruleCss = scopeCss(ruleCss, scopeSelector);
+
+ // The contract stylesheet comes first so block/theme rules (higher
+ // specificity) and inline quick-style variables can override it.
+ return [HTML_CONSTRUCTOR_VARIABLES_CSS, ruleCss].filter(Boolean).join('\n');
+};
+
+const buildStructureContent = (
+ structure: HtmlConstructorStructure,
+ blocks: HtmlConstructorBlock[],
+) => {
+ const structureStyle = styleAttr(htmlConstructorQuickStyleToCss(structure.quickStyle));
+ const children = [
+ structure.content.trim(),
+ ...blocks.map((block, index) => {
+ const blockStyle = styleAttr(htmlConstructorQuickStyleToCss(block.quickStyle));
+
+ return `${block.content ?? ''}
`;
+ }),
+ ].filter(Boolean);
+
+ return `${
+ children.length ? `\n${indent(children.join('\n'))}\n` : ''
+ }
`;
+};
+
+const readScopeId = (node: Node): string =>
+ hashToScopeId(String(node.attrs[YfmHtmlConstructorConsts.NodeAttrs.EntityId] ?? ''));
+
+/** Assembles the static HTML written into a YFM HTML block. */
+export const buildYfmHtmlConstructorHtml = (
+ node: Node,
+ {scopeStyles}: {scopeStyles?: boolean} = {},
+): string => {
+ const structure = readStructure(node);
+ const blocks = readBlocks(node);
+
+ const scopeClass = scopeStyles ? htmlConstructorScopeClassName(readScopeId(node)) : undefined;
+ const css = buildCss(structure, blocks, scopeClass && `.${scopeClass}`);
+ const styleTag = css ? `\n` : '';
+ const html = buildStructureContent(structure, blocks);
+ const body = `${styleTag}${html}`.trim();
+
+ // Wrap in the instance scope so the scoped CSS selectors have an ancestor to
+ // match against, isolating this constructor from others on the same page.
+ return scopeClass ? `\n${indent(body)}\n
` : body;
+};
+
+const YfmHtmlConstructorSpecsExtension: ExtensionAuto = (
+ builder,
+ {nodeView, scopeStyles},
+) => {
+ builder.addNode(yfmHtmlConstructorNodeName, () => ({
+ // The node is created via the toolbar action; no markdown token is emitted for it.
+ fromMd: {
+ tokenSpec: {name: yfmHtmlConstructorNodeName, type: 'node', noCloseToken: true},
+ },
+ spec: {
+ atom: true,
+ selectable: true,
+ group: 'block',
+ attrs: {
+ [YfmHtmlConstructorConsts.NodeAttrs.structure]: {
+ default: emptyHtmlConstructorStructure(),
+ },
+ [YfmHtmlConstructorConsts.NodeAttrs.blocks]: {default: []},
+ [YfmHtmlConstructorConsts.NodeAttrs.EntityId]: {
+ default: defaultYfmHtmlConstructorEntityId,
+ },
+ },
+ parseDOM: [],
+ toDOM(node) {
+ return [
+ 'div',
+ {
+ class: 'yfm-html-constructor',
+ [YfmHtmlConstructorConsts.NodeAttrs.EntityId]:
+ node.attrs[YfmHtmlConstructorConsts.NodeAttrs.EntityId],
+ },
+ ];
+ },
+ dnd: {props: {offset: [8, 1]}},
+ },
+ toMd: (state, node) => {
+ state.write('::: html');
+ state.write('\n');
+ state.write(buildYfmHtmlConstructorHtml(node, {scopeStyles}));
+ state.ensureNewLine();
+ state.write(':::');
+ state.closeBlock(node);
+ },
+ view: nodeView,
+ }));
+};
+
+export const YfmHtmlConstructorSpecs = Object.assign(
+ YfmHtmlConstructorSpecsExtension,
+ YfmHtmlConstructorConsts,
+);
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/actions.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/actions.ts
new file mode 100644
index 000000000..16c39990f
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/actions.ts
@@ -0,0 +1,28 @@
+import type {ActionSpec} from '#core';
+import {generateEntityId} from 'src/utils/entity-id';
+
+import {
+ YfmHtmlConstructorConsts,
+ emptyHtmlConstructorStructure,
+ yfmHtmlConstructorNodeType,
+} from './YfmHtmlConstructorSpecs';
+
+export const addYfmHtmlConstructor: ActionSpec = {
+ isEnable(state) {
+ return state.selection.empty;
+ },
+ run(state, dispatch) {
+ const entityId = generateEntityId(YfmHtmlConstructorConsts.NodeName);
+
+ const tr = state.tr.insert(
+ state.selection.from,
+ yfmHtmlConstructorNodeType(state.schema).create({
+ [YfmHtmlConstructorConsts.NodeAttrs.structure]: emptyHtmlConstructorStructure(),
+ [YfmHtmlConstructorConsts.NodeAttrs.blocks]: [],
+ [YfmHtmlConstructorConsts.NodeAttrs.EntityId]: entityId,
+ }),
+ );
+
+ dispatch(tr);
+ },
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/const.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/const.ts
new file mode 100644
index 000000000..5d352e7c1
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/const.ts
@@ -0,0 +1 @@
+export * from './YfmHtmlConstructorSpecs/const';
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/constructorHooks.test.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/constructorHooks.test.tsx
new file mode 100644
index 000000000..ace29dc18
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/constructorHooks.test.tsx
@@ -0,0 +1,153 @@
+import {type Root, createRoot} from 'react-dom/client';
+import {act} from 'react-dom/test-utils';
+
+import {useConfirm} from './YfmHtmlConstructorNodeView/useConfirm';
+import {
+ YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY,
+ getHtmlConstructorPreferences,
+ setHtmlConstructorPreference,
+ useHtmlConstructorPreferences,
+} from './preferences';
+
+const environment = globalThis as typeof globalThis & {IS_REACT_ACT_ENVIRONMENT?: boolean};
+const previousActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT;
+let container: HTMLDivElement;
+let root: Root | null;
+
+beforeAll(() => {
+ environment.IS_REACT_ACT_ENVIRONMENT = true;
+});
+
+afterAll(() => {
+ environment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
+});
+
+beforeEach(() => {
+ jest.restoreAllMocks();
+ window.localStorage.clear();
+ setHtmlConstructorPreference('compactCodeView', true);
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ container.remove();
+ jest.restoreAllMocks();
+});
+
+const PreferenceProbe = () => {
+ const preferences = useHtmlConstructorPreferences();
+ return {preferences.compactCodeView ? 'compact' : 'split'};
+};
+
+describe('constructor preferences', () => {
+ it('updates every mounted consumer and follows storage changes from another tab', () => {
+ act(() =>
+ root?.render(
+ <>
+
+
+ >,
+ ),
+ );
+ act(() => setHtmlConstructorPreference('compactCodeView', false));
+ expect(container.textContent).toBe('splitsplit');
+
+ act(() => {
+ window.localStorage.setItem(
+ YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY,
+ JSON.stringify({compactCodeView: true}),
+ );
+ window.dispatchEvent(
+ new StorageEvent('storage', {
+ key: YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY,
+ storageArea: window.localStorage,
+ }),
+ );
+ });
+ expect(container.textContent).toBe('compactcompact');
+
+ act(() => setHtmlConstructorPreference('compactCodeView', false));
+ act(() => {
+ window.localStorage.clear();
+ window.dispatchEvent(new StorageEvent('storage', {key: null}));
+ });
+ expect(container.textContent).toBe('compactcompact');
+ });
+
+ it('keeps the selected preference when persistence fails', () => {
+ act(() => root?.render());
+ jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('Storage is full', 'QuotaExceededError');
+ });
+
+ act(() => setHtmlConstructorPreference('compactCodeView', false));
+ act(() =>
+ root?.render(
+ <>
+
+
+ >,
+ ),
+ );
+
+ expect(container.textContent).toBe('splitsplit');
+ expect(getHtmlConstructorPreferences().compactCodeView).toBe(false);
+ });
+
+ it('removes the shared storage listener after the last consumer unmounts', () => {
+ const add = jest.spyOn(window, 'addEventListener');
+ const remove = jest.spyOn(window, 'removeEventListener');
+ act(() =>
+ root?.render(
+ <>
+
+
+ >,
+ ),
+ );
+
+ const storageListeners = add.mock.calls.filter(([event]) => event === 'storage');
+ expect(storageListeners).toHaveLength(1);
+ act(() => root?.unmount());
+ root = null;
+
+ expect(remove).toHaveBeenCalledWith('storage', storageListeners[0]?.[1]);
+ });
+});
+
+describe('constructor confirmations', () => {
+ let confirmation: ReturnType;
+
+ const ConfirmationProbe = () => {
+ confirmation = useConfirm();
+ return null;
+ };
+
+ it('cancels a replaced request and resolves the remaining request on unmount', async () => {
+ act(() => root?.render());
+ let first: Promise = Promise.resolve(true);
+ let second: Promise = Promise.resolve(true);
+ act(() => {
+ first = confirmation.confirm({title: 'First', message: 'Replace first?'});
+ second = confirmation.confirm({title: 'Second', message: 'Replace second?'});
+ });
+
+ await expect(first).resolves.toBe(false);
+ act(() => root?.unmount());
+ root = null;
+ await expect(second).resolves.toBe(false);
+ });
+
+ it('cancels requests made through a callback retained after unmount', async () => {
+ act(() => root?.render());
+ const confirm = confirmation.confirm;
+ act(() => root?.unmount());
+ root = null;
+
+ await expect(confirm({title: 'Deleted', message: 'Continue?'})).resolves.toBe(false);
+ });
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/css.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/css.test.ts
new file mode 100644
index 000000000..e995a01bf
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/css.test.ts
@@ -0,0 +1,62 @@
+import {parse} from 'postcss';
+
+import {replaceCssAnchor, scopeCss} from './css';
+
+describe('constructor CSS', () => {
+ it('preserves keyframes, declarations and at-rules while scoping nested rules', () => {
+ const css = `/* animation */ @keyframes slide { from { opacity: 0 } to { opacity: 1 } }
+@font-face { font-family: "demo"; src: url("font.woff2") }
+@media (min-width: 400px) { :is(h1, h2), [data-label="a,b"] { content: "} & {"; animation: slide 1s } }`;
+ const scoped = scopeCss(css, '.instance');
+ const selectors: string[] = [];
+ parse(scoped).walkRules((rule) => {
+ selectors.push(rule.selector);
+ });
+ expect(selectors).toEqual([
+ 'from',
+ 'to',
+ '.instance :is(h1, h2), .instance [data-label="a,b"]',
+ ]);
+ expect(scoped).toContain('content: "} & {"');
+ expect(scoped).toContain('src: url("font.woff2")');
+ });
+
+ it('replaces selector anchors without changing URLs, strings or attribute values', () => {
+ const css = '&, &[data-label="A&B"] { background: url("/?a=1&b=2"); content: "&" }';
+ expect(replaceCssAnchor(css, '.block')).toBe(
+ '.block, .block[data-label="A&B"] { background: url("/?a=1&b=2"); content: "&" }',
+ );
+ });
+
+ it('preserves native nesting and excludes editor controls from preview rules', () => {
+ const css = '.card { & > button { color: red } }';
+ expect(scopeCss(css, '.instance', '[data-hc-ui]')).toBe(
+ '.instance .card:not([data-hc-ui], [data-hc-ui] *) { & > button:not([data-hc-ui], [data-hc-ui] *) { color: red } }',
+ );
+ });
+
+ it('keeps incomplete CSS editable while excluding it from the preview', () => {
+ expect(scopeCss('.card { color:', '.instance')).toBe('');
+ expect(scopeCss('& {')).toBe('');
+ expect(replaceCssAnchor('& {', '.block')).toBe('& {');
+ });
+
+ it('preserves nesting through conditional at-rules', () => {
+ const css =
+ '& { @media (width > 500px) { @supports (display: grid) { & > h2 { color: red } } } }';
+ const prepared = replaceCssAnchor(css, '.block');
+
+ expect(prepared).toBe(
+ '.block { @media (width > 500px) { @supports (display: grid) { & > h2 { color: red } } } }',
+ );
+ expect(scopeCss(prepared, '.instance')).toBe(
+ '.instance .block { @media (width > 500px) { @supports (display: grid) { & > h2 { color: red } } } }',
+ );
+ });
+
+ it('keeps pseudo-elements valid when excluding editor controls', () => {
+ expect(scopeCss('a::before { content: "&" }', '.instance', '[data-hc-ui]')).toBe(
+ '.instance a:not([data-hc-ui], [data-hc-ui] *)::before { content: "&" }',
+ );
+ });
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/css.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/css.ts
new file mode 100644
index 000000000..a27b526b6
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/css.ts
@@ -0,0 +1,113 @@
+import {type Document, type Rule, parse} from 'postcss';
+import selectorParser from 'postcss-selector-parser';
+
+export const htmlConstructorStructureClass = 'g-md-hc-structure';
+export const htmlConstructorBlockClass = 'g-md-hc-block';
+
+export const structureClass = (index = 0) => `${htmlConstructorStructureClass}-${index + 1}`;
+export const blockClass = (index: number) => `${htmlConstructorBlockClass}-${index + 1}`;
+
+export const structureSelector = (index = 0) =>
+ `.${htmlConstructorStructureClass}.${structureClass(index)}`;
+export const blockSelector = (index: number) =>
+ `.${htmlConstructorBlockClass}.${blockClass(index)}`;
+
+/** Wraps inline declarations into a `selector { ... }` rule. */
+export const inlineToRule = (declarations: string, selector = '&'): string => {
+ const decls = declarations.trim().replace(/;?$/, ';');
+ return decls === ';' ? '' : `${selector} {\n ${decls}\n}`;
+};
+
+export const templateCssToRules = (css: string, selector = '&'): string => {
+ const value = css.trim();
+ if (!value) return '';
+
+ return value.includes('{') ? value : inlineToRule(value, selector);
+};
+
+const isKeyframe = (rule: Rule): boolean => {
+ for (let parent: Rule['parent'] | Document = rule.parent; parent; parent = parent.parent) {
+ if (parent.type === 'atrule' && /(?:^|-)keyframes$/i.test(parent.name)) return true;
+ }
+ return false;
+};
+
+const hasRuleAncestor = (rule: Rule): boolean => {
+ for (let parent: Rule['parent'] | Document = rule.parent; parent; parent = parent.parent) {
+ if (parent.type === 'rule') return true;
+ }
+ return false;
+};
+
+const transformRules = (css: string, transform: (rule: Rule) => void, fallback = ''): string => {
+ try {
+ const root = parse(css);
+ root.walkRules((rule) => {
+ if (!isKeyframe(rule)) transform(rule);
+ });
+ return root.toString().trim();
+ } catch {
+ return fallback;
+ }
+};
+
+export const replaceCssAnchor = (css: string, selector: string): string =>
+ transformRules(
+ css,
+ (rule) => {
+ if (hasRuleAncestor(rule)) return;
+ const replacement = selectorParser().astSync(selector).first;
+ rule.assign({
+ selector: selectorParser((root) => {
+ root.walkNesting((anchor) => {
+ const nodes = replacement.nodes.map((node) => node.clone());
+ nodes[0].spaces.before = anchor.spaces.before;
+ anchor.replaceWith(...nodes);
+ });
+ }).processSync(rule.selector),
+ });
+ },
+ css,
+ );
+
+export const htmlConstructorScopeClassName = (scopeId: string) => `g-md-hc-scope-${scopeId}`;
+
+/** Small, stable, non-cryptographic hash rendered as a short base36 string. */
+export const hashToScopeId = (value: string): string => {
+ let hash = 5381;
+ for (let index = 0; index < value.length; index++) {
+ // djb2-style, kept in a safe integer range with modulo instead of bitwise ops.
+ hash = (hash * 33 + value.charCodeAt(index)) % 0xffffffff;
+ }
+ return hash.toString(36);
+};
+
+/** Scope selectors without rewriting declarations, keyframes or CSS strings. */
+export const scopeCss = (css: string, scopeSelector?: string, exclude?: string): string =>
+ transformRules(css, (rule) => {
+ rule.assign({
+ selector: selectorParser((root) => {
+ root.each((selector) => {
+ if (exclude) {
+ const exclusion = selectorParser().astSync(`:not(${exclude}, ${exclude} *)`)
+ .first.first;
+ const pseudoElement = selector.nodes.find(
+ (node) =>
+ node.type === 'pseudo' &&
+ /^(::|:(before|after|first-line|first-letter)$)/.test(node.value),
+ );
+ if (pseudoElement) selector.insertBefore(pseudoElement, exclusion);
+ else selector.append(exclusion);
+ }
+ if (scopeSelector && !hasRuleAncestor(rule)) {
+ const prefix = selectorParser().astSync(scopeSelector).first;
+ prefix.first.spaces.before = selector.first.spaces.before;
+ selector.first.spaces.before = '';
+ selector.prepend(selectorParser.combinator({value: ' '}));
+ for (const node of [...prefix.nodes].reverse())
+ selector.prepend(node.clone());
+ }
+ });
+ }).processSync(rule.selector),
+ });
+ });
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/cssVariables.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/cssVariables.ts
new file mode 100644
index 000000000..b38fb49a4
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/cssVariables.ts
@@ -0,0 +1,118 @@
+import type {CSSProperties} from 'react';
+
+import {htmlConstructorBlockClass, htmlConstructorStructureClass} from './css';
+import type {HtmlConstructorBorderStyle, HtmlConstructorQuickStyle} from './types';
+
+/** Toolbar overrides. Colors are per theme; border and radius apply to both themes. */
+export const HTML_CONSTRUCTOR_CSS_VARS = {
+ backgroundLight: '--g-md-hc-background-light',
+ backgroundDark: '--g-md-hc-background-dark',
+ textColorLight: '--g-md-hc-text-color-light',
+ textColorDark: '--g-md-hc-text-color-dark',
+ borderRadius: '--g-md-hc-border-radius',
+ border: '--g-md-hc-border',
+} as const;
+
+/** Defaults used when neither the template nor the toolbar sets a value. */
+export const HTML_CONSTRUCTOR_DEFAULTS = {
+ background: 'var(--g-color-base-generic-ultralight)',
+ textColor: 'var(--g-color-text-primary)',
+ border: '1px solid var(--g-color-line-generic)',
+ borderRadius: 'var(--g-border-radius-l)',
+} as const;
+
+const borderToValue = (style: HtmlConstructorBorderStyle): string =>
+ style === 'none' ? 'none' : `1px ${style} var(--g-color-line-generic)`;
+
+/** Maps a quick-style selection onto the public override CSS variables. */
+export const quickStyleToCssVars = (
+ quickStyle: HtmlConstructorQuickStyle | undefined,
+): Record => {
+ const vars: Record = {};
+ if (!quickStyle) return vars;
+
+ if (quickStyle.background?.light) {
+ vars[HTML_CONSTRUCTOR_CSS_VARS.backgroundLight] = quickStyle.background.light;
+ }
+ if (quickStyle.background?.dark) {
+ vars[HTML_CONSTRUCTOR_CSS_VARS.backgroundDark] = quickStyle.background.dark;
+ }
+ if (quickStyle.textColor?.light) {
+ vars[HTML_CONSTRUCTOR_CSS_VARS.textColorLight] = quickStyle.textColor.light;
+ }
+ if (quickStyle.textColor?.dark) {
+ vars[HTML_CONSTRUCTOR_CSS_VARS.textColorDark] = quickStyle.textColor.dark;
+ }
+ if (quickStyle.borderRadius) {
+ vars[HTML_CONSTRUCTOR_CSS_VARS.borderRadius] = quickStyle.borderRadius;
+ }
+ if (quickStyle.borderStyle) {
+ vars[HTML_CONSTRUCTOR_CSS_VARS.border] = borderToValue(quickStyle.borderStyle);
+ }
+
+ return vars;
+};
+
+export const quickStyleToReactVars = (
+ quickStyle: HtmlConstructorQuickStyle | undefined,
+): CSSProperties | undefined => {
+ const vars = quickStyleToCssVars(quickStyle);
+ return Object.keys(vars).length ? (vars as CSSProperties) : undefined;
+};
+
+export const quickStyleToCssVarDeclarations = (
+ quickStyle: HtmlConstructorQuickStyle | undefined,
+): string => {
+ const declarations = Object.entries(quickStyleToCssVars(quickStyle)).map(
+ ([name, value]) => `${name}: ${value}`,
+ );
+ return declarations.length ? `${declarations.join('; ')};` : '';
+};
+
+const STRUCTURE = `.${htmlConstructorStructureClass}`;
+const BLOCK = `.${htmlConstructorBlockClass}`;
+const DARK_ROOTS = ['.g-root_theme_dark', '.g-root_theme_dark-hc'];
+
+/**
+ * Resolves each `-current` variable for the active color theme, falling back to
+ * the constructor default ({@link HTML_CONSTRUCTOR_DEFAULTS}) when no companion
+ * is set. Baking the default in here is what lets templates read `-current`
+ * without carrying any fallback of their own.
+ */
+const resolveCurrent = (variant: 'light' | 'dark') => {
+ const d = HTML_CONSTRUCTOR_DEFAULTS;
+ const pick = (name: string, fallback: string) =>
+ variant === 'light'
+ ? `var(--g-md-hc-${name}-light, ${fallback})`
+ : `var(--g-md-hc-${name}-dark, var(--g-md-hc-${name}-light, ${fallback}))`;
+ return `
+ --g-md-hc-background-current: ${pick('background', d.background)};
+ --g-md-hc-text-color-current: ${pick('text-color', d.textColor)};
+ --g-md-hc-border-radius-current: ${pick('border-radius', d.borderRadius)};
+ --g-md-hc-border-current: ${pick('border', d.border)};`;
+};
+
+const CONSUME = `
+ background: var(--g-md-hc-background, var(--g-md-hc-background-current));
+ color: var(--g-md-hc-text-color, var(--g-md-hc-text-color-current));
+ border-radius: var(--g-md-hc-border-radius, var(--g-md-hc-border-radius-current));
+ border: var(--g-md-hc-border, var(--g-md-hc-border-current));`;
+
+const darkSelector = (selector: string) =>
+ DARK_ROOTS.map((root) => `${root} ${selector}`).join(',\n');
+
+/**
+ * The base contract stylesheet: it resolves each light/dark companion (falling
+ * back to the constructor defaults) for the active color theme and makes
+ * structures/blocks consume the variables. Injected wherever constructor markup
+ * is rendered standalone (output markdown, template previews); the in-editor
+ * copy lives in the SCSS.
+ */
+export const HTML_CONSTRUCTOR_VARIABLES_CSS = `
+${STRUCTURE},
+${BLOCK} {${resolveCurrent('light')}
+${CONSUME}
+}
+${darkSelector(STRUCTURE)},
+${darkSelector(BLOCK)} {${resolveCurrent('dark')}
+}`.trim();
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/document.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/document.ts
new file mode 100644
index 000000000..9632b5c0e
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/document.ts
@@ -0,0 +1,148 @@
+import {
+ blockClass,
+ blockSelector,
+ htmlConstructorBlockClass,
+ htmlConstructorStructureClass,
+ replaceCssAnchor,
+ scopeCss,
+ structureClass,
+ structureSelector,
+ templateCssToRules,
+} from './css';
+import {HTML_CONSTRUCTOR_VARIABLES_CSS} from './cssVariables';
+import {createHtmlConstructorBlockId} from './model';
+import type {HtmlConstructorBlock, HtmlConstructorStructure} from './types';
+
+export type CodeChange = {html?: string; css?: string};
+export type ConstructorDocument = {
+ structure: HtmlConstructorStructure;
+ blocks: HtmlConstructorBlock[];
+};
+
+export const applyCodeChange = (
+ {structure, blocks}: ConstructorDocument,
+ change: CodeChange,
+): ConstructorDocument => {
+ const parsed =
+ change.html === undefined
+ ? {content: structure.content, blocks}
+ : parseStructureHtml(change.html, structure, blocks);
+ return {
+ structure: {...structure, content: parsed.content, css: change.css ?? structure.css},
+ // The combined stylesheet replaces individual block rules only when CSS was edited.
+ blocks:
+ change.css === undefined
+ ? parsed.blocks
+ : parsed.blocks.map((block) => ({...block, css: ''})),
+ };
+};
+
+const prepareCss = (css: string, selector: string) =>
+ replaceCssAnchor(templateCssToRules(css), selector).trim();
+
+const prepareDocumentCss = (structure: HtmlConstructorStructure, blocks: HtmlConstructorBlock[]) =>
+ [
+ structure.css.trim() && prepareCss(structure.css, structureSelector()),
+ ...blocks.map(
+ (block, index) => block.css.trim() && prepareCss(block.css, blockSelector(index)),
+ ),
+ ].filter(Boolean);
+
+export const buildPreviewCss = ({
+ structure,
+ blocks,
+ scopeSelector,
+ exclude,
+}: ConstructorDocument & {
+ scopeSelector?: string;
+ exclude?: string;
+}) =>
+ prepareDocumentCss(structure, blocks)
+ .map((css) => scopeCss(css, scopeSelector, exclude))
+ .filter(Boolean)
+ .join('\n')
+ .replace(/\n{2,}/g, '\n');
+
+/** The locked wrapper lines shown around the structure's editable inner markup. */
+export const getStructureHtmlFrame = () => ({
+ top: ``,
+ bottom: '
',
+});
+
+/** Show shared theme variables above the editable instance styles. */
+export const getStructureCssFrame = () => ({
+ top: HTML_CONSTRUCTOR_VARIABLES_CSS,
+ bottom: '',
+});
+
+const indentLines = (value: string, pad = ' ') =>
+ value
+ .split('\n')
+ .map((line) => (line.trim() ? `${pad}${line}` : line))
+ .join('\n');
+
+/** Editable structure markup, with numbered wrappers preserving block identity. */
+export const assembleStructureHtml = (
+ structure: HtmlConstructorStructure,
+ blocks: HtmlConstructorBlock[],
+): string => {
+ const parts: string[] = [];
+
+ const content = structure.content.trim();
+ if (content) parts.push(content);
+
+ blocks.forEach((block, index) => {
+ const inner = block.content.trim();
+ const body = inner ? `\n${indentLines(inner)}\n` : '';
+ parts.push(`${body}
`);
+ });
+
+ return parts.join('\n\n');
+};
+
+/** Match generated wrapper classes to their original blocks before applying edits. */
+export const parseStructureHtml = (
+ html: string,
+ structure: HtmlConstructorStructure,
+ blocks: HtmlConstructorBlock[],
+): {content: string; blocks: HtmlConstructorBlock[]} => {
+ if (typeof document === 'undefined') {
+ return {content: structure.content, blocks};
+ }
+
+ const template = document.createElement('template');
+ template.innerHTML = html;
+
+ const contentHost = document.createElement('div');
+ const nextBlocks: HtmlConstructorBlock[] = [];
+ const blocksByClass = new Map(blocks.map((block, index) => [blockClass(index), block]));
+ const usedBlockIds = new Set();
+
+ for (const node of Array.from(template.content.childNodes)) {
+ if (node instanceof HTMLElement && node.classList.contains(htmlConstructorBlockClass)) {
+ const existing = Array.from(node.classList)
+ .map((className) => blocksByClass.get(className))
+ .find(Boolean);
+ const content = node.innerHTML.trim();
+ const id =
+ existing && !usedBlockIds.has(existing.id)
+ ? existing.id
+ : createHtmlConstructorBlockId();
+ nextBlocks.push(
+ existing ? {...existing, id, content} : {id, css: '', content, themeIds: []},
+ );
+ usedBlockIds.add(id);
+ continue;
+ }
+
+ contentHost.append(node);
+ }
+
+ return {content: contentHost.innerHTML.trim(), blocks: nextBlocks};
+};
+
+/** Preserve blank lines and invalid CSS while editing the combined stylesheet. */
+export const assembleStructureCss = (
+ structure: HtmlConstructorStructure,
+ blocks: HtmlConstructorBlock[],
+): string => prepareDocumentCss(structure, blocks).join('\n\n');
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/index.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/index.ts
new file mode 100644
index 000000000..cd14434c6
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/index.ts
@@ -0,0 +1,31 @@
+import type {Action, ExtensionAuto, ExtensionDeps, NodeViewConstructor} from '../../../core';
+
+import {WYfmHtmlConstructorNodeView} from './YfmHtmlConstructorNodeView';
+import {YfmHtmlConstructorSpecs} from './YfmHtmlConstructorSpecs';
+import {YfmHtmlConstructorAction} from './YfmHtmlConstructorSpecs/const';
+import {addYfmHtmlConstructor} from './actions';
+import type {YfmHtmlConstructorExtensionOptions} from './types';
+
+export const YfmHtmlConstructor: ExtensionAuto = (
+ builder,
+ options = {},
+) => {
+ builder.use(YfmHtmlConstructorSpecs, {
+ nodeView: yfmHtmlConstructorNodeViewFactory(options),
+ scopeStyles: options.scopeStyles,
+ });
+ builder.addAction(YfmHtmlConstructorAction, () => addYfmHtmlConstructor);
+};
+
+const yfmHtmlConstructorNodeViewFactory: (
+ options: YfmHtmlConstructorExtensionOptions,
+) => (deps: ExtensionDeps) => NodeViewConstructor = (options) => () => (node, view, getPos) =>
+ new WYfmHtmlConstructorNodeView({node, view, getPos, options});
+
+declare global {
+ namespace WysiwygEditor {
+ interface Actions {
+ [YfmHtmlConstructorAction]: Action;
+ }
+ }
+}
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/model.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/model.ts
new file mode 100644
index 000000000..b621415b8
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/model.ts
@@ -0,0 +1,104 @@
+import type {Node} from 'prosemirror-model';
+
+import {YfmHtmlConstructorConsts} from './YfmHtmlConstructorSpecs/const';
+import {normalizeHtmlConstructorQuickStyle} from './quickStyle';
+import {normalizeHtmlConstructorTemplateSettings} from './settings';
+import type {HtmlConstructorBlock, HtmlConstructorStructure} from './types';
+
+export const emptyHtmlConstructorStructure = (): HtmlConstructorStructure => ({
+ css: '',
+ content: '',
+ themeIds: [],
+});
+
+export const readStructure = (node: Node): HtmlConstructorStructure => {
+ const value = node.attrs[YfmHtmlConstructorConsts.NodeAttrs.structure];
+ if (!value || typeof value !== 'object') return emptyHtmlConstructorStructure();
+
+ return {
+ templateId: typeof value.templateId === 'string' ? value.templateId : undefined,
+ css: typeof value.css === 'string' ? value.css : '',
+ content: typeof value.content === 'string' ? value.content : '',
+ themeIds: Array.isArray(value.themeIds)
+ ? value.themeIds.filter((id: unknown): id is string => typeof id === 'string')
+ : [],
+ settings: normalizeHtmlConstructorTemplateSettings(value.settings),
+ quickStyle: normalizeHtmlConstructorQuickStyle(value.quickStyle),
+ };
+};
+
+export const readBlocks = (node: Node): HtmlConstructorBlock[] => {
+ const value = node.attrs[YfmHtmlConstructorConsts.NodeAttrs.blocks];
+ if (!Array.isArray(value)) return [];
+
+ return value.flatMap((block): HtmlConstructorBlock[] => {
+ if (!block || typeof block !== 'object') return [];
+
+ return [
+ {
+ id: typeof block.id === 'string' ? block.id : '',
+ templateId: typeof block.templateId === 'string' ? block.templateId : undefined,
+ css: typeof block.css === 'string' ? block.css : '',
+ content: typeof block.content === 'string' ? block.content : '',
+ themeIds: Array.isArray(block.themeIds)
+ ? block.themeIds.filter((id: unknown): id is string => typeof id === 'string')
+ : [],
+ settings: normalizeHtmlConstructorTemplateSettings(block.settings),
+ quickStyle: normalizeHtmlConstructorQuickStyle(block.quickStyle),
+ },
+ ];
+ });
+};
+
+export const createHtmlConstructorBlockId = () => Math.random().toString(36).slice(2, 10);
+
+const ID_REF_ATTRS = ['aria-labelledby', 'aria-controls', 'aria-describedby', 'aria-owns'];
+
+/** Give duplicated HTML fresh IDs while preserving its internal links and labels. */
+export const regenerateHtmlIds = (html: string): string => {
+ if (!html.trim() || typeof document === 'undefined') return html;
+
+ const template = document.createElement('template');
+ template.innerHTML = html;
+
+ const idMap = new Map();
+ template.content.querySelectorAll('[id]').forEach((element) => {
+ const oldId = element.getAttribute('id');
+ if (!oldId) return;
+
+ const newId = `${oldId}-${createHtmlConstructorBlockId()}`;
+ idMap.set(oldId, newId);
+ element.setAttribute('id', newId);
+ });
+
+ if (idMap.size === 0) return html;
+
+ template.content.querySelectorAll('*').forEach((element) => {
+ const forAttr = element.getAttribute('for');
+ if (forAttr && idMap.has(forAttr)) element.setAttribute('for', idMap.get(forAttr)!);
+
+ const href = element.getAttribute('href');
+ if (href?.startsWith('#') && idMap.has(href.slice(1))) {
+ element.setAttribute('href', `#${idMap.get(href.slice(1))!}`);
+ }
+
+ for (const attr of ID_REF_ATTRS) {
+ const value = element.getAttribute(attr);
+ if (!value) continue;
+
+ const mapped = value
+ .split(/\s+/)
+ .map((token) => idMap.get(token) ?? token)
+ .join(' ');
+ if (mapped !== value) element.setAttribute(attr, mapped);
+ }
+ });
+
+ return template.innerHTML;
+};
+
+export const cloneHtmlConstructorBlock = (block: HtmlConstructorBlock): HtmlConstructorBlock => ({
+ ...block,
+ id: createHtmlConstructorBlockId(),
+ content: regenerateHtmlIds(block.content),
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/preferences.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/preferences.ts
new file mode 100644
index 000000000..4791534e8
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/preferences.ts
@@ -0,0 +1,133 @@
+import {useEffect, useState} from 'react';
+
+/** Preferences shared by constructor instances and persisted when storage is available. */
+export interface HtmlConstructorPreferences {
+ /** Show the code editor as a single tabbed pane instead of side-by-side HTML/CSS. */
+ compactCodeView: boolean;
+}
+
+export const YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY =
+ 'gravity-md-editor:yfm-html-constructor:preferences';
+
+const DEFAULT_PREFERENCES: HtmlConstructorPreferences = {
+ compactCodeView: true,
+};
+
+const getStorage = (): Storage | null => {
+ if (typeof window === 'undefined') return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+};
+
+const normalize = (value: unknown): HtmlConstructorPreferences => {
+ if (typeof value !== 'object' || value === null) return DEFAULT_PREFERENCES;
+
+ const compactCodeView = (value as Record).compactCodeView;
+
+ return {
+ compactCodeView:
+ typeof compactCodeView === 'boolean'
+ ? compactCodeView
+ : DEFAULT_PREFERENCES.compactCodeView,
+ };
+};
+
+const readFromStorage = (fallback = DEFAULT_PREFERENCES): HtmlConstructorPreferences => {
+ const storage = getStorage();
+ if (!storage) return fallback;
+
+ let raw: string | null;
+ try {
+ raw = storage.getItem(YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY);
+ } catch {
+ return fallback;
+ }
+
+ try {
+ return raw ? normalize(JSON.parse(raw)) : DEFAULT_PREFERENCES;
+ } catch {
+ return DEFAULT_PREFERENCES;
+ }
+};
+
+let current = readFromStorage();
+let hasUnpersistedChanges = false;
+const listeners = new Set<() => void>();
+
+const emit = () => {
+ for (const listener of listeners) listener();
+};
+
+export const getHtmlConstructorPreferences = (): HtmlConstructorPreferences => {
+ if (!hasUnpersistedChanges) {
+ const stored = readFromStorage(current);
+ if (stored.compactCodeView !== current.compactCodeView) current = stored;
+ }
+ return current;
+};
+
+const handleStorage = (event: StorageEvent) => {
+ if (event.key !== null && event.key !== YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY) return;
+ if (event.storageArea && event.storageArea !== getStorage()) return;
+
+ const previous = current;
+ if (getHtmlConstructorPreferences() !== previous) emit();
+};
+
+export const setHtmlConstructorPreference = (
+ key: K,
+ value: HtmlConstructorPreferences[K],
+): void => {
+ if (getHtmlConstructorPreferences()[key] === value && !hasUnpersistedChanges) return;
+
+ current = {...current, [key]: value};
+ hasUnpersistedChanges = true;
+
+ const storage = getStorage();
+ if (storage) {
+ try {
+ storage.setItem(YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY, JSON.stringify(current));
+ hasUnpersistedChanges = false;
+ } catch {
+ // Storage may be full or unavailable; keep the in-memory value.
+ }
+ }
+
+ emit();
+};
+
+const subscribe = (listener: () => void): (() => void) => {
+ listeners.add(listener);
+ if (listeners.size === 1 && typeof window !== 'undefined') {
+ window.addEventListener('storage', handleStorage);
+ }
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0 && typeof window !== 'undefined') {
+ window.removeEventListener('storage', handleStorage);
+ }
+ };
+};
+
+export const useHtmlConstructorPreferences = (): HtmlConstructorPreferences => {
+ const [preferences, setPreferences] = useState(getHtmlConstructorPreferences);
+
+ useEffect(() => {
+ const update = () => setPreferences(getHtmlConstructorPreferences());
+ const unsubscribe = subscribe(update);
+ update();
+ return unsubscribe;
+ }, []);
+
+ return preferences;
+};
+
+export const useHtmlConstructorPreference = (
+ key: K,
+): [HtmlConstructorPreferences[K], (value: HtmlConstructorPreferences[K]) => void] => {
+ const value = useHtmlConstructorPreferences()[key];
+ return [value, (next) => setHtmlConstructorPreference(key, next)];
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/quickStyle.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/quickStyle.ts
new file mode 100644
index 000000000..c7643116f
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/quickStyle.ts
@@ -0,0 +1,172 @@
+import {quickStyleToCssVarDeclarations, quickStyleToReactVars} from './cssVariables';
+import type {
+ HtmlConstructorBorderStyle,
+ HtmlConstructorColorTheme,
+ HtmlConstructorQuickStyle,
+ HtmlConstructorThemedColor,
+} from './types';
+
+export const HTML_CONSTRUCTOR_BACKGROUND_COLORS = [
+ // Saturated row
+ '#d64545',
+ '#ec7a1c',
+ '#efb008',
+ '#2f9e54',
+ '#1aa6a6',
+ '#2f6fe0',
+ '#7a4fe0',
+ '#e34d86',
+ '#8b94a3',
+ '#1c1c20',
+ // Light row
+ '#f6d6d6',
+ '#fbe2cc',
+ '#fbefc6',
+ '#d6efde',
+ '#cdecec',
+ '#d6e3ff',
+ '#e4dafb',
+ '#fad7e6',
+ '#e7e9ec',
+ '#ffffff',
+] as const;
+
+export const HTML_CONSTRUCTOR_TEXT_COLORS = [
+ '',
+ '#d64545',
+ '#ec7a1c',
+ '#efb008',
+ '#2f9e54',
+ '#1aa6a6',
+ '#2f6fe0',
+ '#7a4fe0',
+ '#e34d86',
+ '#8b94a3',
+ '#1c1c20',
+ '#ffffff',
+] as const;
+
+/**
+ * Maps a palette color to an i18n key with its human-readable name, shown as the
+ * swatch tooltip. Keep in sync with the keysets in `src/i18n/yfm-html-constructor`.
+ */
+export const HTML_CONSTRUCTOR_COLOR_NAME_KEYS: Record = {
+ '#d64545': 'color_red',
+ '#ec7a1c': 'color_orange',
+ '#efb008': 'color_yellow',
+ '#2f9e54': 'color_green',
+ '#1aa6a6': 'color_teal',
+ '#2f6fe0': 'color_blue',
+ '#7a4fe0': 'color_purple',
+ '#e34d86': 'color_pink',
+ '#8b94a3': 'color_gray',
+ '#1c1c20': 'color_black',
+ '#ffffff': 'color_white',
+ '#f6d6d6': 'color_red_light',
+ '#fbe2cc': 'color_orange_light',
+ '#fbefc6': 'color_yellow_light',
+ '#d6efde': 'color_green_light',
+ '#cdecec': 'color_teal_light',
+ '#d6e3ff': 'color_blue_light',
+ '#e4dafb': 'color_purple_light',
+ '#fad7e6': 'color_pink_light',
+ '#e7e9ec': 'color_gray_light',
+};
+
+export const HTML_CONSTRUCTOR_BORDER_RADIUS = ['', '0', '6px', '12px', '20px', '999px'] as const;
+
+export const HTML_CONSTRUCTOR_BORDER_STYLES: HtmlConstructorBorderStyle[] = [
+ 'solid',
+ 'dashed',
+ 'dotted',
+ 'none',
+];
+
+const isObject = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null;
+
+const isColor = (value: unknown): value is string =>
+ typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
+
+const isBorderRadius = (value: unknown): value is string =>
+ typeof value === 'string' &&
+ (HTML_CONSTRUCTOR_BORDER_RADIUS as readonly string[]).includes(value);
+
+const isBorderStyle = (value: unknown): value is HtmlConstructorBorderStyle =>
+ typeof value === 'string' &&
+ HTML_CONSTRUCTOR_BORDER_STYLES.includes(value as HtmlConstructorBorderStyle);
+
+/**
+ * Accepts the themed `{light?, dark?}` shape and, for backward compatibility,
+ * a bare color string from before colors were theme-aware (applied to both).
+ */
+const normalizeThemedColor = (value: unknown): HtmlConstructorThemedColor | undefined => {
+ if (isColor(value)) return {light: value, dark: value};
+
+ if (!isObject(value)) return undefined;
+
+ const themed: HtmlConstructorThemedColor = {};
+ if (isColor(value.light)) themed.light = value.light;
+ if (isColor(value.dark)) themed.dark = value.dark;
+
+ return themed.light || themed.dark ? themed : undefined;
+};
+
+export const normalizeHtmlConstructorQuickStyle = (
+ value: unknown,
+): HtmlConstructorQuickStyle | undefined => {
+ if (!isObject(value)) return undefined;
+
+ const quickStyle: HtmlConstructorQuickStyle = {};
+
+ const background = normalizeThemedColor(value.background);
+ const textColor = normalizeThemedColor(value.textColor);
+ if (background) quickStyle.background = background;
+ if (textColor) quickStyle.textColor = textColor;
+ if (isBorderRadius(value.borderRadius)) quickStyle.borderRadius = value.borderRadius;
+ if (isBorderStyle(value.borderStyle)) quickStyle.borderStyle = value.borderStyle;
+
+ return Object.keys(quickStyle).length ? quickStyle : undefined;
+};
+
+/**
+ * Returns a themed color with `color` set (or cleared) for the given theme,
+ * or `undefined` when neither side remains — ready to drop the whole entry.
+ */
+export const setThemedColor = (
+ current: HtmlConstructorThemedColor | undefined,
+ theme: HtmlConstructorColorTheme,
+ color: string | undefined,
+): HtmlConstructorThemedColor | undefined => {
+ const next: HtmlConstructorThemedColor = {...current};
+
+ if (color) {
+ next[theme] = color;
+ } else {
+ delete next[theme];
+ }
+
+ return next.light || next.dark ? next : undefined;
+};
+
+/**
+ * Quick styles are serialized as CSS custom properties (the public theming
+ * contract) rather than concrete properties, so user themes can react to them.
+ * See {@link ./cssVariables}.
+ */
+export const htmlConstructorQuickStyleToReactStyle = quickStyleToReactVars;
+
+export const htmlConstructorQuickStyleToCss = quickStyleToCssVarDeclarations;
+
+export const getNextQuickStyle = (
+ quickStyle: HtmlConstructorQuickStyle | undefined,
+ patch: Partial,
+) => {
+ const next = {...quickStyle, ...patch};
+
+ for (const key of Object.keys(next) as (keyof HtmlConstructorQuickStyle)[]) {
+ if (!next[key]) delete next[key];
+ }
+
+ return next;
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/settings.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/settings.ts
new file mode 100644
index 000000000..236906e27
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/settings.ts
@@ -0,0 +1,67 @@
+import type {HtmlConstructorTemplateSettings} from './types';
+
+export const defaultHtmlConstructorTemplateSettings = (): HtmlConstructorTemplateSettings => ({
+ hasBackground: false,
+ hasRound: false,
+ hasBorder: false,
+ hasTextColor: false,
+ hasDelete: false,
+ hasRaw: false,
+ preset: 'default',
+});
+
+const allControls = {
+ hasBackground: true,
+ hasRound: true,
+ hasBorder: true,
+ hasTextColor: true,
+ hasDelete: true,
+ hasRaw: true,
+};
+
+const noControls = {
+ hasBackground: false,
+ hasRound: false,
+ hasBorder: false,
+ hasTextColor: false,
+ hasDelete: false,
+ hasRaw: false,
+};
+
+const isObject = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null;
+
+export const normalizeHtmlConstructorTemplateSettings = (
+ value: unknown,
+): HtmlConstructorTemplateSettings | undefined => {
+ if (!isObject(value)) return undefined;
+
+ const preset = value.preset;
+ if (preset !== 'default' && preset !== 'none' && preset !== 'disabled') return undefined;
+
+ return {
+ hasBackground: value.hasBackground === true,
+ hasRound: value.hasRound === true,
+ hasBorder: value.hasBorder === true,
+ hasTextColor: value.hasTextColor === true,
+ hasDelete: value.hasDelete === true,
+ hasRaw: value.hasRaw === true,
+ preset,
+ };
+};
+
+export const getEnabledHtmlConstructorSettings = (
+ settings: HtmlConstructorTemplateSettings | undefined,
+) => {
+ if (!settings || settings.preset === 'default') return allControls;
+ if (settings.preset === 'disabled') return noControls;
+
+ return {
+ hasBackground: settings.hasBackground,
+ hasRound: settings.hasRound,
+ hasBorder: settings.hasBorder,
+ hasTextColor: settings.hasTextColor,
+ hasDelete: settings.hasDelete,
+ hasRaw: settings.hasRaw,
+ };
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/index.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/index.ts
new file mode 100644
index 000000000..d56c34831
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/index.ts
@@ -0,0 +1,27 @@
+export type {
+ HtmlConstructorBlockTemplate,
+ HtmlConstructorFamilyTemplate,
+ HtmlConstructorStructureTemplate,
+ HtmlConstructorTemplate,
+ HtmlConstructorTemplateBlock,
+ HtmlConstructorTemplateSettings,
+ HtmlConstructorTemplateType,
+ HtmlConstructorThemeTemplate,
+ YfmHtmlConstructorOptions,
+} from '../types';
+
+export {
+ HtmlConstructorTemplateParseError,
+ parseRawBlock,
+ parseTemplateBlock,
+ parseTemplates,
+} from './parse';
+export {
+ YFM_HTML_CONSTRUCTOR_STORAGE_KEY,
+ clearStoredTemplates,
+ mergeTemplatesById,
+ readStoredTemplates,
+ saveTemplates,
+ subscribeStoredTemplates,
+ useStoredTemplates,
+} from './storage';
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/parse.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/parse.test.ts
new file mode 100644
index 000000000..87ab8d645
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/parse.test.ts
@@ -0,0 +1,226 @@
+import {readFileSync} from 'node:fs';
+import path from 'node:path';
+
+import type {
+ HtmlConstructorBlockTemplate,
+ HtmlConstructorStructureTemplate,
+ HtmlConstructorThemeTemplate,
+} from '../types';
+
+import {
+ HtmlConstructorTemplateParseError,
+ parseRawBlock,
+ parseTemplateBlock,
+ parseTemplates,
+} from './parse';
+
+const gravityUiLandingFixture = path.resolve(
+ __dirname,
+ '../../../../../../../demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html',
+);
+
+describe('parseRawBlock', () => {
+ it('keeps the root element verbatim instead of unwrapping it', () => {
+ expect(parseRawBlock('какой-то текст
')).toEqual({
+ css: '',
+ content: 'какой-то текст
',
+ });
+ });
+
+ it('trims and keeps plain text', () => {
+ expect(parseRawBlock(' Plain text ')).toEqual({
+ css: '',
+ content: 'Plain text',
+ });
+ });
+});
+
+describe('parseTemplateBlock', () => {
+ it('extracts top-level styles and removes nested styles', () => {
+ expect(
+ parseTemplateBlock(
+ '',
+ ),
+ ).toEqual({
+ css: '& { padding: 12px; }',
+ content: '',
+ });
+ });
+});
+
+describe('parseTemplates', () => {
+ it('parses the Gravity UI landing template pack', () => {
+ const result = parseTemplates(readFileSync(gravityUiLandingFixture, 'utf8'));
+
+ expect(result.filter((template) => template.type === 'family')).toHaveLength(1);
+ expect(result.filter((template) => template.type === 'structure')).toHaveLength(1);
+ expect(result.filter((template) => template.type === 'block')).toHaveLength(10);
+ expect(result.filter((template) => template.type === 'theme')).toHaveLength(8);
+ });
+
+ it('parses families, structures, blocks and themes', () => {
+ const result = parseTemplates(`
+
+
+ Cover
+
+
+
+
+
+
+
+ Card
+
+
+
+ Ignored
+
+ `);
+
+ expect(result[0]).toMatchObject({
+ type: 'family',
+ id: 'marketing',
+ title: 'Marketing',
+ styles: ['.cover { color: red; }'],
+ content: 'Cover
',
+ });
+ expect(result[1]).toMatchObject({
+ type: 'structure',
+ id: 'landing',
+ family: 'marketing',
+ settings: {hasRaw: true, preset: 'default'},
+ styles: ['.g-md-hc-structure { display: grid; }'],
+ });
+ expect(result[1]).not.toHaveProperty('content');
+ expect(result[2]).toMatchObject({
+ type: 'block',
+ id: 'card',
+ structure: 'landing',
+ styles: ['& { padding: 12px; }'],
+ content: 'Card',
+ });
+ expect(result[3]).toMatchObject({
+ type: 'theme',
+ id: 'compact',
+ structure: 'landing',
+ styles: ['.g-md-hc-structure { gap: 8px; }'],
+ });
+ });
+
+ it('sorts structures, blocks and themes by priority then declaration index', () => {
+ const result = parseTemplates(`
+
+
+
+
+
+
+ `);
+
+ expect(
+ result
+ .filter(
+ (template): template is HtmlConstructorStructureTemplate =>
+ template.type === 'structure',
+ )
+ .map((template) => template.id),
+ ).toEqual(['a', 'b']);
+ expect(
+ result
+ .filter(
+ (template): template is HtmlConstructorBlockTemplate =>
+ template.type === 'block',
+ )
+ .map((template) => template.id),
+ ).toEqual(['block-a', 'block-b']);
+ expect(
+ result
+ .filter(
+ (template): template is HtmlConstructorThemeTemplate =>
+ template.type === 'theme',
+ )
+ .map((template) => template.id),
+ ).toEqual(['theme-a', 'theme-b']);
+ });
+
+ it('accepts the optional version attribute on any template type', () => {
+ const result = parseTemplates(`
+
+
+ `);
+
+ expect(result[0]).toMatchObject({type: 'family', id: 'wikib2b', version: '1.0.0'});
+ expect(result[1]).toMatchObject({type: 'structure', id: 'page', version: '2.1.0'});
+ });
+
+ it('captures arbitrary data-* metadata on a family template', () => {
+ const [family] = parseTemplates(`
+
+ `);
+
+ expect(family).toMatchObject({
+ type: 'family',
+ id: 'wikib2b',
+ meta: {author: 'Wiki Team', category: 'marketing'},
+ });
+ });
+
+ it('parses none preset with explicitly enabled template controls', () => {
+ const [template] = parseTemplates(`
+
+ `);
+
+ expect(template).toMatchObject({
+ type: 'block',
+ settings: {
+ hasBackground: true,
+ hasRound: false,
+ hasBorder: true,
+ hasTextColor: false,
+ hasDelete: false,
+ hasRaw: false,
+ preset: 'none',
+ },
+ });
+ });
+
+ it.each([
+ ['unknown attribute', ''],
+ [
+ 'duplicate id',
+ '',
+ ],
+ ['bad family reference', ''],
+ [
+ 'bad structure reference',
+ '',
+ ],
+ ['bad block reference', ''],
+ ['invalid priority', ''],
+ ['invalid data-preset', ''],
+ ['theme settings', ''],
+ ['top-level non-template', ''],
+ ])('rejects %s', (_name, input) => {
+ expect(() => parseTemplates(input)).toThrow(HtmlConstructorTemplateParseError);
+ });
+
+ it('returns an empty array for blank input', () => {
+ expect(parseTemplates(' ')).toEqual([]);
+ expect(parseTemplates('')).toEqual([]);
+ });
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/parse.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/parse.ts
new file mode 100644
index 000000000..ffe93158c
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/parse.ts
@@ -0,0 +1,350 @@
+import type {
+ HtmlConstructorBlockTemplate,
+ HtmlConstructorFamilyTemplate,
+ HtmlConstructorStructureTemplate,
+ HtmlConstructorTemplate,
+ HtmlConstructorTemplateBlock,
+ HtmlConstructorTemplatePreset,
+ HtmlConstructorTemplateSettings,
+ HtmlConstructorTemplateType,
+ HtmlConstructorThemeTemplate,
+} from '../types';
+
+const TEMPLATE_TYPES: HtmlConstructorTemplateType[] = ['family', 'structure', 'block', 'theme'];
+const DATA_PRESETS: HtmlConstructorTemplatePreset[] = ['default', 'none', 'disabled'];
+const BOOLEAN_SETTING_ATTRS = [
+ 'data-has-background',
+ 'data-has-round',
+ 'data-has-border',
+ 'data-has-text-color',
+ 'data-has-delete',
+ 'data-has-raw',
+] as const;
+const COMMON_ATTRS = new Set([
+ 'type',
+ 'id',
+ 'title',
+ 'version',
+ 'family',
+ 'structure',
+ 'block',
+ 'priority',
+ 'data-preset',
+ ...BOOLEAN_SETTING_ATTRS,
+]);
+const FAMILY_ATTRS = new Set(['type', 'id', 'title', 'version']);
+const DATA_PREFIX = 'data-';
+const ELEMENT_NODE = 1;
+const TEXT_NODE = 3;
+
+export class HtmlConstructorTemplateParseError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'HtmlConstructorTemplateParseError';
+ }
+}
+
+const parseHtml = (value: string): Document | null => {
+ if (typeof document !== 'undefined') {
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = value;
+
+ return doc;
+ }
+
+ if (typeof DOMParser !== 'undefined') {
+ return new DOMParser().parseFromString(`${value}`, 'text/html');
+ }
+
+ if (typeof window !== 'undefined' && typeof window.DOMParser !== 'undefined') {
+ return new window.DOMParser().parseFromString(`${value}`, 'text/html');
+ }
+
+ return null;
+};
+
+const fail = (message: string): never => {
+ throw new HtmlConstructorTemplateParseError(message);
+};
+
+const isElementWithTag = (
+ node: ChildNode,
+ tagName: string,
+): node is TElement =>
+ node.nodeType === ELEMENT_NODE &&
+ (node as Element).tagName.toLowerCase() === tagName.toLowerCase();
+
+const isTemplateElement = (node: ChildNode): node is HTMLTemplateElement =>
+ isElementWithTag(node, 'template');
+
+const getAttr = (template: HTMLTemplateElement, name: string) =>
+ template.getAttribute(name)?.trim() ?? undefined;
+
+const hasAttr = (template: HTMLTemplateElement, name: string) => template.hasAttribute(name);
+
+const requireAttr = (template: HTMLTemplateElement, name: string) => {
+ const value = getAttr(template, name);
+ if (!value) fail(`Template is missing required "${name}" attribute.`);
+ return value as string;
+};
+
+const isTemplateType = (value: string): value is HtmlConstructorTemplateType =>
+ TEMPLATE_TYPES.includes(value as HtmlConstructorTemplateType);
+
+const parsePriority = (template: HTMLTemplateElement) => {
+ const value = getAttr(template, 'priority');
+ if (value === undefined) return 0;
+
+ const priority = Number(value);
+ if (value === '' || !Number.isFinite(priority)) {
+ fail(`Template "${getAttr(template, 'id') ?? ''}" has invalid priority.`);
+ }
+
+ return priority;
+};
+
+const parsePreset = (template: HTMLTemplateElement): HtmlConstructorTemplatePreset => {
+ const value = getAttr(template, 'data-preset');
+ if (value === undefined) return 'default';
+ if (!DATA_PRESETS.includes(value as HtmlConstructorTemplatePreset)) {
+ fail(`Template "${getAttr(template, 'id') ?? ''}" has invalid data-preset.`);
+ }
+ return value as HtmlConstructorTemplatePreset;
+};
+
+const parseSettings = (template: HTMLTemplateElement): HtmlConstructorTemplateSettings => ({
+ hasBackground: hasAttr(template, 'data-has-background'),
+ hasRound: hasAttr(template, 'data-has-round'),
+ hasBorder: hasAttr(template, 'data-has-border'),
+ hasTextColor: hasAttr(template, 'data-has-text-color'),
+ hasDelete: hasAttr(template, 'data-has-delete'),
+ hasRaw: hasAttr(template, 'data-has-raw'),
+ preset: parsePreset(template),
+});
+
+const normalizeStyleCss = (css: string) =>
+ css
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .join('\n');
+
+const getFragmentHtml = (fragment: DocumentFragment) => {
+ const host = fragment.ownerDocument.createElement('div');
+ host.append(...Array.from(fragment.childNodes).map((node) => node.cloneNode(true)));
+
+ return host.innerHTML.trim();
+};
+
+const extractTopLevelStyles = (fragment: DocumentFragment) => {
+ const styles: string[] = [];
+
+ for (const node of Array.from(fragment.childNodes)) {
+ if (isElementWithTag(node, 'style')) {
+ const css = normalizeStyleCss(node.textContent ?? '');
+ if (css) styles.push(css);
+ node.remove();
+ }
+ }
+
+ return styles;
+};
+
+const removeNestedStyles = (fragment: DocumentFragment) => {
+ for (const style of Array.from(fragment.querySelectorAll('style'))) {
+ style.remove();
+ }
+};
+
+const getTemplateParts = (template: HTMLTemplateElement) => {
+ const fragment = template.content.cloneNode(true) as DocumentFragment;
+ const styles = extractTopLevelStyles(fragment);
+ removeNestedStyles(fragment);
+
+ return {
+ styles,
+ content: getFragmentHtml(fragment),
+ };
+};
+
+const isDataAttr = (name: string) => name.startsWith(DATA_PREFIX);
+
+const validateAttributes = (template: HTMLTemplateElement, type: HtmlConstructorTemplateType) => {
+ for (const attr of Array.from(template.attributes)) {
+ // A family accepts type/id/title/version plus arbitrary `data-*` metadata,
+ // so it is validated separately from the other (settings-bound) types.
+ if (type === 'family') {
+ if (!FAMILY_ATTRS.has(attr.name) && !isDataAttr(attr.name)) {
+ fail(
+ `Family template "${getAttr(template, 'id') ?? ''}" has invalid attribute "${attr.name}".`,
+ );
+ }
+ continue;
+ }
+
+ if (!COMMON_ATTRS.has(attr.name)) fail(`Unknown template attribute "${attr.name}".`);
+ if (
+ type === 'theme' &&
+ (attr.name === 'data-preset' ||
+ BOOLEAN_SETTING_ATTRS.includes(attr.name as (typeof BOOLEAN_SETTING_ATTRS)[number]))
+ ) {
+ fail(`Theme template "${getAttr(template, 'id') ?? ''}" has settings attributes.`);
+ }
+ }
+};
+
+const parseFamilyMeta = (template: HTMLTemplateElement): Record | undefined => {
+ const meta: Record = {};
+ for (const attr of Array.from(template.attributes)) {
+ if (isDataAttr(attr.name)) meta[attr.name.slice(DATA_PREFIX.length)] = attr.value;
+ }
+ return Object.keys(meta).length ? meta : undefined;
+};
+
+const readReference = (template: HTMLTemplateElement, name: 'family' | 'structure' | 'block') => {
+ if (!template.hasAttribute(name)) return undefined;
+ const value = getAttr(template, name);
+ if (!value) fail(`Template "${getAttr(template, 'id') ?? ''}" has empty "${name}".`);
+ return value;
+};
+
+export const parseTemplateBlock = (content: string): HtmlConstructorTemplateBlock => {
+ const doc = parseHtml(`${content}`);
+ const template = doc?.body.firstElementChild;
+ if (!template || !isTemplateElement(template)) return {css: '', content: content.trim()};
+
+ const {styles, content: html} = getTemplateParts(template);
+ return {css: styles.join('\n\n'), content: html};
+};
+
+/** Keeps user HTML verbatim inside the block; the block div only carries constructor classes. */
+export const parseRawBlock = (content: string): HtmlConstructorTemplateBlock => ({
+ css: '',
+ content: content.trim(),
+});
+
+const byTemplateOrder = <
+ T extends
+ | HtmlConstructorStructureTemplate
+ | HtmlConstructorBlockTemplate
+ | HtmlConstructorThemeTemplate,
+>(
+ left: T,
+ right: T,
+) => left.priority - right.priority || left.declarationIndex - right.declarationIndex;
+
+/**
+ * Parses one or more top-level HTML Constructor `` elements.
+ * Blank input returns an empty list; invalid nonblank input throws HtmlConstructorTemplateParseError.
+ */
+export function parseTemplates(input: string): HtmlConstructorTemplate[] {
+ const value = input.trim();
+ if (!value) return [];
+
+ const doc = parseHtml(value) ?? fail('Template parser requires a DOM implementation.');
+
+ const familyTemplates: HtmlConstructorFamilyTemplate[] = [];
+ const structureTemplates: HtmlConstructorStructureTemplate[] = [];
+ const blockTemplates: HtmlConstructorBlockTemplate[] = [];
+ const themeTemplates: HtmlConstructorThemeTemplate[] = [];
+ const byId = new Map();
+
+ let declarationIndex = 0;
+
+ for (const node of Array.from(doc.body.childNodes)) {
+ if (node.nodeType === TEXT_NODE && !node.textContent?.trim()) continue;
+ const templateElement = isTemplateElement(node)
+ ? node
+ : fail('Template set contains a non-template top-level node.');
+
+ const rawTypeValue = requireAttr(templateElement, 'type');
+ if (!isTemplateType(rawTypeValue)) fail(`Unknown template type "${rawTypeValue}".`);
+ const typeValue = rawTypeValue as HtmlConstructorTemplateType;
+ validateAttributes(templateElement, typeValue);
+
+ const id = requireAttr(templateElement, 'id');
+ if (byId.has(id)) fail(`Duplicate template id "${id}".`);
+
+ const title = getAttr(templateElement, 'title');
+ const version = getAttr(templateElement, 'version');
+ const base = {id, type: typeValue, title, declarationIndex, ...(version ? {version} : {})};
+ declarationIndex += 1;
+
+ let template: HtmlConstructorTemplate;
+
+ if (typeValue === 'family') {
+ const familyTitle = title || fail(`Family template "${id}" is missing title.`);
+ // The HTML/CSS inside a family template is never rendered by the editor;
+ // it is captured only to be used as the family cover in the external
+ // templates marketplace.
+ const {styles, content} = getTemplateParts(templateElement);
+ const meta = parseFamilyMeta(templateElement);
+ const familyTemplate: HtmlConstructorFamilyTemplate = {
+ ...base,
+ type: 'family',
+ title: familyTitle,
+ styles,
+ content,
+ ...(meta ? {meta} : {}),
+ };
+ template = familyTemplate;
+ familyTemplates.push(familyTemplate);
+ } else {
+ const referencedBase = {
+ ...base,
+ family: readReference(templateElement, 'family'),
+ structure: readReference(templateElement, 'structure'),
+ block: readReference(templateElement, 'block'),
+ priority: parsePriority(templateElement),
+ };
+
+ if (typeValue === 'structure') {
+ // A structure is only a layout container for its blocks, so any
+ // markup it contains is discarded — only its styles are kept.
+ const {styles} = getTemplateParts(templateElement);
+ template = {
+ ...referencedBase,
+ type: 'structure',
+ settings: parseSettings(templateElement),
+ styles,
+ };
+ structureTemplates.push(template);
+ } else if (typeValue === 'block') {
+ const {styles, content} = getTemplateParts(templateElement);
+ template = {
+ ...referencedBase,
+ type: 'block',
+ settings: parseSettings(templateElement),
+ styles,
+ content,
+ };
+ blockTemplates.push(template);
+ } else {
+ const {styles} = getTemplateParts(templateElement);
+ template = {...referencedBase, type: 'theme', styles};
+ themeTemplates.push(template);
+ }
+ }
+
+ byId.set(id, template);
+ }
+
+ for (const template of [...structureTemplates, ...blockTemplates, ...themeTemplates]) {
+ if (template.family && byId.get(template.family)?.type !== 'family') {
+ fail(`Template "${template.id}" references missing family "${template.family}".`);
+ }
+ if (template.structure && byId.get(template.structure)?.type !== 'structure') {
+ fail(`Template "${template.id}" references missing structure "${template.structure}".`);
+ }
+ if (template.block && byId.get(template.block)?.type !== 'block') {
+ fail(`Template "${template.id}" references missing block "${template.block}".`);
+ }
+ }
+
+ return [
+ ...familyTemplates,
+ ...structureTemplates.sort(byTemplateOrder),
+ ...blockTemplates.sort(byTemplateOrder),
+ ...themeTemplates.sort(byTemplateOrder),
+ ];
+}
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/preview.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/preview.ts
new file mode 100644
index 000000000..e71bd80af
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/preview.ts
@@ -0,0 +1,51 @@
+import {
+ blockClass,
+ htmlConstructorBlockClass,
+ htmlConstructorStructureClass,
+ structureClass,
+} from '../css';
+import {buildPreviewCss} from '../document';
+import type {
+ HtmlConstructorBlockTemplate,
+ HtmlConstructorStructure,
+ HtmlConstructorStructureTemplate,
+ HtmlConstructorTemplate,
+ HtmlConstructorThemeTemplate,
+} from '../types';
+
+import {blockTemplateToBlock, structureTemplateToAttrs} from './state';
+
+const PREVIEW_STRUCTURE: HtmlConstructorStructure = {css: '', content: '', themeIds: []};
+
+/** Markup + CSS for previewing a single block template (optionally themed). */
+export const buildBlockPreviewParts = (
+ template: HtmlConstructorBlockTemplate,
+ theme?: HtmlConstructorThemeTemplate,
+) => {
+ const block = blockTemplateToBlock(template, theme);
+
+ return {
+ markup: `${block.content}
`,
+ css: buildPreviewCss({structure: PREVIEW_STRUCTURE, blocks: [block]}),
+ };
+};
+
+/** Markup + CSS for previewing a whole structure template with its blocks (optionally themed). */
+export const buildStructurePreviewParts = (
+ templates: HtmlConstructorTemplate[],
+ structure: HtmlConstructorStructureTemplate,
+ theme?: HtmlConstructorThemeTemplate,
+) => {
+ const {structure: state, blocks} = structureTemplateToAttrs(templates, structure, theme);
+ const blocksHtml = blocks
+ .map(
+ (block, index) =>
+ `${block.content}
`,
+ )
+ .join('');
+
+ return {
+ markup: `${state.content}${blocksHtml}
`,
+ css: buildPreviewCss({structure: state, blocks}),
+ };
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/state.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/state.ts
new file mode 100644
index 000000000..f90230eab
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/state.ts
@@ -0,0 +1,193 @@
+import {createHtmlConstructorBlockId} from '../model';
+import type {
+ HtmlConstructorBlock,
+ HtmlConstructorBlockTemplate,
+ HtmlConstructorStructure,
+ HtmlConstructorStructureTemplate,
+ HtmlConstructorTemplate,
+ HtmlConstructorTemplateBlock,
+ HtmlConstructorThemeTemplate,
+} from '../types';
+
+const mergeStyles = (...styles: string[][]) =>
+ styles
+ .flat()
+ .map((style) => style.trim())
+ .filter(Boolean)
+ .join('\n\n');
+
+const withThemeStyles = (styles: string[], theme: HtmlConstructorThemeTemplate | undefined) =>
+ mergeStyles(styles, theme?.styles ?? []);
+
+export const structureTemplateToState = (
+ template: HtmlConstructorStructureTemplate,
+ theme?: HtmlConstructorThemeTemplate,
+): HtmlConstructorStructure => ({
+ templateId: template.id,
+ css: withThemeStyles(template.styles, theme),
+ // Structures hold no markup of their own; their blocks provide all content.
+ content: '',
+ themeIds: theme ? [theme.id] : [],
+ settings: template.settings,
+});
+
+export const blockTemplateToBlock = (
+ template: HtmlConstructorBlockTemplate,
+ theme?: HtmlConstructorThemeTemplate,
+): HtmlConstructorBlock => ({
+ id: createHtmlConstructorBlockId(),
+ templateId: template.id,
+ css: withThemeStyles(template.styles, theme),
+ content: template.content,
+ themeIds: theme ? [theme.id] : [],
+ settings: template.settings,
+});
+
+export const applyStructureThemeToState = (
+ structure: HtmlConstructorStructure,
+ template: HtmlConstructorStructureTemplate,
+ theme?: HtmlConstructorThemeTemplate,
+): HtmlConstructorStructure => ({
+ ...structure,
+ templateId: template.id,
+ css: withThemeStyles(template.styles, theme),
+ themeIds: theme ? [theme.id] : [],
+});
+
+export const applyBlockTemplateToBlock = (
+ block: HtmlConstructorBlock,
+ template: HtmlConstructorBlockTemplate,
+ theme?: HtmlConstructorThemeTemplate,
+): HtmlConstructorBlock => ({
+ ...block,
+ templateId: template.id,
+ css: withThemeStyles(template.styles, theme),
+ content: template.content,
+ themeIds: theme ? [theme.id] : [],
+ settings: template.settings,
+});
+
+export const applyBlockThemeToBlock = (
+ block: HtmlConstructorBlock,
+ template: HtmlConstructorBlockTemplate,
+ theme?: HtmlConstructorThemeTemplate,
+): HtmlConstructorBlock => ({
+ ...block,
+ templateId: template.id,
+ css: withThemeStyles(template.styles, theme),
+ themeIds: theme ? [theme.id] : [],
+});
+
+export const rawTemplateBlockToBlock = (
+ block: HtmlConstructorTemplateBlock,
+): HtmlConstructorBlock => ({
+ id: createHtmlConstructorBlockId(),
+ css: block.css,
+ content: block.content,
+ themeIds: [],
+});
+
+const isDirectStructureBlock = (
+ template: HtmlConstructorTemplate,
+ structureId: string,
+): template is HtmlConstructorBlockTemplate =>
+ template.type === 'block' && template.structure === structureId && !template.block;
+
+export const getDirectStructureBlocks = (
+ templates: HtmlConstructorTemplate[],
+ structureId: string,
+) => templates.filter((template) => isDirectStructureBlock(template, structureId));
+
+export const structureTemplateToAttrs = (
+ templates: HtmlConstructorTemplate[],
+ structure: HtmlConstructorStructureTemplate,
+ theme?: HtmlConstructorThemeTemplate,
+) => ({
+ structure: structureTemplateToState(structure, theme),
+ blocks: getDirectStructureBlocks(templates, structure.id).map((block) =>
+ blockTemplateToBlock(block),
+ ),
+});
+
+const isStructureTemplate = (
+ template: HtmlConstructorTemplate,
+): template is HtmlConstructorStructureTemplate => template.type === 'structure';
+
+const isBlockTemplate = (
+ template: HtmlConstructorTemplate,
+): template is HtmlConstructorBlockTemplate => template.type === 'block';
+
+const isThemeTemplate = (
+ template: HtmlConstructorTemplate,
+): template is HtmlConstructorThemeTemplate => template.type === 'theme';
+
+const isCompatibleWithStructure = (
+ template: HtmlConstructorBlockTemplate | HtmlConstructorThemeTemplate,
+ activeStructureId: string | undefined,
+) => !activeStructureId || !template.structure || template.structure === activeStructureId;
+
+export const getStructureTemplateById = (
+ templates: HtmlConstructorTemplate[],
+ templateId: string | undefined,
+) =>
+ templates.find(
+ (template): template is HtmlConstructorStructureTemplate =>
+ isStructureTemplate(template) && template.id === templateId,
+ );
+
+export const getStructureThemeTemplates = (
+ templates: HtmlConstructorTemplate[],
+ structureId: string | undefined,
+) => {
+ if (!structureId) return [];
+
+ return templates.filter(
+ (template): template is HtmlConstructorThemeTemplate =>
+ isThemeTemplate(template) &&
+ !template.block &&
+ (!template.structure || template.structure === structureId),
+ );
+};
+
+export const getBlockTemplateById = (
+ templates: HtmlConstructorTemplate[],
+ templateId: string | undefined,
+) =>
+ templates.find(
+ (template): template is HtmlConstructorBlockTemplate =>
+ isBlockTemplate(template) && template.id === templateId,
+ );
+
+export const getBlockTemplateStateGroup = (
+ templates: HtmlConstructorTemplate[],
+ templateId: string | undefined,
+ activeStructureId: string | undefined,
+) => {
+ const currentTemplate = getBlockTemplateById(templates, templateId);
+ if (!currentTemplate) {
+ return {states: [], themesByBlockId: {} as Record};
+ }
+
+ const baseTemplateId = currentTemplate.block ?? currentTemplate.id;
+ const blocks = templates.filter(isBlockTemplate);
+ const themes = templates.filter(isThemeTemplate);
+ const baseTemplate = blocks.find((template) => template.id === baseTemplateId);
+ const states = [
+ ...(baseTemplate ? [baseTemplate] : []),
+ ...blocks.filter((template) => template.block === baseTemplateId),
+ ].filter((template) => isCompatibleWithStructure(template, activeStructureId));
+
+ return {
+ states,
+ themesByBlockId: Object.fromEntries(
+ states.map((state) => [
+ state.id,
+ themes.filter(
+ (theme) =>
+ theme.block === state.id &&
+ isCompatibleWithStructure(theme, activeStructureId),
+ ),
+ ]),
+ ) as Record,
+ };
+};
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.test.ts
new file mode 100644
index 000000000..86ed95e12
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.test.ts
@@ -0,0 +1,225 @@
+import type {HtmlConstructorTemplate} from '../types';
+
+import {
+ YFM_HTML_CONSTRUCTOR_STORAGE_KEY,
+ clearStoredTemplates,
+ mergeTemplatesById,
+ readStoredTemplates,
+ saveTemplates,
+ subscribeStoredTemplates,
+} from './storage';
+
+const settings = {
+ hasBackground: false,
+ hasRound: false,
+ hasBorder: false,
+ hasTextColor: false,
+ hasDelete: false,
+ hasRaw: false,
+ preset: 'default' as const,
+};
+
+const familyTpl = (id: string, title = id): HtmlConstructorTemplate => ({
+ id,
+ title,
+ type: 'family',
+ declarationIndex: 0,
+ styles: [],
+ content: '',
+});
+
+const structureTpl = (id: string, title = id, family?: string): HtmlConstructorTemplate => ({
+ id,
+ title,
+ family,
+ type: 'structure',
+ declarationIndex: 1,
+ priority: 0,
+ settings,
+ styles: ['.g-md-hc-structure { display: grid; }'],
+});
+
+const blockTpl = (id: string, title = id, family?: string): HtmlConstructorTemplate => ({
+ id,
+ title,
+ family,
+ type: 'block',
+ declarationIndex: 2,
+ priority: 0,
+ settings,
+ styles: ['& { padding: 1px; }'],
+ content: `${id}
`,
+});
+
+const themeTpl = (id: string, title = id, family?: string): HtmlConstructorTemplate => ({
+ id,
+ title,
+ family,
+ type: 'theme',
+ declarationIndex: 3,
+ priority: 0,
+ styles: ['.g-md-hc-block { color: red; }'],
+});
+
+beforeEach(() => {
+ jest.restoreAllMocks();
+ window.localStorage.clear();
+ clearStoredTemplates();
+});
+
+afterEach(() => jest.restoreAllMocks());
+
+describe('mergeTemplatesById', () => {
+ it('keeps order and overrides duplicates with the later source', () => {
+ const result = mergeTemplatesById(
+ [blockTpl('a', 'option a'), structureTpl('b')],
+ [blockTpl('a', 'stored a')],
+ );
+
+ expect(result).toEqual([blockTpl('a', 'stored a'), structureTpl('b')]);
+ });
+});
+
+describe('readStoredTemplates', () => {
+ it('returns an empty array when nothing is stored', () => {
+ expect(readStoredTemplates()).toEqual([]);
+ });
+
+ it('ignores malformed json', () => {
+ window.localStorage.setItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY, '{not json');
+ expect(readStoredTemplates()).toEqual([]);
+ });
+
+ it('filters out entries with the wrong shape', () => {
+ window.localStorage.setItem(
+ YFM_HTML_CONSTRUCTOR_STORAGE_KEY,
+ JSON.stringify([
+ familyTpl('family', 'Family'),
+ structureTpl('structure', 'Structure', 'family'),
+ blockTpl('block', 'Block', 'family'),
+ themeTpl('theme', 'Theme', 'family'),
+ {id: 'missing-type', title: 'Missing type', content: ''},
+ {id: 'wrong-type', title: 'Wrong type', type: 'section', content: ''},
+ {...blockTpl('bad-styles'), styles: [1]},
+ {...structureTpl('bad-settings'), settings: {preset: 'default'}},
+ {...themeTpl('bad-priority'), priority: Number.NaN},
+ ]),
+ );
+
+ expect(readStoredTemplates()).toEqual([
+ familyTpl('family', 'Family'),
+ structureTpl('structure', 'Structure', 'family'),
+ blockTpl('block', 'Block', 'family'),
+ themeTpl('theme', 'Theme', 'family'),
+ ]);
+ });
+});
+
+describe('saveTemplates', () => {
+ it('persists templates and merges by id across calls', () => {
+ saveTemplates([blockTpl('a', 'first')]);
+ const result = saveTemplates([blockTpl('a', 'second'), structureTpl('b')]);
+
+ expect(result).toEqual([blockTpl('a', 'second'), structureTpl('b')]);
+ expect(readStoredTemplates()).toEqual([blockTpl('a', 'second'), structureTpl('b')]);
+ });
+
+ it('keeps successive imports in memory when writes fail, then persists on retry', () => {
+ saveTemplates([blockTpl('stored')]);
+ const write = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('Storage is full', 'QuotaExceededError');
+ });
+
+ saveTemplates([blockTpl('first')]);
+ saveTemplates([blockTpl('second')]);
+
+ const expected = [blockTpl('stored'), blockTpl('first'), blockTpl('second')];
+ expect(readStoredTemplates()).toEqual(expected);
+ write.mockRestore();
+ saveTemplates([]);
+ expect(JSON.parse(window.localStorage.getItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY)!)).toEqual(
+ expected,
+ );
+ });
+
+ it('shares templates even when access to localStorage is denied', () => {
+ jest.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
+ throw new DOMException('Storage is blocked', 'SecurityError');
+ });
+
+ saveTemplates([blockTpl('first')]);
+ saveTemplates([blockTpl('second')]);
+
+ expect(readStoredTemplates()).toEqual([blockTpl('first'), blockTpl('second')]);
+ });
+});
+
+describe('clearStoredTemplates', () => {
+ it('removes saved templates from localStorage', () => {
+ saveTemplates([blockTpl('a'), structureTpl('b')]);
+
+ expect(clearStoredTemplates()).toEqual([]);
+ expect(readStoredTemplates()).toEqual([]);
+ expect(window.localStorage.getItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY)).toBeNull();
+ });
+
+ it('does not resurrect cleared templates when removing persisted data fails', () => {
+ saveTemplates([blockTpl('old')]);
+ jest.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
+ throw new DOMException('Storage is blocked', 'SecurityError');
+ });
+
+ clearStoredTemplates();
+
+ expect(readStoredTemplates()).toEqual([]);
+ expect(saveTemplates([blockTpl('new')])).toEqual([blockTpl('new')]);
+ });
+});
+
+describe('template subscriptions', () => {
+ it('notifies all active subscribers on import and clear', () => {
+ const first = jest.fn();
+ const second = jest.fn();
+ const unsubscribeFirst = subscribeStoredTemplates(first);
+ const unsubscribeSecond = subscribeStoredTemplates(second);
+
+ try {
+ saveTemplates([blockTpl('shared')]);
+ expect(first).toHaveBeenCalledTimes(1);
+ expect(second).toHaveBeenCalledTimes(1);
+ unsubscribeFirst();
+ clearStoredTemplates();
+ expect(first).toHaveBeenCalledTimes(1);
+ expect(second).toHaveBeenCalledTimes(2);
+ } finally {
+ unsubscribeFirst();
+ unsubscribeSecond();
+ }
+ });
+
+ it('refreshes templates after another tab saves or clears localStorage', () => {
+ const listener = jest.fn();
+ const unsubscribe = subscribeStoredTemplates(listener);
+
+ try {
+ const value = JSON.stringify([blockTpl('other-tab')]);
+ window.localStorage.setItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY, value);
+ window.dispatchEvent(
+ new StorageEvent('storage', {
+ key: YFM_HTML_CONSTRUCTOR_STORAGE_KEY,
+ newValue: value,
+ storageArea: window.localStorage,
+ }),
+ );
+ expect(readStoredTemplates()).toEqual([blockTpl('other-tab')]);
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ window.localStorage.clear();
+ window.dispatchEvent(new StorageEvent('storage', {key: null}));
+ expect(readStoredTemplates()).toEqual([]);
+ expect(listener).toHaveBeenCalledTimes(2);
+ } finally {
+ unsubscribe();
+ }
+ });
+});
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.ts
new file mode 100644
index 000000000..7921fb9e1
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.ts
@@ -0,0 +1,208 @@
+import {useEffect, useState} from 'react';
+
+import type {
+ HtmlConstructorBlockTemplate,
+ HtmlConstructorFamilyTemplate,
+ HtmlConstructorStructureTemplate,
+ HtmlConstructorTemplate,
+ HtmlConstructorTemplateSettings,
+ HtmlConstructorThemeTemplate,
+} from '../types';
+
+export const YFM_HTML_CONSTRUCTOR_STORAGE_KEY = 'gravity-md-editor:yfm-html-constructor';
+
+const isObject = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null;
+
+const isStringArray = (value: unknown): value is string[] =>
+ Array.isArray(value) && value.every((item) => typeof item === 'string');
+
+const isOptionalString = (value: unknown): value is string | undefined =>
+ value === undefined || typeof value === 'string';
+
+const isOptionalStringRecord = (value: unknown): value is Record | undefined =>
+ value === undefined ||
+ (isObject(value) && Object.values(value).every((item) => typeof item === 'string'));
+
+const isSettings = (value: unknown): value is HtmlConstructorTemplateSettings =>
+ isObject(value) &&
+ typeof value.hasBackground === 'boolean' &&
+ typeof value.hasRound === 'boolean' &&
+ typeof value.hasBorder === 'boolean' &&
+ typeof value.hasTextColor === 'boolean' &&
+ typeof value.hasDelete === 'boolean' &&
+ typeof value.hasRaw === 'boolean' &&
+ (value.preset === 'default' || value.preset === 'none' || value.preset === 'disabled');
+
+const isTemplateBase = (value: unknown): value is Record =>
+ isObject(value) &&
+ typeof value.id === 'string' &&
+ typeof value.declarationIndex === 'number' &&
+ Number.isFinite(value.declarationIndex) &&
+ isOptionalString(value.title) &&
+ isOptionalString(value.version);
+
+const isReferencedTemplateBase = (
+ value: unknown,
+): value is
+ | HtmlConstructorStructureTemplate
+ | HtmlConstructorBlockTemplate
+ | HtmlConstructorThemeTemplate =>
+ isTemplateBase(value) &&
+ isOptionalString(value.family) &&
+ isOptionalString(value.structure) &&
+ isOptionalString(value.block) &&
+ typeof value.priority === 'number' &&
+ Number.isFinite(value.priority);
+
+const isFamilyTemplate = (value: unknown): value is HtmlConstructorFamilyTemplate =>
+ isTemplateBase(value) &&
+ value.type === 'family' &&
+ typeof value.title === 'string' &&
+ typeof value.content === 'string' &&
+ isStringArray(value.styles) &&
+ isOptionalStringRecord(value.meta);
+
+const isStructureTemplate = (value: unknown): value is HtmlConstructorStructureTemplate =>
+ isReferencedTemplateBase(value) &&
+ value.type === 'structure' &&
+ isSettings(value.settings) &&
+ isStringArray(value.styles);
+
+const isBlockTemplate = (value: unknown): value is HtmlConstructorBlockTemplate =>
+ isReferencedTemplateBase(value) &&
+ value.type === 'block' &&
+ isSettings(value.settings) &&
+ isStringArray(value.styles) &&
+ typeof value.content === 'string';
+
+const isThemeTemplate = (value: unknown): value is HtmlConstructorThemeTemplate =>
+ isReferencedTemplateBase(value) && value.type === 'theme' && isStringArray(value.styles);
+
+const isHtmlConstructorTemplate = (value: unknown): value is HtmlConstructorTemplate =>
+ isFamilyTemplate(value) ||
+ isStructureTemplate(value) ||
+ isBlockTemplate(value) ||
+ isThemeTemplate(value);
+
+const getStorage = (): Storage | null => {
+ if (typeof window === 'undefined') return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+};
+
+let currentTemplates: HtmlConstructorTemplate[] = [];
+let storedValue: string | null | undefined;
+let hasUnpersistedChanges = false;
+const listeners = new Set<() => void>();
+
+const parseStoredTemplates = (raw: string | null): HtmlConstructorTemplate[] => {
+ try {
+ const parsed: unknown = raw ? JSON.parse(raw) : [];
+ return Array.isArray(parsed) ? parsed.filter(isHtmlConstructorTemplate) : [];
+ } catch {
+ return [];
+ }
+};
+
+export function readStoredTemplates(): HtmlConstructorTemplate[] {
+ // Failed writes must not be replaced by an older persisted snapshot.
+ if (hasUnpersistedChanges) return currentTemplates;
+
+ const storage = getStorage();
+ if (!storage) return currentTemplates;
+
+ try {
+ const raw = storage.getItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY);
+ if (raw !== storedValue) {
+ currentTemplates = parseStoredTemplates(raw);
+ storedValue = raw;
+ }
+ } catch {
+ // Keep the last readable snapshot when storage becomes unavailable.
+ }
+
+ return currentTemplates;
+}
+
+const emit = () => {
+ for (const listener of listeners) listener();
+};
+
+const writeTemplates = (next: HtmlConstructorTemplate[], clear = false) => {
+ currentTemplates = next;
+ hasUnpersistedChanges = true;
+
+ const storage = getStorage();
+ if (storage) {
+ try {
+ const raw = clear ? null : JSON.stringify(next);
+ if (raw === null) storage.removeItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY);
+ else storage.setItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY, raw);
+ storedValue = raw;
+ hasUnpersistedChanges = false;
+ } catch {
+ // Subsequent imports continue from the shared in-memory snapshot.
+ }
+ }
+
+ emit();
+ return currentTemplates;
+};
+
+export function saveTemplates(next: HtmlConstructorTemplate[]): HtmlConstructorTemplate[] {
+ return writeTemplates(mergeTemplatesById(readStoredTemplates(), next));
+}
+
+export function clearStoredTemplates(): HtmlConstructorTemplate[] {
+ return writeTemplates([], true);
+}
+
+const handleStorage = (event: StorageEvent) => {
+ if (event.key !== null && event.key !== YFM_HTML_CONSTRUCTOR_STORAGE_KEY) return;
+ if (event.storageArea && event.storageArea !== getStorage()) return;
+
+ const previous = currentTemplates;
+ if (readStoredTemplates() !== previous) emit();
+};
+
+export const subscribeStoredTemplates = (listener: () => void): (() => void) => {
+ listeners.add(listener);
+ if (listeners.size === 1 && typeof window !== 'undefined') {
+ window.addEventListener('storage', handleStorage);
+ }
+
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0 && typeof window !== 'undefined') {
+ window.removeEventListener('storage', handleStorage);
+ }
+ };
+};
+
+export const useStoredTemplates = (): HtmlConstructorTemplate[] => {
+ const [templates, setTemplates] = useState(readStoredTemplates);
+
+ useEffect(() => {
+ const update = () => setTemplates(readStoredTemplates());
+ const unsubscribe = subscribeStoredTemplates(update);
+ update();
+ return unsubscribe;
+ }, []);
+
+ return templates;
+};
+
+/** Later templates override earlier ones with the same id; order is preserved. */
+export function mergeTemplatesById(
+ ...sources: HtmlConstructorTemplate[][]
+): HtmlConstructorTemplate[] {
+ const byId = new Map();
+ for (const template of sources.flat()) {
+ byId.set(template.id, template);
+ }
+ return [...byId.values()];
+}
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/useTemplateCatalog.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/useTemplateCatalog.ts
new file mode 100644
index 000000000..82418653b
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/useTemplateCatalog.ts
@@ -0,0 +1,29 @@
+import {useMemo} from 'react';
+
+import type {YfmHtmlConstructorOptions} from '../types';
+
+import {getStructureTemplateById, getStructureThemeTemplates} from './state';
+import {mergeTemplatesById, useStoredTemplates} from './storage';
+
+export const useTemplateCatalog = (
+ options: YfmHtmlConstructorOptions | undefined,
+ structureId?: string,
+) => {
+ const stored = useStoredTemplates();
+ return useMemo(() => {
+ const items = mergeTemplatesById(options?.items ?? [], stored);
+ const allowAdd = Boolean(options?.allowAdd);
+ return {
+ items,
+ allowAdd,
+ hasStored: stored.length > 0,
+ showButton:
+ Boolean(options?.showButton) &&
+ (allowAdd || items.some((item) => item.type === 'structure')),
+ activeStructure: getStructureTemplateById(items, structureId),
+ themes: getStructureThemeTemplates(items, structureId),
+ };
+ }, [options, stored, structureId]);
+};
+
+export type TemplateCatalog = ReturnType;
diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/types.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/types.ts
new file mode 100644
index 000000000..d7a6eea8c
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/types.ts
@@ -0,0 +1,141 @@
+export type HtmlConstructorTemplateType = 'family' | 'structure' | 'block' | 'theme';
+
+export type HtmlConstructorTemplatePreset = 'default' | 'none' | 'disabled';
+
+export type HtmlConstructorTemplateSettings = {
+ hasBackground: boolean;
+ hasRound: boolean;
+ hasBorder: boolean;
+ hasTextColor: boolean;
+ hasDelete: boolean;
+ hasRaw: boolean;
+ preset: HtmlConstructorTemplatePreset;
+};
+
+export type HtmlConstructorBorderStyle = 'solid' | 'dashed' | 'dotted' | 'none';
+
+export type HtmlConstructorColorTheme = 'light' | 'dark';
+
+/**
+ * A color the user can pick separately for the light and the dark color theme.
+ * The block consumes the value matching the reader's active theme, so a single
+ * quick-style choice can invert between themes.
+ */
+export type HtmlConstructorThemedColor = {
+ light?: string;
+ dark?: string;
+};
+
+export type HtmlConstructorQuickStyle = {
+ background?: HtmlConstructorThemedColor;
+ textColor?: HtmlConstructorThemedColor;
+ borderRadius?: string;
+ borderStyle?: HtmlConstructorBorderStyle;
+};
+
+interface HtmlConstructorTemplateBase {
+ id: string;
+ type: HtmlConstructorTemplateType;
+ title?: string;
+ /** Optional semver tag. Recommended for `family`, allowed for any type. */
+ version?: string;
+ declarationIndex: number;
+}
+
+interface HtmlConstructorReferencedTemplateBase extends HtmlConstructorTemplateBase {
+ family?: string;
+ structure?: string;
+ block?: string;
+ priority: number;
+}
+
+export interface HtmlConstructorFamilyTemplate extends HtmlConstructorTemplateBase {
+ type: 'family';
+ title: string;
+ /**
+ * Family-level HTML used as the family cover in the templates marketplace
+ * (an external resource — not the editor). The editor itself never renders it.
+ */
+ content: string;
+ /**
+ * Family-level CSS that styles the marketplace cover. Ignored by the editor.
+ */
+ styles: string[];
+ /**
+ * Free-form metadata declared via `data-*` attributes on the family template
+ * (key is the part after `data-`). Unlike structure/block/theme, where `data-*`
+ * is restricted to settings, a family may carry arbitrary meta.
+ */
+ meta?: Record;
+}
+
+export interface HtmlConstructorStructureTemplate extends HtmlConstructorReferencedTemplateBase {
+ type: 'structure';
+ settings: HtmlConstructorTemplateSettings;
+ /**
+ * Structure-level CSS (layout). A structure is a container composed only of
+ * its blocks; any non-block markup inside the structure template is discarded.
+ */
+ styles: string[];
+}
+
+export interface HtmlConstructorBlockTemplate extends HtmlConstructorReferencedTemplateBase {
+ type: 'block';
+ settings: HtmlConstructorTemplateSettings;
+ styles: string[];
+ content: string;
+}
+
+export interface HtmlConstructorThemeTemplate extends HtmlConstructorReferencedTemplateBase {
+ type: 'theme';
+ styles: string[];
+}
+
+export type HtmlConstructorTemplate =
+ | HtmlConstructorFamilyTemplate
+ | HtmlConstructorStructureTemplate
+ | HtmlConstructorBlockTemplate
+ | HtmlConstructorThemeTemplate;
+
+export type HtmlConstructorRenderableTemplate =
+ | HtmlConstructorStructureTemplate
+ | HtmlConstructorBlockTemplate;
+
+export type HtmlConstructorTemplateBlock = {
+ css: string;
+ content: string;
+};
+
+export type HtmlConstructorStructure = {
+ templateId?: string;
+ css: string;
+ content: string;
+ themeIds: string[];
+ settings?: HtmlConstructorTemplateSettings;
+ quickStyle?: HtmlConstructorQuickStyle;
+};
+
+export type HtmlConstructorBlock = {
+ id: string;
+ templateId?: string;
+ css: string;
+ content: string;
+ themeIds: string[];
+ settings?: HtmlConstructorTemplateSettings;
+ quickStyle?: HtmlConstructorQuickStyle;
+};
+
+export interface YfmHtmlConstructorOptions {
+ /** Templates provided via extension options; read-only source. */
+ items?: HtmlConstructorTemplate[];
+ /** Show the structure templates button in the block toolbar. */
+ showButton?: boolean;
+ /** Allow template imports; shared in memory and persisted when storage is available. */
+ allowAdd?: boolean;
+}
+
+export interface YfmHtmlConstructorExtensionOptions {
+ templates?: YfmHtmlConstructorOptions;
+ /** Scope exported CSS to one constructor. Editor previews are always scoped. */
+ scopeStyles?: boolean;
+}
diff --git a/packages/editor/src/i18n/yfm-html-constructor/en.json b/packages/editor/src/i18n/yfm-html-constructor/en.json
new file mode 100644
index 000000000..0b6e4ba73
--- /dev/null
+++ b/packages/editor/src/i18n/yfm-html-constructor/en.json
@@ -0,0 +1,121 @@
+{
+ "add_attribute": "Add attribute",
+ "add_block": "Add block",
+ "add_template": "Import",
+ "attribute_name": "name",
+ "attribute_value": "value",
+ "attributes": "Attributes",
+ "auto": "Auto",
+ "background_color": "Background color",
+ "border": "Border",
+ "border_default": "Default",
+ "border_dashed": "Dashed",
+ "border_dotted": "Dotted",
+ "border_none": "No border",
+ "border_solid": "Solid",
+ "block_actions": "Block {{index}} actions",
+ "block_css": "Block {{index}} CSS",
+ "block_html_input_placeholder": "Block HTML
",
+ "block_html_placeholder": "",
+ "block_placeholder": "Empty block",
+ "block_templates_empty": "No block templates",
+ "blocks_title": "Select block",
+ "cancel": "Cancel",
+ "change": "Change",
+ "clear": "Clear",
+ "clear_templates": "Clear all templates",
+ "close": "Close",
+ "color_black": "Black",
+ "color_blue": "Blue",
+ "color_blue_light": "Light blue",
+ "color_gray": "Gray",
+ "color_gray_light": "Light gray",
+ "color_green": "Green",
+ "color_green_light": "Light green",
+ "color_orange": "Orange",
+ "color_orange_light": "Light orange",
+ "color_pink": "Pink",
+ "color_pink_light": "Light pink",
+ "color_purple": "Purple",
+ "color_purple_light": "Light purple",
+ "color_red": "Red",
+ "color_red_light": "Light red",
+ "color_teal": "Teal",
+ "color_teal_light": "Light teal",
+ "color_white": "White",
+ "color_yellow": "Yellow",
+ "color_yellow_light": "Light yellow",
+ "confirm_change_state_message": "The selected state will overwrite the current content of this block.",
+ "confirm_change_state_title": "Change block state?",
+ "confirm_clear_templates_message": "All saved templates will be permanently deleted.",
+ "confirm_clear_templates_title": "Clear all templates?",
+ "confirm_remove_block_message": "This block and its content will be deleted.",
+ "confirm_remove_block_title": "Delete block?",
+ "confirm_remove_constructor_action": "Discard",
+ "confirm_remove_constructor_message": "Everything you've added here will be permanently removed.",
+ "confirm_remove_constructor_title": "Discard your work?",
+ "confirm_replace_structure_message": "The selected structure will overwrite the current content of this constructor.",
+ "confirm_replace_structure_title": "Replace content?",
+ "continue": "Continue",
+ "compact_view": "Compact view",
+ "custom_block": "Custom block",
+ "custom_html": "Custom HTML",
+ "custom_structure": "Custom structure",
+ "css": "CSS",
+ "duplicate_block": "Duplicate block",
+ "duplicate_constructor": "Duplicate constructor",
+ "drag_block": "Drag block {{index}}",
+ "edit_element": "Edit element",
+ "edit_icon": "Replace icon",
+ "edit_image_src": "Edit image source",
+ "edit_link": "Edit link",
+ "edit_link_href": "URL",
+ "edit_link_text": "Text",
+ "edit_text": "Edit text",
+ "edit_target_changed": "This element has changed. Copy your edits, then reopen it.",
+ "html": "HTML",
+ "icon": "Icon",
+ "initial_subtitle": "Create beautiful HTML blocks right in editor.",
+ "insert": "Insert",
+ "invalid_attribute_name": "Attribute names cannot contain spaces or invalid characters.",
+ "lock_block": "Lock block",
+ "lock_constructor": "Lock constructor",
+ "more_actions": "More actions",
+ "no_text": "This element has no editable text",
+ "remove_attribute": "Remove attribute",
+ "remove_block": "Delete block",
+ "remove_constructor": "Remove constructor",
+ "replace": "Replace",
+ "reset": "Reset",
+ "round_default": "Default",
+ "round_none": "No radius",
+ "round_pill": "Pill",
+ "rounding": "Rounding",
+ "save": "Save",
+ "search_structures": "Search structure…",
+ "search_templates": "Search by name",
+ "show_code": "Show code",
+ "select_palette": "Select palette",
+ "select_state": "Select state",
+ "select_structure": "Select structure",
+ "select_theme": "Select theme",
+ "states_empty": "No block states",
+ "structure_settings": "Structure settings",
+ "structure_templates": "Structure templates",
+ "structure_templates_empty": "No structure templates",
+ "structures_title": "Select structure",
+ "text": "Text",
+ "text_color": "Text color",
+ "theme_dark": "Dark",
+ "theme_light": "Light",
+ "themes_count": ["{{count}} theme", "{{count}} themes", "{{count}} themes", "{{count}} themes"],
+ "themes_empty": "No themes",
+ "variants_count": [
+ "{{count}} variant",
+ "{{count}} variants",
+ "{{count}} variants",
+ "{{count}} variants"
+ ],
+ "templates_input_placeholder": "\n\n\n \n \n\n\n\n \n Card text
\n\n\n\n \n",
+ "templates_parse_error": "Could not parse templates"
+}
diff --git a/packages/editor/src/i18n/yfm-html-constructor/index.ts b/packages/editor/src/i18n/yfm-html-constructor/index.ts
new file mode 100644
index 000000000..14925fd56
--- /dev/null
+++ b/packages/editor/src/i18n/yfm-html-constructor/index.ts
@@ -0,0 +1,8 @@
+import {registerKeyset} from '../i18n';
+
+import en from './en.json';
+import ru from './ru.json';
+
+const KEYSET = 'yfm-html-constructor';
+
+export const i18n = registerKeyset(KEYSET, {en, ru});
diff --git a/packages/editor/src/i18n/yfm-html-constructor/ru.json b/packages/editor/src/i18n/yfm-html-constructor/ru.json
new file mode 100644
index 000000000..1b3127598
--- /dev/null
+++ b/packages/editor/src/i18n/yfm-html-constructor/ru.json
@@ -0,0 +1,121 @@
+{
+ "add_attribute": "Добавить атрибут",
+ "add_block": "Добавить блок",
+ "add_template": "Импорт",
+ "attribute_name": "имя",
+ "attribute_value": "значение",
+ "attributes": "Атрибуты",
+ "auto": "Авто",
+ "background_color": "Цвет фона",
+ "border": "Рамка",
+ "border_default": "По умолчанию",
+ "border_dashed": "Пунктирная",
+ "border_dotted": "Точечная",
+ "border_none": "Без рамки",
+ "border_solid": "Сплошная",
+ "block_actions": "Действия блока {{index}}",
+ "block_css": "CSS блока {{index}}",
+ "block_html_input_placeholder": "HTML блока
",
+ "block_html_placeholder": "",
+ "block_placeholder": "Пустой блок",
+ "block_templates_empty": "Нет шаблонов блоков",
+ "blocks_title": "Выбор блока",
+ "cancel": "Отмена",
+ "change": "Заменить",
+ "clear": "Очистить",
+ "clear_templates": "Очистить все шаблоны",
+ "close": "Закрыть",
+ "color_black": "Чёрный",
+ "color_blue": "Синий",
+ "color_blue_light": "Синий светлый",
+ "color_gray": "Серый",
+ "color_gray_light": "Серый светлый",
+ "color_green": "Зелёный",
+ "color_green_light": "Зелёный светлый",
+ "color_orange": "Оранжевый",
+ "color_orange_light": "Оранжевый светлый",
+ "color_pink": "Розовый",
+ "color_pink_light": "Розовый светлый",
+ "color_purple": "Фиолетовый",
+ "color_purple_light": "Фиолетовый светлый",
+ "color_red": "Красный",
+ "color_red_light": "Красный светлый",
+ "color_teal": "Бирюзовый",
+ "color_teal_light": "Бирюзовый светлый",
+ "color_white": "Белый",
+ "color_yellow": "Жёлтый",
+ "color_yellow_light": "Жёлтый светлый",
+ "confirm_change_state_message": "Выбранное состояние перезапишет текущее содержимое этого блока.",
+ "confirm_change_state_title": "Заменить состояние блока?",
+ "confirm_clear_templates_message": "Все сохранённые шаблоны будут безвозвратно удалены.",
+ "confirm_clear_templates_title": "Очистить все шаблоны?",
+ "confirm_remove_block_message": "Этот блок и его содержимое будут удалены.",
+ "confirm_remove_block_title": "Удалить блок?",
+ "confirm_remove_constructor_action": "Удалить наработки",
+ "confirm_remove_constructor_message": "Всё, что вы здесь добавили, будет безвозвратно удалено.",
+ "confirm_remove_constructor_title": "Удалить наработки?",
+ "confirm_replace_structure_message": "Выбранная структура перезапишет текущее содержимое этого конструктора.",
+ "confirm_replace_structure_title": "Заменить содержимое?",
+ "continue": "Продолжить",
+ "compact_view": "Компактный вид",
+ "custom_block": "Свой блок",
+ "custom_html": "Свой HTML",
+ "custom_structure": "Своя структура",
+ "css": "CSS",
+ "duplicate_block": "Дублировать блок",
+ "duplicate_constructor": "Дублировать конструктор",
+ "drag_block": "Перетащить блок {{index}}",
+ "edit_element": "Редактировать элемент",
+ "edit_icon": "Заменить иконку",
+ "edit_image_src": "Изменить ссылку картинки",
+ "edit_link": "Изменить ссылку",
+ "edit_link_href": "URL",
+ "edit_link_text": "Текст",
+ "edit_text": "Изменить текст",
+ "edit_target_changed": "Этот элемент изменился. Скопируйте правки и откройте его заново.",
+ "html": "HTML",
+ "icon": "Иконка",
+ "initial_subtitle": "Создавайте красивые HTML-блоки прямо в редакторе.",
+ "insert": "Вставить",
+ "invalid_attribute_name": "В имени атрибута не должно быть пробелов и недопустимых символов.",
+ "lock_block": "Заблокировать блок",
+ "lock_constructor": "Заблокировать конструктор",
+ "more_actions": "Другие действия",
+ "no_text": "У этого элемента нет редактируемого текста",
+ "remove_attribute": "Удалить атрибут",
+ "remove_block": "Удалить блок",
+ "remove_constructor": "Удалить конструктор",
+ "replace": "Заменить",
+ "reset": "Сбросить",
+ "round_default": "По умолчанию",
+ "round_none": "Без скругления",
+ "round_pill": "Пилюля",
+ "rounding": "Скругление",
+ "save": "Сохранить",
+ "search_structures": "Поиск структуры…",
+ "search_templates": "Поиск по названию",
+ "show_code": "Показать код",
+ "select_palette": "Выбрать палитру",
+ "select_state": "Выбрать состояние",
+ "select_structure": "Выбрать структуру",
+ "select_theme": "Выбрать тему",
+ "states_empty": "Нет состояний блока",
+ "structure_settings": "Настройки структуры",
+ "structure_templates": "Шаблоны структур",
+ "structure_templates_empty": "Нет шаблонов структур",
+ "structures_title": "Выбор структуры",
+ "text": "Текст",
+ "text_color": "Цвет текста",
+ "theme_dark": "Тёмная",
+ "theme_light": "Светлая",
+ "themes_count": ["{{count}} тема", "{{count}} темы", "{{count}} тем", "{{count}} тем"],
+ "themes_empty": "Нет тем",
+ "variants_count": [
+ "{{count}} вариант",
+ "{{count}} варианта",
+ "{{count}} вариантов",
+ "{{count}} вариантов"
+ ],
+ "templates_input_placeholder": "\n\n\n \n \n\n\n\n \n Card text
\n\n\n\n \n",
+ "templates_parse_error": "Не удалось разобрать шаблоны"
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 410bbfebc..c33633365 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -467,6 +467,12 @@ importers:
orderedmap:
specifier: ^2.1.1
version: 2.1.1
+ postcss:
+ specifier: catalog:linters
+ version: 8.5.10
+ postcss-selector-parser:
+ specifier: ^7.1.1
+ version: 7.1.1
prosemirror-autocomplete:
specifier: ^0.4.3
version: 0.4.3
@@ -654,9 +660,6 @@ importers:
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
- postcss:
- specifier: catalog:linters
- version: 8.5.10
prettier:
specifier: catalog:linters
version: 3.7.4