');
+ 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('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('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..f8b733a33
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.test.ts
@@ -0,0 +1,163 @@
+import {
+ applyElementAttributes,
+ getEditableTextNode,
+ getElementAttributes,
+ 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('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('Press me ');
+ });
+
+ 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('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..b4ebcc99a
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.ts
@@ -0,0 +1,168 @@
+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;
+};
+
+/**
+ * In-place editors below mutate the existing rendered nodes (instead of replacing
+ * them with form controls), so the editing UI can live in a floating popup and the
+ * content never shifts while typing. Editing existing nodes also preserves all of
+ * their attributes for free.
+ */
+
+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) ?? ''}));
+
+/**
+ * Reconciles an element's attributes with `attributes`: drops any attribute no
+ * longer listed, then sets every listed name to its value. Handles edits,
+ * additions, removals and renames uniformly. Entries with an empty name are
+ * ignored.
+ */
+export const applyElementAttributes = (element: Element, attributes: EditableAttribute[]): void => {
+ const keep = new Set(attributes.map((attr) => attr.name).filter(Boolean));
+
+ for (const name of element.getAttributeNames()) {
+ if (!keep.has(name)) element.removeAttribute(name);
+ }
+
+ for (const {name, value} of attributes) {
+ if (name) element.setAttribute(name, value);
+ }
+};
+
+// 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};
+};
+
+/** 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;
+};
+
+// Attributes that control how the original inline SVG is sized/placed inside the
+// block. They are carried over to the replacement so swapping the glyph keeps the
+// same footprint and styling defined by the template.
+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);
+ }
+ }
+
+ svg.replaceWith(next);
+
+ return next;
+};
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..aa80c4897
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConfirm.tsx
@@ -0,0 +1,66 @@
+import {useCallback, 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;
+ /** Paint the confirm button red — use for destructive, irreversible actions. */
+ danger?: boolean;
+}
+
+export type ConfirmFn = (options: ConfirmOptions) => Promise;
+
+interface PendingConfirm extends ConfirmOptions {
+ resolve: (confirmed: boolean) => void;
+}
+
+/**
+ * Promise-based confirmation dialog. Returns a `confirm()` that resolves to the
+ * user's choice and a `confirmElement` that must be rendered once by the caller.
+ */
+export const useConfirm = (): {confirm: ConfirmFn; confirmElement: ReactNode} => {
+ const [pending, setPending] = useState(null);
+
+ const confirm = useCallback(
+ (options) =>
+ new Promise((resolve) => {
+ setPending({...options, resolve});
+ }),
+ [],
+ );
+
+ const finish = (confirmed: boolean) => {
+ if (!pending) return;
+ pending.resolve(confirmed);
+ setPending(null);
+ };
+
+ const confirmElement = pending ? (
+ finish(false)}
+ onEnterKeyDown={() => finish(true)}
+ >
+
+ {pending.message}
+ finish(true)}
+ onClickButtonCancel={() => finish(false)}
+ />
+
+ ) : null;
+
+ return {confirm, confirmElement};
+};
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..d3a174c9e
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useInlineHtmlEditing.tsx
@@ -0,0 +1,546 @@
+import {useRef, useState} from 'react';
+import type {
+ CSSProperties,
+ FocusEvent,
+ KeyboardEvent,
+ MouseEvent,
+ ReactNode,
+ RefObject,
+} from 'react';
+
+import {ChevronDown, Pencil, Plus, TrashBin} from '@gravity-ui/icons';
+import {Button, Icon, Popup, TextArea, TextInput} from '@gravity-ui/uikit';
+
+import {i18n} from 'src/i18n/yfm-html-constructor';
+
+import {STOP_EVENT_CLASSNAME, cnYfmHtmlConstructor} from './const';
+import {HTML_CONSTRUCTOR_ICONS} from './iconLibrary';
+import {
+ type EditableAttribute,
+ applyElementAttributes,
+ getEditableTextNode,
+ getElementAttributes,
+ isIconSizedSvg,
+ setElementText,
+ setSvgIcon,
+} from './textEditing';
+
+const b = cnYfmHtmlConstructor;
+const stop = STOP_EVENT_CLASSNAME;
+const inlineEditButtonSelector = `.${b('inline-edit-button')}`;
+
+interface InlineEditTarget {
+ element: Element;
+ buttonStyle: CSSProperties;
+ outlineStyle: CSSProperties;
+}
+
+interface EditTarget {
+ element: Element;
+ anchor: HTMLElement;
+ outlineStyle: CSSProperties;
+ textNode: Text | null;
+ canEditText: boolean;
+ /** Inline SVG sized as an icon — offer the glyph picker. */
+ iconPicker: boolean;
+}
+
+interface AttrRow extends EditableAttribute {
+ id: number;
+}
+
+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;
+
+// `bounds` is the positioned container the overlay is rendered into; the returned
+// coordinates are relative to it.
+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: {
+ // A compact badge pinned to the highlight's top-right corner, nudged
+ // outward so it rests on the corner rather than on top of the content.
+ 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,
+ },
+ };
+};
+
+/**
+ * The most specific editable element under the cursor. Any element inside the
+ * content is editable (text + attributes); inner SVG shapes resolve to their
+ * owning `` so the glyph is edited as a whole. The content root itself is
+ * never a target.
+ */
+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)};
+};
+
+const humanizeIconId = (id: string) =>
+ id.replace(/-/g, ' ').replace(/^./, (char) => char.toUpperCase());
+
+export interface InlineHtmlEditingApi {
+ /** Ref for the element that owns the editable HTML (hit-testing + serialization). */
+ contentRef: RefObject;
+ /** Ref for the positioned container the edit overlay is rendered into. */
+ boundsRef: RefObject;
+ /** Event handlers to spread on the interactive container. */
+ containerHandlers: {
+ onClick: (event: MouseEvent) => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+ onMouseMove: (event: MouseEvent) => void;
+ onMouseOut: (event: MouseEvent) => void;
+ onBlur: (event: FocusEvent) => void;
+ onMouseLeave: () => void;
+ };
+ /** The hover affordance + edit popup, rendered inside the bounds container. */
+ overlay: ReactNode;
+}
+
+export const useInlineHtmlEditing = ({
+ onCommit,
+}: {
+ onCommit: (html: string) => void;
+}): InlineHtmlEditingApi => {
+ const contentRef = useRef(null);
+ const boundsRef = useRef(null);
+ const [inlineEditTarget, setInlineEditTarget] = useState(null);
+ const [editTarget, setEditTarget] = useState(null);
+ const [editValue, setEditValue] = useState('');
+ const [attrs, setAttrs] = useState([]);
+ const [attrsOpen, setAttrsOpen] = useState(false);
+ const [pendingIcon, setPendingIcon] = useState<{id: string; markup: string} | null>(null);
+ const editControlRef = useRef(null);
+ const attrIdRef = useRef(0);
+
+ const nextAttrId = () => {
+ attrIdRef.current += 1;
+ return attrIdRef.current;
+ };
+
+ const toAttrRows = (attributes: EditableAttribute[]): AttrRow[] =>
+ attributes.map((attr) => ({...attr, id: nextAttrId()}));
+
+ // Focus and select the field once the popup has finished opening (and is thus
+ // positioned). We focus it ourselves with `preventScroll` instead of relying on
+ // the Popup's `initialFocus`, because the focus manager would focus the control
+ // while the popup is still at its initial (0,0) position and without
+ // `preventScroll`, scrolling the page to the top to reveal it.
+ const selectEditControl = () => {
+ const control = editControlRef.current;
+ if (!control) return;
+ control.focus({preventScroll: true});
+ control.select();
+ };
+
+ 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 nextTarget = resolveTarget(event.target);
+ if (!nextTarget) return;
+
+ setInlineEditTarget((currentTarget) => {
+ if (currentTarget?.element === nextTarget.element) return currentTarget;
+
+ return nextTarget;
+ });
+ };
+
+ 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);
+
+ // Keep the edit affordance when focus moves to one of its own controls (the
+ // pencil button). Otherwise pressing the button would blur the content and
+ // unmount the button between mousedown and mouseup, swallowing the click.
+ const handleContentBlur = (event: FocusEvent) => {
+ const next = event.relatedTarget;
+ if (next instanceof Node && event.currentTarget.contains(next)) return;
+ clearInlineEditTarget();
+ };
+
+ const cancelEditing = () => {
+ setEditTarget(null);
+ setPendingIcon(null);
+ };
+
+ const updateAttr = (id: number, patch: Partial) => {
+ setAttrs((rows) => rows.map((row) => (row.id === id ? {...row, ...patch} : row)));
+ };
+
+ const removeAttr = (id: number) => {
+ setAttrs((rows) => rows.filter((row) => row.id !== id));
+ };
+
+ const addAttr = () => {
+ setAttrs((rows) => [...rows, {id: nextAttrId(), name: '', value: ''}]);
+ };
+
+ const commitEditing = () => {
+ const root = contentRef.current;
+ if (!root || !editTarget) {
+ cancelEditing();
+ return;
+ }
+
+ let element: Element = editTarget.element;
+
+ // Replacing an icon's glyph is the primary action; the new glyph keeps its
+ // own geometry (setSvgIcon carries class/style/width/height), so the
+ // attribute editor is not re-applied on top of it.
+ if (editTarget.iconPicker && pendingIcon) {
+ const next = setSvgIcon(element as SVGSVGElement, pendingIcon.markup);
+ if (next) element = next;
+ } else {
+ if (editTarget.canEditText) {
+ setElementText(element, editTarget.textNode, editValue);
+ }
+ applyElementAttributes(
+ element,
+ attrs.map(({name, value}) => ({name: name.trim(), value})),
+ );
+ }
+
+ onCommit(root.innerHTML);
+ cancelEditing();
+ };
+
+ const handleEditPopupKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ cancelEditing();
+ return;
+ }
+
+ if (event.key !== 'Enter') return;
+
+ // Shift/Ctrl/Cmd+Enter inserts a newline (handled natively by the textarea);
+ // plain Enter saves and closes the popup.
+ if (event.shiftKey || event.metaKey || event.ctrlKey) return;
+
+ event.preventDefault();
+ commitEditing();
+ };
+
+ // Editing happens in a popup anchored to the element rather than inline, so the
+ // content keeps its place (no layout jump) while the user types.
+ const openInlineEditTarget = (target: InlineEditTarget) => {
+ if (!contentRef.current) return false;
+
+ const {element, outlineStyle} = target;
+ const {node, canEdit} = getEditableTextNode(element);
+ const iconPicker = element instanceof SVGSVGElement && isIconSizedSvg(element);
+
+ setEditTarget({
+ element,
+ anchor: element as unknown as HTMLElement,
+ outlineStyle,
+ textNode: node,
+ canEditText: canEdit,
+ iconPicker,
+ });
+ setEditValue(node?.nodeValue ?? '');
+ setAttrs(toAttrRows(getElementAttributes(element)));
+ // Lead with attributes when there is no text to edit (media, wrappers).
+ setAttrsOpen(!canEdit);
+ setPendingIcon(null);
+ setInlineEditTarget(null);
+
+ return true;
+ };
+
+ const handleIconSelect = (event: MouseEvent, id: string) => {
+ // `` wraps the glyph in an extra ``; take the innermost (deepest)
+ // one so we embed the real icon markup, not the wrapper.
+ const svgs = event.currentTarget.querySelectorAll('svg');
+ const svg = svgs[svgs.length - 1];
+ if (!svg) return;
+
+ setPendingIcon({id, markup: svg.outerHTML});
+ };
+
+ const handleOpenInlineEdit = (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!inlineEditTarget) return;
+
+ openInlineEditTarget(inlineEditTarget);
+ };
+
+ const handleContentClick = (event: MouseEvent) => {
+ // A single click opens the editor for any element under the cursor. The
+ // hover affordance already hints that the content is editable.
+ const target = resolveTarget(event.target);
+ if (!target) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ openInlineEditTarget(target);
+ };
+
+ const handleContentKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+
+ if (!(event.target instanceof Element) || event.target.closest(inlineEditButtonSelector)) {
+ return;
+ }
+
+ const target = resolveTarget(event.target) ?? inlineEditTarget;
+ if (!target) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ openInlineEditTarget(target);
+ };
+
+ const renderTextField = () => {
+ if (!editTarget) return null;
+
+ if (!editTarget.canEditText) {
+ return {i18n('no_text')}
;
+ }
+
+ return (
+
+ );
+ };
+
+ const renderAttributes = () => {
+ if (!editTarget) return null;
+
+ return (
+
+
setAttrsOpen((open) => !open)}
+ >
+
+ {i18n('attributes')}
+ {attrs.length}
+
+ {attrsOpen && (
+
+ {attrs.map((row) => (
+
+ updateAttr(row.id, {name})}
+ placeholder={i18n('attribute_name')}
+ />
+ updateAttr(row.id, {value})}
+ placeholder={i18n('attribute_value')}
+ />
+ removeAttr(row.id)}
+ aria-label={i18n('remove_attribute')}
+ title={i18n('remove_attribute')}
+ >
+
+
+
+ ))}
+
+
+ {i18n('add_attribute')}
+
+
+ )}
+
+ );
+ };
+
+ const renderIconPicker = () => {
+ if (!editTarget?.iconPicker) return null;
+
+ return (
+
+
{i18n('icon')}
+
+ {HTML_CONSTRUCTOR_ICONS.map((icon) => (
+ handleIconSelect(event, icon.id)}
+ >
+
+
+ ))}
+
+
+ );
+ };
+
+ const overlay = (
+ <>
+ {inlineEditTarget && !editTarget && (
+ <>
+
+
+
+
+ >
+ )}
+ {editTarget && (
+ <>
+
+ {
+ if (!open) cancelEditing();
+ }}
+ >
+
+ {renderTextField()}
+ {renderAttributes()}
+ {renderIconPicker()}
+
+
+ {i18n('cancel')}
+
+
+ {i18n('save')}
+
+
+
+
+ >
+ )}
+ >
+ );
+
+ return {
+ contentRef,
+ boundsRef,
+ containerHandlers: {
+ onClick: handleContentClick,
+ onKeyDown: handleContentKeyDown,
+ onMouseMove: handleContentMouseMove,
+ onMouseOut: handleContentMouseOut,
+ 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..3e41d1b56
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/index.ts
@@ -0,0 +1,227 @@
+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 {htmlConstructorQuickStyleToCss, normalizeHtmlConstructorQuickStyle} from '../quickStyle';
+import {normalizeHtmlConstructorTemplateSettings} from '../settings';
+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 const emptyHtmlConstructorStructure = (): HtmlConstructorStructure => ({
+ css: '',
+ content: '',
+ themeIds: [],
+});
+
+const escapeHtmlAttr = (value: string) =>
+ value
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+
+const styleAttr = (style: string) => (style ? ` style="${escapeHtmlAttr(style)}"` : '');
+
+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),
+ };
+};
+
+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),
+ },
+ ];
+ });
+};
+
+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/css.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/css.ts
new file mode 100644
index 000000000..f089b8dca
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/css.ts
@@ -0,0 +1,99 @@
+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);
+};
+
+export const replaceCssAnchor = (css: string, selector: string): string =>
+ css.trim().replace(/&/g, selector);
+
+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);
+};
+
+/**
+ * Prefixes every style-rule selector in `css` with `scopeSelector`
+ * (e.g. `.g-md-hc-scope-abc`) so the rules only match inside one instance's
+ * subtree. At-rule preludes (`@media`, …) are preserved and their nested rules
+ * scoped; declarations are copied verbatim.
+ *
+ * This is a lightweight scoper for the constructor's own generated CSS, not a
+ * general-purpose CSS parser (declarations are assumed brace-free).
+ */
+export const scopeCss = (css: string, scopeSelector: string): string => {
+ const scopeSelectorList = (selectors: string) =>
+ selectors
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .map((part) => `${scopeSelector} ${part}`)
+ .join(', ');
+
+ let out = '';
+ let buf = '';
+ let i = 0;
+
+ const copyDeclarations = () => {
+ while (i < css.length) {
+ const ch = css[i++];
+ out += ch;
+ if (ch === '}') return;
+ }
+ };
+
+ const parseRules = () => {
+ while (i < css.length) {
+ const ch = css[i];
+ if (ch === '}') {
+ i++;
+ out += '}';
+ return;
+ }
+ if (ch === '{') {
+ i++;
+ const prelude = buf.trim();
+ buf = '';
+ out += out && !out.endsWith('\n') ? '\n' : '';
+ if (prelude.startsWith('@')) {
+ out += `${prelude} {`;
+ parseRules();
+ } else {
+ out += `${scopeSelectorList(prelude)} {`;
+ copyDeclarations();
+ }
+ continue;
+ }
+ buf += ch;
+ i++;
+ }
+ };
+
+ parseRules();
+ return out.trim();
+};
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..b71e6105a
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/cssVariables.ts
@@ -0,0 +1,151 @@
+import type {CSSProperties} from 'react';
+
+import {htmlConstructorBlockClass, htmlConstructorStructureClass} from './css';
+import type {HtmlConstructorBorderStyle, HtmlConstructorQuickStyle} from './types';
+
+/**
+ * Public CSS custom properties that connect the block toolbar's quick-style
+ * controls to user-authored themes.
+ *
+ * For every styleable aspect there are four properties:
+ * --g-md-hc- – the active value the block consumes
+ * --g-md-hc--light – value a theme sets for the light color theme
+ * --g-md-hc--dark – value a theme sets for the dark color theme
+ * --g-md-hc--current – internal: the light/dark value resolved for the
+ * active theme (set by {@link HTML_CONSTRUCTOR_VARIABLES_CSS})
+ *
+ * The block resolves each property as a chain:
+ * quick-style override (inline --g-md-hc-)
+ * -> theme value for the active color theme (--g-md-hc--current)
+ * -> the constructor's own theme-aware default ({@link HTML_CONSTRUCTOR_DEFAULTS}).
+ *
+ * Colors are theme-aware: the toolbar writes the light/dark companions
+ * (--g-md-hc--light / -dark), overriding the template's own companions, so
+ * `-current` resolves a different color per active theme. Border radius and
+ * border are theme-agnostic and still write the single override variable.
+ *
+ * Templates no longer need to hardcode default colors: when nothing is set the
+ * constructor supplies the defaults below, which are built from Gravity UI
+ * semantic tokens that already adapt to the active light/dark theme.
+ */
+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;
+
+/**
+ * Theme-aware defaults the constructor (the container) bakes into the
+ * `-current` variables when neither a quick-style override nor a
+ * template/theme companion sets a value. They lean on Gravity UI semantic
+ * tokens, which already flip with the active theme.
+ *
+ * Because the defaults live on the container, templates never hardcode default
+ * colors: they just read `var(--g-md-hc-, var(--g-md-hc--current))`
+ * and inherit the resolved value (override -> theme companion -> this default).
+ */
+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/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/preferences.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/preferences.ts
new file mode 100644
index 000000000..08d238466
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/preferences.ts
@@ -0,0 +1,107 @@
+import {useSyncExternalStore} from 'react';
+
+/**
+ * User-level preferences for the HTML constructor. They are shared across every
+ * constructor instance on the page (and persisted to localStorage), so toggling
+ * a setting in one block's code editor updates all of them at once.
+ */
+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 = (): HtmlConstructorPreferences => {
+ const storage = getStorage();
+ if (!storage) return DEFAULT_PREFERENCES;
+
+ try {
+ const raw = storage.getItem(YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY);
+ return raw ? normalize(JSON.parse(raw)) : DEFAULT_PREFERENCES;
+ } catch {
+ return DEFAULT_PREFERENCES;
+ }
+};
+
+let current = readFromStorage();
+const listeners = new Set<() => void>();
+
+const emit = () => {
+ for (const listener of listeners) listener();
+};
+
+// Keep instances in other tabs in sync as well.
+if (typeof window !== 'undefined') {
+ window.addEventListener('storage', (event) => {
+ if (event.key !== YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY) return;
+ current = readFromStorage();
+ emit();
+ });
+}
+
+export const getHtmlConstructorPreferences = (): HtmlConstructorPreferences => current;
+
+export const setHtmlConstructorPreference = (
+ key: K,
+ value: HtmlConstructorPreferences[K],
+): void => {
+ if (current[key] === value) return;
+
+ current = {...current, [key]: value};
+
+ const storage = getStorage();
+ if (storage) {
+ try {
+ storage.setItem(YFM_HTML_CONSTRUCTOR_PREFERENCES_STORAGE_KEY, JSON.stringify(current));
+ } catch {
+ // Storage may be full or unavailable; keep the in-memory value.
+ }
+ }
+
+ emit();
+};
+
+const subscribe = (listener: () => void): (() => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+export const useHtmlConstructorPreferences = (): HtmlConstructorPreferences =>
+ useSyncExternalStore(subscribe, getHtmlConstructorPreferences, getHtmlConstructorPreferences);
+
+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..f4bf34991
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/quickStyle.ts
@@ -0,0 +1,159 @@
+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;
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..036093c8e
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/index.ts
@@ -0,0 +1,25 @@
+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,
+} 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/storage.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.test.ts
new file mode 100644
index 000000000..85752fa81
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.test.ts
@@ -0,0 +1,131 @@
+import type {HtmlConstructorTemplate} from '../types';
+
+import {
+ YFM_HTML_CONSTRUCTOR_STORAGE_KEY,
+ clearStoredTemplates,
+ mergeTemplatesById,
+ readStoredTemplates,
+ saveTemplates,
+} 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(() => {
+ window.localStorage.clear();
+});
+
+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')]);
+ });
+});
+
+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();
+ });
+});
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..77080392d
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.ts
@@ -0,0 +1,147 @@
+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;
+ }
+};
+
+export function readStoredTemplates(): HtmlConstructorTemplate[] {
+ const storage = getStorage();
+ if (!storage) return [];
+
+ try {
+ const raw = storage.getItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.filter(isHtmlConstructorTemplate) : [];
+ } catch {
+ return [];
+ }
+}
+
+/** Merges `next` into already stored templates by id and persists the result. */
+export function saveTemplates(next: HtmlConstructorTemplate[]): HtmlConstructorTemplate[] {
+ const merged = mergeTemplatesById(readStoredTemplates(), next);
+
+ const storage = getStorage();
+ if (storage) {
+ try {
+ storage.setItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY, JSON.stringify(merged));
+ } catch {
+ // Storage may be full or unavailable; keep the in-memory result.
+ }
+ }
+
+ return merged;
+}
+
+export function clearStoredTemplates(): HtmlConstructorTemplate[] {
+ const storage = getStorage();
+ if (storage) {
+ try {
+ storage.removeItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY);
+ } catch {
+ // Storage may be unavailable; keep the in-memory result empty.
+ }
+ }
+
+ return [];
+}
+
+/** 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/types.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/types.ts
new file mode 100644
index 000000000..0b1963575
--- /dev/null
+++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/types.ts
@@ -0,0 +1,147 @@
+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;
+ /** Show the "add template" button (writes to localStorage). */
+ allowAdd?: boolean;
+}
+
+export interface YfmHtmlConstructorExtensionOptions {
+ templates?: YfmHtmlConstructorOptions;
+ /**
+ * Experimental. Scope each constructor's generated CSS to its own instance
+ * by wrapping it in a unique class (derived from the node's entity id), so
+ * styles from one constructor don't leak into another on the same page.
+ *
+ * Off by default — flip it on to evaluate whether the isolation is needed.
+ */
+ 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..01177a060
--- /dev/null
+++ b/packages/editor/src/i18n/yfm-html-constructor/en.json
@@ -0,0 +1,119 @@
+{
+ "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": "Button text ",
+ "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",
+ "html": "HTML",
+ "icon": "Icon",
+ "initial_subtitle": "Create beautiful HTML blocks right in editor.",
+ "insert": "Insert",
+ "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..8cbb95dd8
--- /dev/null
+++ b/packages/editor/src/i18n/yfm-html-constructor/ru.json
@@ -0,0 +1,119 @@
+{
+ "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": "Изменить текст",
+ "html": "HTML",
+ "icon": "Иконка",
+ "initial_subtitle": "Создавайте красивые HTML-блоки прямо в редакторе.",
+ "insert": "Вставить",
+ "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": "Не удалось разобрать шаблоны"
+}