diff --git a/demo/.storybook/main.ts b/demo/.storybook/main.ts index 98ee7fce7..23901face 100644 --- a/demo/.storybook/main.ts +++ b/demo/.storybook/main.ts @@ -60,6 +60,10 @@ const config: StorybookConfig = { type: 'asset/resource' as const, generator: {emit: false}, }); + config.module.rules.push({ + test: /\.html$/, + type: 'asset/source' as const, + }); config.watchOptions ||= {}; config.watchOptions.ignored = /node_modules([\\]+|\/)+(?!@gravity-ui\/markdown-editor)/; diff --git a/demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html b/demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html new file mode 100644 index 000000000..92dd4d568 --- /dev/null +++ b/demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html @@ -0,0 +1,965 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/defaults/yfm-html-constructor/index.ts b/demo/src/defaults/yfm-html-constructor/index.ts new file mode 100644 index 000000000..3cf591fe4 --- /dev/null +++ b/demo/src/defaults/yfm-html-constructor/index.ts @@ -0,0 +1,26 @@ +import { + parseTemplates, + saveTemplates, +} from '@gravity-ui/markdown-editor/extensions/additional/YfmHtmlConstructor/templates/index.js'; + +import gravityUiLanding from './gravity-ui-landing.html'; + +const SEEDED_FLAG_KEY = 'gravity-md-editor:yfm-html-constructor:demo-seeded'; + +/** + * The demo ships a "Gravity UI" template family. To make the whole flow go + * through localStorage — so the picker's "Clear all templates" can actually + * remove them — we seed those defaults into storage once instead of passing + * them as static `items`. The flag keeps a cleared list cleared across reloads. + */ +export const seedYfmHtmlConstructorTemplates = (): void => { + if (typeof window === 'undefined') return; + + try { + if (window.localStorage.getItem(SEEDED_FLAG_KEY)) return; + saveTemplates(parseTemplates(gravityUiLanding)); + window.localStorage.setItem(SEEDED_FLAG_KEY, '1'); + } catch { + // Storage may be unavailable in the demo environment; ignore. + } +}; diff --git a/demo/src/global.d.ts b/demo/src/global.d.ts index 0ae8aa69d..8d1541a79 100644 --- a/demo/src/global.d.ts +++ b/demo/src/global.d.ts @@ -21,3 +21,8 @@ declare module 'markdown-it-ins' { declare const plugin: PluginSimple; export = plugin; } + +declare module '*.html' { + const content: string; + export default content; +} diff --git a/demo/src/stories/yfm/YfmHtmlConstructor.stories.tsx b/demo/src/stories/yfm/YfmHtmlConstructor.stories.tsx new file mode 100644 index 000000000..f891e72f3 --- /dev/null +++ b/demo/src/stories/yfm/YfmHtmlConstructor.stories.tsx @@ -0,0 +1,16 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {YfmHtmlConstructorDemo} from './YfmHtmlConstructor'; + +const meta: Meta = { + title: 'Extensions / YFM', + component: YfmHtmlConstructorDemo, +}; + +export default meta; + +type Story = StoryObj; + +export const YfmHtmlConstructor: Story = { + name: 'YFM HTML Constructor', +}; diff --git a/demo/src/stories/yfm/YfmHtmlConstructor.tsx b/demo/src/stories/yfm/YfmHtmlConstructor.tsx new file mode 100644 index 000000000..5c7368add --- /dev/null +++ b/demo/src/stories/yfm/YfmHtmlConstructor.tsx @@ -0,0 +1,81 @@ +import {memo} from 'react'; + +import {LayoutCells} from '@gravity-ui/icons'; +import { + MarkdownEditorView, + type ToolbarsPreset, + useMarkdownEditor, +} from '@gravity-ui/markdown-editor'; +import {ToolbarName as Toolbar} from '@gravity-ui/markdown-editor/_/modules/toolbars/constants.js'; +import {defaultPreset} from '@gravity-ui/markdown-editor/_/modules/toolbars/presets.js'; +import {YfmHtmlConstructor as YfmHtmlConstructorExtension} from '@gravity-ui/markdown-editor/extensions/additional/YfmHtmlConstructor/index.js'; + +import {PlaygroundLayout} from '../../components/PlaygroundLayout'; +import {seedYfmHtmlConstructorTemplates} from '../../defaults/yfm-html-constructor'; + +// Seed the bundled "Gravity UI" templates into localStorage once so the picker +// reads them from storage (and can clear them) instead of static `items`. +seedYfmHtmlConstructorTemplates(); + +const yfmHtmlConstructorItemId = 'yfmHtmlConstructor'; + +const toolbarsPreset: ToolbarsPreset = { + items: { + ...defaultPreset.items, + [yfmHtmlConstructorItemId]: { + view: { + icon: {data: LayoutCells}, + title: 'YFM HTML Constructor', + }, + wysiwyg: { + exec: (e) => e.actions.createYfmHtmlConstructor.run(), + isActive: (e) => e.actions.createYfmHtmlConstructor.isActive(), + isEnable: (e) => e.actions.createYfmHtmlConstructor.isEnable(), + }, + }, + }, + orders: { + ...defaultPreset.orders, + [Toolbar.wysiwygMain]: [ + [yfmHtmlConstructorItemId], + ...defaultPreset.orders[Toolbar.wysiwygMain], + ], + }, +}; + +export const YfmHtmlConstructorDemo = memo(function YfmHtmlConstructorDemo() { + const editor = useMarkdownEditor( + { + initial: {mode: 'wysiwyg', markup: ''}, + wysiwygConfig: { + extensions: (builder) => + builder.use(YfmHtmlConstructorExtension, { + // Experimental: isolate each constructor's CSS so styles + // from one don't leak into another on the same page. + scopeStyles: true, + templates: { + showButton: true, + allowAdd: true, + }, + }), + }, + }, + [], + ); + + return ( + ( + + )} + /> + ); +}); diff --git a/demo/tests/visual-tests/YfmExtensions.helpers.tsx b/demo/tests/visual-tests/YfmExtensions.helpers.tsx index 1fc09a114..95982d86d 100644 --- a/demo/tests/visual-tests/YfmExtensions.helpers.tsx +++ b/demo/tests/visual-tests/YfmExtensions.helpers.tsx @@ -4,10 +4,12 @@ import * as DefaultYFMStories from '../../src/stories/yfm/YFM.stories'; type Stories = ReturnType>; -export const YFMStories: Stories = composeStories(DefaultYFMStories, { - argsEnhancers: [ - () => ({ - stickyToolbar: false, - }), - ], -}); +export const YFMStories: Stories = { + ...composeStories(DefaultYFMStories, { + argsEnhancers: [ + () => ({ + stickyToolbar: false, + }), + ], + }), +}; diff --git a/docs/yfm-html-constructor-theming.md b/docs/yfm-html-constructor-theming.md new file mode 100644 index 000000000..231b432cc --- /dev/null +++ b/docs/yfm-html-constructor-theming.md @@ -0,0 +1,105 @@ +# YFM HTML Constructor — theming contract + +The HTML Constructor block exposes its quick-style controls (background, text +color, corner rounding, border) as **CSS custom properties** instead of writing +concrete CSS properties. This is the contract that connects what a user does in +the block toolbar to the CSS a theme author writes. Themes are authored by users +as plain CSS, so they need a stable, documented set of variables to target. + +## How it works + +Every styleable aspect is backed by four CSS variables: + +| Variable | Who sets it | Purpose | +| -------------------------- | ------------------ | --------------------------------------------------------------- | +| `--g-md-hc-` | block toolbar | The quick-style **override** (inline on the element). | +| `--g-md-hc--light` | theme author | Value for the **light** color theme. | +| `--g-md-hc--dark` | theme author | Value for the **dark** color theme. | +| `--g-md-hc--current` | constructor (auto) | The light/dark value resolved for the active theme. Do not set. | + +The block resolves the final value as a fallback chain: + +``` +--g-md-hc- (toolbar override, highest priority) + └─ --g-md-hc--current (resolved by the container, see below) +``` + +`*-current` is computed automatically by the container (the constructor +contract) from the `*-light` / `*-dark` companions depending on the active +Gravity UI theme (`.g-root_theme_dark` / `.g-root_theme_dark-hc` switch to the +dark companions, everything else uses the light ones), and falls back to the +constructor default when no companion is set. + +### Constructor defaults (live on the container) + +The defaults are baked into the container's `*-current` resolution +(`HTML_CONSTRUCTOR_DEFAULTS` in `cssVariables.ts`), not into individual +templates. They use Gravity UI semantic tokens, which already flip between light +and dark themes: + +| `` | Default | +| --------------- | ---------------------------------------- | +| `background` | `var(--g-color-base-generic-ultralight)` | +| `text-color` | `var(--g-color-text-primary)` | +| `border` | `1px solid var(--g-color-line-generic)` | +| `border-radius` | `var(--g-border-radius-l)` | + +Because the default lives on the container, a template never carries its own +fallback. It simply reads the resolved value: + +```css +& { + 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)); +} +``` + +The value (override -> theme companion -> default) comes from the container, so +a bare structure/block already reads as a subtle, adaptive card. Set the +companions only when you want a look different from the default. + +### Available aspects + +| `` | Consumed property | Notes | +| --------------- | ----------------- | --------------------------------------------------------- | +| `background` | `background` | Any CSS color. | +| `text-color` | `color` | Any CSS color. | +| `border-radius` | `border-radius` | Any length, e.g. `12px`, `999px`. | +| `border` | `border` | A full border shorthand, e.g. `1px solid #ccc` or `none`. | + +## Writing a theme + +A theme is a CSS rule scoped to a block (or structure). To stay compatible with +the toolbar **and** support light/dark, set the `*-light` / `*-dark` companions +rather than the final properties: + +```css +/* Good: themable + toolbar-overridable + dark-aware */ +& { + --g-md-hc-background-light: #ffffff; + --g-md-hc-background-dark: #1c1c20; + --g-md-hc-text-color-light: #1c1c20; + --g-md-hc-text-color-dark: #f0f0f0; + --g-md-hc-border-radius-light: 16px; + --g-md-hc-border-radius-dark: 16px; + --g-md-hc-border-light: 1px solid #e7e9ec; + --g-md-hc-border-dark: 1px solid #34343a; +} +``` + +You can set only one companion if a value should be the same in both themes — +the dark companion falls back to the light one when it is not provided. + +If you set a property **directly** (e.g. `background: red`) the block can no +longer override it from the toolbar and it will not adapt to light/dark. That is +sometimes intentional (a fixed brand look), but for general-purpose themes prefer +the companion variables above. + +## Where the contract lives + +- Variable names and helpers: `cssVariables.ts`. +- In-editor resolution/consumption (with editor chrome fallbacks): the + `&__item` / `&__structure` rules in `YfmHtmlConstructorNodeView/YfmHtmlConstructor.scss`. +- Output markdown and template previews prepend the generated contract + stylesheet (`HTML_CONSTRUCTOR_VARIABLES_CSS`) so the variables resolve outside + the editor too. diff --git a/packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/TemplatesPopup.scss b/packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/TemplatesPopup.scss index 3bb299ca9..a0369b511 100644 --- a/packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/TemplatesPopup.scss +++ b/packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/TemplatesPopup.scss @@ -12,15 +12,14 @@ &__editor { display: flex; flex-direction: column; - gap: 8px; + padding: 8px; } &__controls { display: flex; justify-content: end; - gap: 8px; } } diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructor.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructor.test.ts new file mode 100644 index 000000000..f1f2ed22f --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructor.test.ts @@ -0,0 +1,103 @@ +import {builders} from 'prosemirror-test-builder'; + +import {ExtensionsManager} from '../../../core'; +import {BaseNode, BaseSchemaSpecs} from '../../specs'; + +import {YfmHtmlConstructorSpecs} from './YfmHtmlConstructorSpecs'; +import {YfmHtmlConstructorAttrs, yfmHtmlConstructorNodeName} from './YfmHtmlConstructorSpecs/const'; +import {HTML_CONSTRUCTOR_VARIABLES_CSS} from './cssVariables'; + +/** Matches the 2-space indentation `buildYfmHtmlConstructorHtml` applies inside `', + '
', + '
First
', + '
', + ':::', + ].join('\n'), + ); + }); +}); diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/BlockTemplatesPanel.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/BlockTemplatesPanel.tsx new file mode 100644 index 000000000..35bc06373 --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/BlockTemplatesPanel.tsx @@ -0,0 +1,105 @@ +import {useCallback} from 'react'; +import type {FC} from 'react'; + +import {i18n} from 'src/i18n/yfm-html-constructor'; + +import type { + HtmlConstructorBlockTemplate, + HtmlConstructorTemplate, + HtmlConstructorThemeTemplate, +} from '../types'; + +import {TemplatePickerPanel} from './TemplatePicker'; +import type {PickerCardModel, PickerGroup} from './TemplatePicker'; +import {buildBlockPreviewParts} from './blockUtils'; +import {buildBlockMenuGroups} from './groupTemplates'; +import type {BlockMenuItem} from './groupTemplates'; + +const getTitle = (template: {id: string; title?: string}) => template.title?.trim() || template.id; + +const blockPreview = buildBlockPreviewParts; + +const variantLabel = ( + base: HtmlConstructorBlockTemplate, + state: HtmlConstructorBlockTemplate, + theme?: HtmlConstructorThemeTemplate, +) => { + if (!theme) return getTitle(state); + const themeTitle = getTitle(theme); + return state.id === base.id ? themeTitle : `${getTitle(state)} · ${themeTitle}`; +}; + +const blockCard = ( + item: BlockMenuItem, + onApply: (block: HtmlConstructorBlockTemplate, theme?: HtmlConstructorThemeTemplate) => void, +): PickerCardModel => { + const {block} = item; + const variants: {state: HtmlConstructorBlockTemplate; theme?: HtmlConstructorThemeTemplate}[] = + []; + item.states.forEach((state, index) => { + // The base state is the card itself; only extra states become variants. + if (index > 0) variants.push({state}); + for (const theme of item.themesByBlockId[state.id] ?? []) { + variants.push({state, theme}); + } + }); + + return { + id: block.id, + title: getTitle(block), + preview: blockPreview(block), + badge: variants.length ? i18n('variants_count', {count: variants.length}) : undefined, + onApply: () => onApply(block), + variants: variants.map(({state, theme}) => ({ + key: `${state.id}:${theme?.id ?? 'state'}`, + label: variantLabel(block, state, theme), + preview: blockPreview(state, theme), + onApply: () => onApply(state, theme), + })), + }; +}; + +interface BlockTemplatesPanelProps { + templates: HtmlConstructorTemplate[]; + activeStructureId?: string; + emptyText: string; + onClose: () => void; + onApplyTemplate: ( + template: HtmlConstructorBlockTemplate, + theme?: HtmlConstructorThemeTemplate, + ) => void; + onApplyHtml: (value: {content: string; css: string}) => void; +} + +export const BlockTemplatesPanel: FC = ({ + templates, + activeStructureId, + emptyText, + onClose, + onApplyTemplate, + onApplyHtml, +}) => { + const buildGroups = useCallback( + (filter: string): PickerGroup[] => + buildBlockMenuGroups(templates, activeStructureId, filter).map((group) => ({ + title: group.title, + cards: group.items.map((item) => blockCard(item, onApplyTemplate)), + })), + [activeStructureId, onApplyTemplate, templates], + ); + + return ( + + ); +}; diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/FloatingToolbar.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/FloatingToolbar.tsx new file mode 100644 index 000000000..838d75788 --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/FloatingToolbar.tsx @@ -0,0 +1,817 @@ +import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import type {CSSProperties, FC, MouseEvent, ReactNode} from 'react'; + +import { + BucketPaint, + ChevronDown, + Code, + Copy, + Ellipsis, + Font, + Lock, + SquareDashedText, + TrashBin, +} from '@gravity-ui/icons'; +import {Button, Icon, Popup, useThemeType} from '@gravity-ui/uikit'; + +import {i18n} from 'src/i18n/yfm-html-constructor'; +import {useElementState} from 'src/react-utils/hooks'; + +import { + HTML_CONSTRUCTOR_BACKGROUND_COLORS, + HTML_CONSTRUCTOR_BORDER_RADIUS, + HTML_CONSTRUCTOR_BORDER_STYLES, + HTML_CONSTRUCTOR_COLOR_NAME_KEYS, + HTML_CONSTRUCTOR_TEXT_COLORS, + setThemedColor, +} from '../quickStyle'; +import {getEnabledHtmlConstructorSettings} from '../settings'; +import type { + HtmlConstructorBorderStyle, + HtmlConstructorColorTheme, + HtmlConstructorQuickStyle, + HtmlConstructorTemplateSettings, +} from '../types'; + +import {STOP_EVENT_CLASSNAME, cnYfmHtmlConstructor} from './const'; + +const b = cnYfmHtmlConstructor; +const stop = STOP_EVENT_CLASSNAME; +const TOOLBAR_MAX_WIDTH_RATIO = 0.9; +const TOOLBAR_HORIZONTAL_PADDING = 16; +const TOOLBAR_ITEM_GAP = 4; +const TOOLBAR_GROUP_SEPARATOR_WIDTH = 21; +const MORE_BUTTON_FALLBACK_WIDTH = 32; + +type ToolbarMenu = 'background' | 'textColor' | 'border' | null; +type ToolbarActionId = string; +type ToolbarActionGroup = 'primary' | 'style' | 'actions'; +type ToolbarAction = {id: ToolbarActionId; group: ToolbarActionGroup; node: ReactNode}; +export type FloatingToolbarPrimaryAction = {id: ToolbarActionId; node: ReactNode}; + +const HIDE_ACTION_ORDER: ToolbarActionId[] = [ + 'lock', + 'duplicate', + 'delete', + 'border', + 'palette', + 'textColor', + 'background', + 'raw', +]; +const TOOLBAR_GROUP_ORDER: ToolbarActionGroup[] = ['primary', 'style', 'actions']; + +type FloatingToolbarProps = { + settings?: HtmlConstructorTemplateSettings; + quickStyle?: HtmlConstructorQuickStyle; + onQuickStyleChange: (quickStyle: HtmlConstructorQuickStyle) => void; + /** Disable the quick-style zone (background/text color/border) when there is nothing to style yet. */ + styleDisabled?: boolean; + /** + * Cap the toolbar width to its positioned parent (the block) instead of the + * viewport, so a corner-anchored block toolbar collapses extra controls into + * the overflow menu rather than spilling outside the block. + */ + constrainToParent?: boolean; + onOpenSettings: () => void; + primaryActions?: FloatingToolbarPrimaryAction[]; + onDuplicate?: () => void; + onRemove?: () => void; + expandedContent?: ReactNode; + expandedContentView?: 'menu' | 'editor' | 'panel'; + onCloseExpandedContent?: () => void; + codeLabel: string; + duplicateLabel?: string; + removeLabel: string; + lockLabel?: string; +}; + +export const getNextQuickStyle = ( + quickStyle: HtmlConstructorQuickStyle | undefined, + patch: Partial, +) => { + const next = {...quickStyle, ...patch}; + + for (const key of Object.keys(next) as (keyof HtmlConstructorQuickStyle)[]) { + if (!next[key]) delete next[key]; + } + + return next; +}; + +const resolveColorTheme = (theme: string): HtmlConstructorColorTheme => + theme === 'dark' ? 'dark' : 'light'; + +const getColorName = (color: string) => { + const key = HTML_CONSTRUCTOR_COLOR_NAME_KEYS[color.toLowerCase()]; + return key ? i18n(key as Parameters[0]) : color; +}; + +const getRadiusLabel = (value: string | undefined) => { + if (!value) return i18n('round_default'); + if (value === '0') return i18n('round_none'); + if (value === '999px') return i18n('round_pill'); + return value; +}; + +const getBorderLabel = (value: HtmlConstructorBorderStyle | undefined) => { + if (value === 'solid') return i18n('border_solid'); + if (value === 'dashed') return i18n('border_dashed'); + if (value === 'dotted') return i18n('border_dotted'); + if (value === 'none') return i18n('border_none'); + + return i18n('border_default'); +}; + +const getToolbarWidth = (actions: ToolbarAction[], widths: Record) => { + const groupCounts = TOOLBAR_GROUP_ORDER.map( + (group) => actions.filter((action) => action.group === group).length, + ).filter(Boolean); + + return ( + actions.reduce((sum, action) => sum + (widths[action.id] ?? 0), 0) + + groupCounts.reduce((sum, count) => sum + Math.max(0, count - 1) * TOOLBAR_ITEM_GAP, 0) + + Math.max(0, groupCounts.length - 1) * TOOLBAR_GROUP_SEPARATOR_WIDTH + ); +}; + +const groupToolbarActions = (actions: ToolbarAction[]) => + TOOLBAR_GROUP_ORDER.map((group) => ({ + group, + actions: actions.filter((action) => action.group === group), + })).filter(({actions: groupActions}) => groupActions.length > 0); + +export const FloatingToolbar: FC = ({ + settings, + quickStyle, + onQuickStyleChange, + styleDisabled = false, + onOpenSettings, + primaryActions = [], + onDuplicate, + onRemove, + expandedContent, + expandedContentView = 'menu', + onCloseExpandedContent, + codeLabel, + duplicateLabel, + removeLabel, + lockLabel, + constrainToParent = false, +}) => { + const enabled = getEnabledHtmlConstructorSettings(settings); + const activeTheme = resolveColorTheme(useThemeType()); + // The palette edits one theme's color at a time and defaults to the editor's + // active theme, so switching the editor theme re-targets the palette ("при + // переключении темы меняется палитра"). The author can still flip the toggle + // to set the other theme without leaving. + const [paletteTheme, setPaletteTheme] = useState(activeTheme); + const [openMenu, setOpenMenu] = useState(null); + const [backgroundAnchor, setBackgroundAnchor] = useElementState(); + const [textColorAnchor, setTextColorAnchor] = useElementState(); + const [borderAnchor, setBorderAnchor] = useElementState(); + const [moreAnchor, setMoreAnchor] = useElementState(); + const [moreOpen, setMoreOpen] = useState(false); + const toolbarRef = useRef(null); + const toolbarRowRef = useRef(null); + const [availableToolbarWidth, setAvailableToolbarWidth] = useState(Number.POSITIVE_INFINITY); + const [toolbarItemWidths, setToolbarItemWidths] = useState>({}); + const toolbarOpen = openMenu !== null || Boolean(expandedContent); + const chromelessPanel = expandedContentView === 'editor' || expandedContentView === 'panel'; + const settingsSelected = Boolean(expandedContent) && expandedContentView === 'editor'; + + useEffect(() => { + setPaletteTheme(activeTheme); + }, [activeTheme]); + + const toggleMenu = (menu: Exclude) => (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setOpenMenu((current) => (current === menu ? null : menu)); + }; + + const updateQuickStyle = (patch: Partial) => { + onQuickStyleChange(getNextQuickStyle(quickStyle, patch)); + setOpenMenu(null); + setMoreOpen(false); + }; + + // Color swatches keep the menu open so the author can set the other theme via + // the light/dark toggle without reopening the palette. + const setThemedQuickStyle = (key: 'background' | 'textColor', color: string | undefined) => { + onQuickStyleChange( + getNextQuickStyle(quickStyle, { + [key]: setThemedColor(quickStyle?.[key], paletteTheme, color), + }), + ); + }; + + const renderPaletteThemeToggle = () => ( +
+ {(['light', 'dark'] as const).map((theme) => ( + + ))} +
+ ); + + const handleOpenSettings = () => { + setOpenMenu(null); + setMoreOpen(false); + onOpenSettings(); + }; + + const handleDuplicate = () => { + setOpenMenu(null); + setMoreOpen(false); + onDuplicate?.(); + }; + + const handleRemove = () => { + setOpenMenu(null); + setMoreOpen(false); + onRemove?.(); + }; + + const closeMenuOnPopupClose = (open: boolean) => { + if (!open) setOpenMenu(null); + }; + + const closeMoreOnPopupClose = (open: boolean) => { + setMoreOpen(open); + if (!open) setOpenMenu(null); + }; + + const closeExpandedContent = useCallback(() => { + setOpenMenu(null); + setMoreOpen(false); + onCloseExpandedContent?.(); + }, [onCloseExpandedContent]); + + const renderRawButton = () => { + if (!enabled.hasRaw) return null; + if (styleDisabled) return renderDisabledButton(codeLabel, Code); + + return ( + + ); + }; + + const renderDisabledButton = (label: string, icon: typeof SquareDashedText) => ( + + ); + + const renderPaletteButton = () => + renderDisabledButton(i18n('select_palette'), SquareDashedText); + + const renderBackgroundControl = () => { + if (!enabled.hasBackground) return null; + if (styleDisabled) return renderDisabledButton(i18n('background_color'), BucketPaint); + + const activeColor = quickStyle?.background?.[activeTheme]; + const selectedColor = quickStyle?.background?.[paletteTheme]; + + return ( +
+ + +
+ {renderPaletteThemeToggle()} +
+ {HTML_CONSTRUCTOR_BACKGROUND_COLORS.map((color) => ( +
+ +
+
+
+ ); + }; + + const renderTextColorControl = () => { + if (!enabled.hasTextColor) return null; + if (styleDisabled) return renderDisabledButton(i18n('text_color'), Font); + + const activeColor = quickStyle?.textColor?.[activeTheme]; + const selectedColor = quickStyle?.textColor?.[paletteTheme] ?? ''; + + return ( +
+ + +
+ {renderPaletteThemeToggle()} +
+ {HTML_CONSTRUCTOR_TEXT_COLORS.map((color) => ( + + ))} +
+
+
+
+ ); + }; + + const renderBorderControl = () => { + if (!enabled.hasBorder && !enabled.hasRound) return null; + if (styleDisabled) { + return ( + + ); + } + + return ( +
+ + +
+ {enabled.hasBorder && ( +
+
+ {i18n('border')} +
+ + {HTML_CONSTRUCTOR_BORDER_STYLES.map((borderStyle) => ( + + ))} +
+ )} + {enabled.hasRound && ( +
+
+ {i18n('rounding')} +
+ {HTML_CONSTRUCTOR_BORDER_RADIUS.map((radius) => ( + + ))} +
+ )} +
+
+
+ ); + }; + + const renderDuplicateButton = () => { + if (!onDuplicate || !duplicateLabel) return null; + + return ( + + ); + }; + + const renderDeleteButton = () => { + if (!enabled.hasDelete || !onRemove) return null; + + return ( + + ); + }; + + const renderLockButton = () => { + if (!lockLabel) return null; + + return ( + + ); + }; + + const toolbarActions = [ + ...primaryActions.map((action) => ({ + id: action.id, + group: 'primary' as const, + node: action.node, + })), + enabled.hasRaw && { + id: 'raw' as const, + group: 'primary' as const, + node: renderRawButton(), + }, + enabled.hasBackground && { + id: 'background' as const, + group: 'style' as const, + node: renderBackgroundControl(), + }, + enabled.hasTextColor && { + id: 'textColor' as const, + group: 'style' as const, + node: renderTextColorControl(), + }, + { + id: 'palette' as const, + group: 'style' as const, + node: renderPaletteButton(), + }, + (enabled.hasBorder || enabled.hasRound) && { + id: 'border' as const, + group: 'style' as const, + node: renderBorderControl(), + }, + onDuplicate && + duplicateLabel && { + id: 'duplicate' as const, + group: 'actions' as const, + node: renderDuplicateButton(), + }, + enabled.hasDelete && + onRemove && { + id: 'delete' as const, + group: 'actions' as const, + node: renderDeleteButton(), + }, + lockLabel && { + id: 'lock' as const, + group: 'actions' as const, + node: renderLockButton(), + }, + ].filter(Boolean) as ToolbarAction[]; + const toolbarActionIds = toolbarActions.map((action) => action.id); + const toolbarActionIdsKey = toolbarActionIds.join('|'); + const measuredActions = toolbarActionIds.every((id) => toolbarItemWidths[id]); + const hideActionOrder = [ + ...HIDE_ACTION_ORDER, + ...toolbarActionIds.filter((id) => !HIDE_ACTION_ORDER.includes(id)).reverse(), + ]; + const hiddenActionIds = (() => { + if (!measuredActions) return []; + + let visibleActions = [...toolbarActions]; + + if (getToolbarWidth(visibleActions, toolbarItemWidths) <= availableToolbarWidth) return []; + + const availableWidth = availableToolbarWidth - MORE_BUTTON_FALLBACK_WIDTH; + + for (const actionId of hideActionOrder) { + if (!visibleActions.some((action) => action.id === actionId)) continue; + + visibleActions = visibleActions.filter((action) => action.id !== actionId); + + const nextWidth = getToolbarWidth(visibleActions, toolbarItemWidths); + + if (nextWidth <= availableWidth) break; + } + + return toolbarActionIds.filter((id) => !visibleActions.some((action) => action.id === id)); + })(); + const hiddenActionIdSet = new Set(hiddenActionIds); + const visibleActions = toolbarActions.filter((action) => !hiddenActionIdSet.has(action.id)); + const hiddenActions = toolbarActions.filter((action) => hiddenActionIdSet.has(action.id)); + const visibleActionGroups = groupToolbarActions(visibleActions); + const hiddenActionGroups = groupToolbarActions(hiddenActions); + + useLayoutEffect(() => { + const updateToolbarSizes = () => { + const toolbarRow = toolbarRowRef.current; + if (!toolbarRow) return; + + setToolbarItemWidths((currentWidths) => { + const nextWidths = {...currentWidths}; + let hasChanges = false; + + toolbarRow + .querySelectorAll('[data-toolbar-action-id]') + .forEach((item) => { + const id = item.dataset.toolbarActionId; + if (!id || currentWidths[id] === item.offsetWidth) return; + + nextWidths[id] = item.offsetWidth; + hasChanges = true; + }); + + return hasChanges ? nextWidths : currentWidths; + }); + + const viewportWidth = + Math.floor(window.innerWidth * TOOLBAR_MAX_WIDTH_RATIO) - + TOOLBAR_HORIZONTAL_PADDING; + // Corner-anchored block toolbars must stay within their block, so cap + // the budget to the positioned parent's width and let the rest collapse + // into the overflow menu. + const parent = toolbarRef.current?.offsetParent as HTMLElement | null; + const parentWidth = + constrainToParent && parent + ? parent.clientWidth - TOOLBAR_HORIZONTAL_PADDING + : Number.POSITIVE_INFINITY; + const nextAvailableWidth = Math.min(viewportWidth, parentWidth); + setAvailableToolbarWidth((currentWidth) => { + if (currentWidth === nextAvailableWidth) return currentWidth; + + return nextAvailableWidth; + }); + }; + + updateToolbarSizes(); + window.addEventListener('resize', updateToolbarSizes); + + return () => window.removeEventListener('resize', updateToolbarSizes); + }, [toolbarActionIdsKey, constrainToParent]); + + useEffect(() => { + if (!expandedContent || !onCloseExpandedContent) return undefined; + + const closeOnOutsidePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + + if (toolbarRef.current?.contains(target)) return; + // Popups (theme picker, color menus, …) render in a portal outside the + // toolbar, but they still belong to the constructor and carry the stop + // class. Treat them as inside so a click there is not swallowed by the + // close handler before it reaches the element's own onClick. + if (target.closest(`.${stop}`)) return; + + closeExpandedContent(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeExpandedContent(); + }; + + document.addEventListener('pointerdown', closeOnOutsidePointerDown, true); + document.addEventListener('keydown', closeOnEscape); + + return () => { + document.removeEventListener('pointerdown', closeOnOutsidePointerDown, true); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [closeExpandedContent, expandedContent, onCloseExpandedContent]); + + return ( +
+
+ {visibleActionGroups.map(({group, actions}) => ( +
+ {actions.map((action) => ( +
+ {action.node} +
+ ))} +
+ ))} + {hiddenActions.length > 0 && ( +
+
+ + +
+ {hiddenActionGroups.map(({group, actions}) => ( +
+ {actions.map((action) => ( +
+ {action.node} +
+ ))} +
+ ))} +
+
+
+
+ )} +
+ {expandedContent && ( +
+ {expandedContent} +
+ )} +
+
+ ); +}; diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/GroupedTemplatesMenuItems.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/GroupedTemplatesMenuItems.tsx new file mode 100644 index 000000000..ecab7fa0c --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/GroupedTemplatesMenuItems.tsx @@ -0,0 +1,227 @@ +import {useState} from 'react'; +import type {FC, ReactNode} from 'react'; + +import {ChevronDown, ChevronRight, Palette} from '@gravity-ui/icons'; +import {Icon, Menu} from '@gravity-ui/uikit'; + +import type { + HtmlConstructorBlockTemplate, + HtmlConstructorStructureTemplate, + HtmlConstructorThemeTemplate, +} from '../types'; + +import {STOP_EVENT_CLASSNAME, cnYfmHtmlConstructor} from './const'; +import type { + BlockMenuItem, + StructureMenuItem as GroupedStructureMenuItem, + TemplateMenuGroup, +} from './groupTemplates'; + +const b = cnYfmHtmlConstructor; +const stop = STOP_EVENT_CLASSNAME; + +const getTitle = (template: {id: string; title?: string}) => template.title?.trim() || template.id; + +interface StructureTemplatesMenuItemsProps { + groups: TemplateMenuGroup[]; + filter: string; + emptyText: string; + onApply: ( + structure: HtmlConstructorStructureTemplate, + theme?: HtmlConstructorThemeTemplate, + ) => void; +} + +interface BlockTemplatesMenuItemsProps { + groups: TemplateMenuGroup[]; + filter: string; + emptyText: string; + onApply: (block: HtmlConstructorBlockTemplate, theme?: HtmlConstructorThemeTemplate) => void; +} + +interface TemplateGroupMenuItemProps { + group: TemplateMenuGroup; + open: boolean; + children: ReactNode; + onToggle: (title: string) => void; +} + +function TemplateGroupMenuItem({ + group, + open, + children, + onToggle, +}: TemplateGroupMenuItemProps) { + return ( + <> + } + onClick={() => onToggle(group.title)} + extraProps={{'aria-expanded': open}} + > + {group.title} + + {open && children} + + ); +} + +const useOpenGroups = (filter: string) => { + const [openGroups, setOpenGroups] = useState([]); + const hasFilter = Boolean(filter.trim()); + + const toggleGroup = (title: string) => { + setOpenGroups((current) => + current.includes(title) + ? current.filter((openTitle) => openTitle !== title) + : [...current, title], + ); + }; + + return {hasFilter, openGroups, toggleGroup}; +}; + +export function StructureTemplatesMenuItems({ + groups, + filter, + emptyText, + onApply, +}: StructureTemplatesMenuItemsProps) { + const {hasFilter, openGroups, toggleGroup} = useOpenGroups(filter); + + if (groups.length === 0) { + return ( + + {emptyText} + + ); + } + + return ( + <> + {groups.map((group) => ( + + {group.items.map(({structure, themes}) => ( + + ))} + + ))} + + ); +} + +const StructureMenuItem: FC<{ + structure: HtmlConstructorStructureTemplate; + themes: HtmlConstructorThemeTemplate[]; + onApply: ( + structure: HtmlConstructorStructureTemplate, + theme?: HtmlConstructorThemeTemplate, + ) => void; +}> = ({structure, themes, onApply}) => ( + <> + onApply(structure)} + > + {getTitle(structure)} + + {themes.map((theme) => ( + } + onClick={() => onApply(structure, theme)} + > + {getTitle(theme)} + + ))} + +); + +export function BlockTemplatesMenuItems({ + groups, + filter, + emptyText, + onApply, +}: BlockTemplatesMenuItemsProps) { + const {hasFilter, openGroups, toggleGroup} = useOpenGroups(filter); + + if (groups.length === 0) { + return ( + + {emptyText} + + ); + } + + return ( + <> + {groups.map((group) => ( + + {group.items.map((item) => ( + + ))} + + ))} + + ); +} + +const BlockMenuItemView: FC<{ + item: BlockMenuItem; + onApply: (block: HtmlConstructorBlockTemplate, theme?: HtmlConstructorThemeTemplate) => void; +}> = ({item, onApply}) => ( + <> + {item.states.map((state, index) => ( + + ))} + +); + +const BlockStateMenuItems: FC<{ + block: HtmlConstructorBlockTemplate; + primary: boolean; + themes: HtmlConstructorThemeTemplate[]; + onApply: (block: HtmlConstructorBlockTemplate, theme?: HtmlConstructorThemeTemplate) => void; +}> = ({block, primary, themes, onApply}) => ( + <> + onApply(block)} + > + {getTitle(block)} + + {themes.map((theme) => ( + } + onClick={() => onApply(block, theme)} + > + {getTitle(theme)} + + ))} + +); diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/HtmlBlockItem.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/HtmlBlockItem.tsx new file mode 100644 index 000000000..43665155a --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/HtmlBlockItem.tsx @@ -0,0 +1,341 @@ +import {useMemo, useState} from 'react'; +import type {FC, PointerEvent as ReactPointerEvent} from 'react'; + +import {GripHorizontal, Palette, Sliders} from '@gravity-ui/icons'; +import {Button, Icon} from '@gravity-ui/uikit'; + +import {i18n} from 'src/i18n/yfm-html-constructor'; + +import {blockClass, htmlConstructorBlockClass} from '../css'; +import {htmlConstructorQuickStyleToReactStyle} from '../quickStyle'; +import type { + HtmlConstructorBlock, + HtmlConstructorBlockTemplate, + HtmlConstructorQuickStyle, + HtmlConstructorTemplate, + HtmlConstructorThemeTemplate, +} from '../types'; + +import {FloatingToolbar, type FloatingToolbarPrimaryAction} from './FloatingToolbar'; +import {BlockSettingsPanel} from './SettingsPopups'; +import {TemplatePickerPanel} from './TemplatePicker'; +import type {PickerCardModel, PickerGroup} from './TemplatePicker'; +import { + applyBlockTemplateToBlock, + applyBlockThemeToBlock, + buildBlockPreviewParts, + getBlockTemplateById, + getBlockTemplateStateGroup, +} from './blockUtils'; +import {STOP_EVENT_CLASSNAME, cnYfmHtmlConstructor} from './const'; +import type {DropTarget} from './drag'; +import {getBlockDragAttrs} from './drag'; +import type {ConfirmFn} from './useConfirm'; +import {useInlineHtmlEditing} from './useInlineHtmlEditing'; + +const b = cnYfmHtmlConstructor; +const stop = STOP_EVENT_CLASSNAME; +const getTitle = (template: {id: string; title?: string}) => template.title?.trim() || template.id; + +interface HtmlBlockItemProps { + block: HtmlConstructorBlock; + index: number; + isDragged: boolean; + dropTarget: DropTarget | null; + templates: HtmlConstructorTemplate[]; + activeStructureId?: string; + onBeginDrag: (blockId: string, event: ReactPointerEvent) => void; + onCommitContent: (blockId: string, content: string) => void; + onCssChange: (blockId: string, css: string) => void; + onQuickStyleChange: (blockId: string, quickStyle: HtmlConstructorQuickStyle) => void; + onReplace: (blockId: string, block: HtmlConstructorBlock) => void; + onDuplicate: (blockId: string) => void; + onRemove: (blockId: string) => void; + confirm: ConfirmFn; +} + +type BlockPanel = 'state' | 'theme' | 'settings' | null; + +export const HtmlBlockItem: FC = ({ + block, + index, + isDragged, + dropTarget, + templates, + activeStructureId, + onBeginDrag, + onCommitContent, + onCssChange, + onQuickStyleChange, + onReplace, + onDuplicate, + onRemove, + confirm, +}) => { + const [blockPanel, setBlockPanel] = useState(null); + + const {contentRef, boundsRef, containerHandlers, overlay} = useInlineHtmlEditing({ + onCommit: (html) => onCommitContent(block.id, html), + }); + + const number = index + 1; + const isDropBefore = dropTarget?.id === block.id && dropTarget.placement === 'before'; + const isDropAfter = dropTarget?.id === block.id && dropTarget.placement === 'after'; + const activeBlockTemplate = useMemo( + () => getBlockTemplateById(templates, block.templateId), + [block.templateId, templates], + ); + const {states, themesByBlockId} = useMemo( + () => getBlockTemplateStateGroup(templates, block.templateId, activeStructureId), + [activeStructureId, block.templateId, templates], + ); + const blockThemes = activeBlockTemplate ? (themesByBlockId[activeBlockTemplate.id] ?? []) : []; + + const closeBlockPanel = () => setBlockPanel(null); + const toggleBlockPanel = (panel: Exclude) => { + setBlockPanel((current) => (current === panel ? null : panel)); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + onBeginDrag(block.id, event); + }; + + const applyBlockState = async ( + template: HtmlConstructorBlockTemplate, + theme?: HtmlConstructorThemeTemplate, + ) => { + // Switching state replaces the block's content, so confirm when there is + // something to lose. + if (block.content.trim()) { + const confirmed = await confirm({ + title: i18n('confirm_change_state_title'), + message: i18n('confirm_change_state_message'), + confirmText: i18n('change'), + danger: true, + }); + if (!confirmed) return; + } + + onReplace(block.id, applyBlockTemplateToBlock(block, template, theme)); + closeBlockPanel(); + }; + + const applyBlockTheme = (theme?: HtmlConstructorThemeTemplate) => { + if (!activeBlockTemplate) return; + + onReplace(block.id, applyBlockThemeToBlock(block, activeBlockTemplate, theme)); + closeBlockPanel(); + }; + + const buildStateGroups = (): PickerGroup[] => { + if (states.length === 0) return []; + + return [ + { + title: '', + cards: states.map((state): PickerCardModel => { + const stateThemes = themesByBlockId[state.id] ?? []; + + return { + id: state.id, + title: getTitle(state), + preview: buildBlockPreviewParts(state), + active: state.id === block.templateId, + badge: stateThemes.length + ? i18n('variants_count', {count: stateThemes.length}) + : undefined, + onApply: () => applyBlockState(state), + variants: stateThemes.map((theme) => ({ + key: theme.id, + label: getTitle(theme), + preview: buildBlockPreviewParts(state, theme), + onApply: () => applyBlockState(state, theme), + })), + }; + }), + }, + ]; + }; + + const buildThemeGroups = (): PickerGroup[] => { + if (!activeBlockTemplate || blockThemes.length === 0) return []; + + const hasActiveTheme = blockThemes.some((theme) => block.themeIds.includes(theme.id)); + const autoCard: PickerCardModel = { + id: '__auto', + title: i18n('auto'), + preview: buildBlockPreviewParts(activeBlockTemplate), + active: !hasActiveTheme, + onApply: () => applyBlockTheme(undefined), + variants: [], + }; + + return [ + { + title: '', + cards: [ + autoCard, + ...blockThemes.map( + (theme): PickerCardModel => ({ + id: theme.id, + title: getTitle(theme), + preview: buildBlockPreviewParts(activeBlockTemplate, theme), + active: block.themeIds.includes(theme.id), + onApply: () => applyBlockTheme(theme), + variants: [], + }), + ), + ], + }, + ]; + }; + + const renderBlockPanelContent = () => { + if (blockPanel === 'state') { + return ( + + ); + } + + if (blockPanel === 'theme') { + return ( + + ); + } + + if (blockPanel === 'settings') { + return ( + `, + bottom: '
', + }} + onHtmlCommit={(value) => onCommitContent(block.id, value)} + onCssChange={(value) => onCssChange(block.id, value)} + onClose={closeBlockPanel} + /> + ); + } + + return null; + }; + + const blockPanelContent = renderBlockPanelContent(); + const blockPrimaryActions = [ + { + id: 'blockState', + node: ( + + ), + }, + { + id: 'blockTheme', + node: ( + + ), + }, + ] as FloatingToolbarPrimaryAction[]; + + return ( +
+ + onQuickStyleChange(block.id, quickStyle)} + onOpenSettings={() => toggleBlockPanel('settings')} + primaryActions={blockPrimaryActions} + onDuplicate={() => onDuplicate(block.id)} + onRemove={async () => { + const confirmed = await confirm({ + title: i18n('confirm_remove_block_title'), + message: i18n('confirm_remove_block_message'), + confirmText: i18n('remove_block'), + danger: true, + }); + if (!confirmed) return; + + closeBlockPanel(); + onRemove(block.id); + }} + codeLabel={i18n('block_css', {index: String(number)})} + duplicateLabel={i18n('duplicate_block')} + removeLabel={i18n('remove_block')} + lockLabel={i18n('lock_block')} + expandedContentView={blockPanel === 'settings' ? 'editor' : 'panel'} + onCloseExpandedContent={closeBlockPanel} + expandedContent={blockPanelContent} + /> +
+
+ {overlay} +
+
+ ); +}; diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/NodeView.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/NodeView.tsx new file mode 100644 index 000000000..50299ac6a --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/NodeView.tsx @@ -0,0 +1,113 @@ +import {Portal} from '@gravity-ui/uikit'; +import type {Node} from 'prosemirror-model'; +import type {EditorView, NodeView} from 'prosemirror-view'; + +import {getReactRendererFromState} from 'src/extensions/behavior/ReactRenderer'; +import {generateEntityId, isInvalidEntityId} from 'src/utils/entity-id'; + +import { + YfmHtmlConstructorConsts, + defaultYfmHtmlConstructorEntityId, +} from '../YfmHtmlConstructorSpecs/const'; +import type {YfmHtmlConstructorExtensionOptions} from '../types'; + +import {YfmHtmlConstructorView} from './YfmHtmlConstructorView'; +import {STOP_EVENT_CLASSNAME} from './const'; + +export class WYfmHtmlConstructorNodeView implements NodeView { + readonly dom: HTMLElement; + private node: Node; + private readonly view: EditorView; + private readonly getPos: () => number | undefined; + private readonly options: YfmHtmlConstructorExtensionOptions; + private readonly renderItem; + + constructor({ + node, + view, + getPos, + options, + }: { + node: Node; + view: EditorView; + getPos: () => number | undefined; + options: YfmHtmlConstructorExtensionOptions; + }) { + this.node = node; + this.dom = document.createElement('div'); + this.dom.classList.add('yfm-html-constructor-root'); + this.dom.contentEditable = 'false'; + this.view = view; + this.getPos = getPos; + this.options = options; + + this.renderItem = getReactRendererFromState(view.state).createItem( + 'yfmHtmlConstructor-view', + this.renderYfmHtmlConstructor.bind(this), + ); + + this.validateEntityId(); + } + + update(node: Node) { + if (node.type !== this.node.type) return false; + this.node = node; + this.renderItem.rerender(); + return true; + } + + destroy() { + this.renderItem.remove(); + } + + ignoreMutation() { + return true; + } + + stopEvent(e: Event) { + const target = e.target as Element; + return Boolean(target.closest?.(`.${STOP_EVENT_CLASSNAME}`)); + } + + private validateEntityId() { + if ( + isInvalidEntityId({ + node: this.node, + doc: this.view.state.doc, + defaultId: defaultYfmHtmlConstructorEntityId, + }) + ) { + const newId = generateEntityId(YfmHtmlConstructorConsts.NodeName); + this.view.dispatch( + this.view.state.tr.setNodeAttribute( + this.getPos()!, + YfmHtmlConstructorConsts.NodeAttrs.EntityId, + newId, + ), + ); + } + } + + private onChange(attrs: Partial) { + const pos = this.getPos(); + if (pos === undefined) return; + + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, undefined, {...this.node.attrs, ...attrs}, []), + ); + } + + private renderYfmHtmlConstructor() { + return ( + + + + ); + } +} diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/SettingsPopups.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/SettingsPopups.tsx new file mode 100644 index 000000000..8a040fa86 --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/SettingsPopups.tsx @@ -0,0 +1,378 @@ +import {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import type {FC, UIEvent} from 'react'; + +import {Xmark} from '@gravity-ui/icons'; +import {Button, Icon, Popup, Switch} from '@gravity-ui/uikit'; + +import {i18n} from 'src/i18n/yfm-html-constructor'; + +import {useHtmlConstructorPreference} from '../preferences'; + +import {STOP_EVENT_CLASSNAME, cnYfmHtmlConstructor} from './const'; + +const b = cnYfmHtmlConstructor; +const stop = STOP_EVENT_CLASSNAME; + +// Kept in sync with `&__code-gutter` line-height and vertical padding in the +// stylesheet; used to count how many line numbers fill the visible body. +const GUTTER_LINE_HEIGHT = 20; +const GUTTER_PADDING_Y = 24; + +type CodeKind = 'html' | 'css'; + +/** Non-editable wrapper lines shown around editable HTML (the spec block markup). */ +export interface CodeFrame { + top: string; + bottom: string; +} + +interface CodeEditorPaneProps { + label: string; + value: string; + placeholder: string; + showLabel: boolean; + autoFocus: boolean; + onUpdate: (value: string) => void; + onCommit: () => void; + /** When set, renders read-only wrapper lines around the editable content. */ + frame?: CodeFrame; +} + +const CodeEditorPane: FC = ({ + label, + value, + placeholder, + showLabel, + autoFocus, + onUpdate, + onCommit, + frame, +}) => { + const gutterRef = useRef(null); + const bodyRef = useRef(null); + const controlRef = useRef(null); + const [visibleRows, setVisibleRows] = useState(0); + + useEffect(() => { + if (!autoFocus) return undefined; + + const timer = setTimeout(() => controlRef.current?.focus(), 30); + return () => clearTimeout(timer); + }, [autoFocus]); + + // Track how many gutter rows fit in the visible body so the line numbers can + // keep going past the last line instead of dead-ending into empty space. + useLayoutEffect(() => { + const node = bodyRef.current; + if (!node) return undefined; + + const measure = () => { + const available = node.clientHeight - GUTTER_PADDING_Y; + setVisibleRows(Math.max(0, Math.floor(available / GUTTER_LINE_HEIGHT))); + }; + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + const contentLines = value ? value.split('\n').length : 1; + const frameTopLines = frame?.top ? frame.top.split('\n').length : 0; + const frameBottomLines = frame?.bottom ? frame.bottom.split('\n').length : 0; + + // In framed mode the wrapper lines and content share one scroll area, so the + // textarea must grow to fit its content instead of scrolling on its own. + useLayoutEffect(() => { + if (!frame) return; + const node = controlRef.current; + if (!node) return; + node.style.height = 'auto'; + node.style.height = `${node.scrollHeight}px`; + }, [frame, value]); + + const lineNumbers = useMemo(() => { + const contentTotal = contentLines + frameTopLines + frameBottomLines; + const total = Math.max(contentTotal, visibleRows); + let result = ''; + for (let line = 1; line <= total; line++) { + result += line === 1 ? '1' : `\n${line}`; + } + return result; + }, [contentLines, frameTopLines, frameBottomLines, visibleRows]); + + const syncScroll = (event: UIEvent) => { + if (gutterRef.current) gutterRef.current.scrollTop = event.currentTarget.scrollTop; + }; + + const control = ( +