diff --git a/AGENTS.md b/AGENTS.md index af1adb6..8e50093 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,9 +29,9 @@ Ключевые сущности: - `BlockNode` содержит `rows`, `settings` (`BlockSettings`) и `label`. -- `RowNode` содержит `cells` и `settings` (`RowSettings` с `gap`, optional `height`). -- `CellNode` содержит `atoms`, `rows` (вложенность поддерживается рекурсивно) и `settings` (`CellSettings` с `verticalAlign`, `horizontalAlign`, `borderRadius`, `width`, `height`, `link`). -- `Atom` — контентный узел (`text`, `button`, `divider`, `image`, `menu`). Базовый `BaseAtom` имеет `id`, `type`, optional `spacing`. +- `RowNode` содержит `cells` и `settings` (`RowSettings` с `gap`, optional `height`, optional `hiddenOnMobile`, optional `collapseOnMobile`). +- `CellNode` содержит `atoms`, `rows` (вложенность поддерживается рекурсивно) и `settings` (`CellSettings` с `verticalAlign`, `horizontalAlign`, `borderRadius`, `width`, `height`, `link`, optional `hiddenOnMobile`). +- `Atom` — контентный узел (`text`, `button`, `divider`, `image`, `menu`). Базовый `BaseAtom` имеет `id`, `type`, optional `spacing`, optional `hiddenOnMobile`. - `CanvasBlockInstance` — элемент канваса с `id`, `version: 2`, `block`. - `BlockPreset` — расширяет `CanvasBlockInstance`, добавляет `name`, `label`, `type` (`ComponentType`), `preview`. @@ -57,14 +57,17 @@ ID-правила: | Валидация/санитизация/migration/remap id шаблона | `src/entities/template/template-io.ts` | | Barrel-export модели редактора | `src/features/editor/model/index.ts` | | Типы модели редактора (`BlockSelectionLevel`, `SidebarTab`, re-export entities) | `src/features/editor/model/types.ts` | -| Shared reactive state (`installed`, `editableId`, `isDragging`, `general`, `list`, `templateImportIssues`) | `src/features/editor/model/state.ts` | +| Shared reactive state (`installed`, `editableId`, `isDragging`, `previewMode`, `general`, `list`, `templateImportIssues`) | `src/features/editor/model/state.ts` | | CRUD дерева на канвасе | `src/features/editor/model/use-canvas.ts` (`useCanvas`) | | Выделение block/row/cell/atom, sidebar tab, tree scroll | `src/features/editor/model/use-selection.ts` (`useSelection`) | | Template IO интеграция со store | `src/features/editor/model/use-template-io.ts` (`useTemplateIO`) | | Persist/Hydrate localStorage | `src/features/editor/model/use-persistence.ts` (`usePersistence`) | | Tool-утилиты (трансформации, defaults) | `src/features/editor/model/tools.ts` | -| Главный рендер блока | `src/features/email-preview/ui/BlockRenderer.vue` | -| Рендер row/cell/atom | `src/features/email-preview/ui/BlockRendererRowNode.vue` | +| Главный рендер блока (preview) | `src/features/email-preview/ui/BlockRenderer.vue` | +| Рендер row/cell/atom (preview) | `src/features/email-preview/ui/BlockRendererRowNode.vue` | +| Главный рендер блока (export) | `src/features/email-preview/ui/ExportBlockRenderer.vue` | +| Рендер row/cell/atom (export) | `src/features/email-preview/ui/ExportBlockRendererRowNode.vue` | +| HTML-документ экспорта | `src/features/email-preview/ui/EmailExportDocument.vue` | | Панель настроек блока/атома | `src/features/editor/components/tools/BlockSettingsPanel.vue` | | Каталог пресетов (JSON-файлы, загрузчик, хелперы) | `src/features/email-preview/catalog/` | | JSON-схема пресетов | `src/features/email-preview/catalog/block.schema.json` | @@ -88,7 +91,7 @@ ID-правила: - **`useSelection()`** — выделение: `selectBlock`, `selectRow`, `selectCell`, `selectAtom`, `resetSelection`. Computed: `selectedBlock`, `selectedRow`, `selectedCell`, `selectedAtom`. Управление UI: `sidebarActiveTab`, `treeScrollTarget`, `treeScrollRequestId`, `requestTreeScroll`, `openTreeAndScroll`, `selectionLevel`. -- **`useTemplateIO()`** — импорт/экспорт: `importTemplate`, `importTemplateFromJson`, `exportTemplate`, `exportTemplateJson`, `applyImportedTemplate`, `withPersistLock`, `isPersistIgnored`, `triggerPersist`. +- **`useTemplateIO()`** — импорт/экспорт: `importTemplate`, `importTemplateFromJson`, `exportTemplate`, `exportTemplateJson`, `exportTemplateHtml`, `applyImportedTemplate`, `withPersistLock`, `isPersistIgnored`, `triggerPersist`. - **`usePersistence()`** — localStorage: `persistTemplateToLocalStorage`, `hydrateTemplateFromLocalStorage`, `initTemplatePersistence` (auto-init при создании singleton). @@ -96,6 +99,7 @@ ID-правила: - `installed` — `ref` — установленные блоки на канвасе. - `editableId` — `ref` — ID выбранного блока. - `isDragging` — `ref` — состояние перетаскивания. +- `previewMode` — `ref<'desktop' | 'mobile'>` — режим предпросмотра. - `general` — `reactive` — глобальные настройки (padding, background, font, previewText). - `list` — `shallowRef` — категории пресетов с блоками (Menu, Header, Content, Feature, Call to Action, E-Commerce, Footer). - `templateImportIssues` — `ref` — проблемы импорта. @@ -216,6 +220,7 @@ Smoke-check вручную (критично для этого проекта): 3. Import в обоих режимах: `replace` и `append`. 4. Перезагрузка страницы восстанавливает состояние из localStorage. 5. В Shadow DOM стили элемента выглядят корректно и не текут наружу. +6. Export HTML повторяет ключевое поведение preview (mobile hide/collapse). ## 9. Антипаттерны (не делать) diff --git a/src/entities/block/block-factory.ts b/src/entities/block/block-factory.ts index b86da5c..0e90717 100644 --- a/src/entities/block/block-factory.ts +++ b/src/entities/block/block-factory.ts @@ -26,6 +26,8 @@ export function createDefaultRowSettings(): RowSettings { return { spacing: {}, backgroundColor: 'transparent', + hiddenOnMobile: false, + collapseOnMobile: true, gap: 0, } } @@ -35,6 +37,7 @@ export function createDefaultCellSettings(): CellSettings { spacing: {}, backgroundColor: 'transparent', link: undefined, + hiddenOnMobile: false, verticalAlign: 'top', horizontalAlign: 'left', borderRadius: undefined, diff --git a/src/entities/block/types.ts b/src/entities/block/types.ts index 6ab37da..6406905 100644 --- a/src/entities/block/types.ts +++ b/src/entities/block/types.ts @@ -4,6 +4,7 @@ export interface BaseAtom { id: string type: AtomType spacing?: SpacingValue + hiddenOnMobile?: boolean } export interface TextAtom extends BaseAtom { @@ -80,6 +81,8 @@ export interface RowSettings { spacing: SpacingValue backgroundColor: string backgroundImage?: BackgroundImageValue + hiddenOnMobile?: boolean + collapseOnMobile?: boolean height?: number gap: number } @@ -89,6 +92,7 @@ export interface CellSettings { backgroundColor: string backgroundImage?: BackgroundImageValue link?: string + hiddenOnMobile?: boolean verticalAlign: 'top' | 'middle' | 'bottom' horizontalAlign?: 'left' | 'center' | 'right' borderRadius?: number diff --git a/src/entities/template/template-io.ts b/src/entities/template/template-io.ts index 3923480..1de38fb 100644 --- a/src/entities/template/template-io.ts +++ b/src/entities/template/template-io.ts @@ -137,6 +137,10 @@ function isString(value: unknown): value is string { return typeof value === 'string' } +function toOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + function pushIssue(issues: TemplateValidationIssue[], path: string, message: string) { issues.push({ path, message }) } @@ -511,6 +515,10 @@ function validateAtom(value: unknown, path: string, issues: TemplateValidationIs if (!isString(value.type)) pushIssue(issues, `${path}.type`, 'atom.type must be a string') + if (value.hiddenOnMobile !== undefined && typeof value.hiddenOnMobile !== 'boolean') { + pushIssue(issues, `${path}.hiddenOnMobile`, 'atom.hiddenOnMobile must be a boolean') + } + if (value.type === 'text') { if (!isString(value.value)) pushIssue(issues, `${path}.value`, 'text atom value must be a string') @@ -722,6 +730,25 @@ function validateCellNode(value: unknown, path: string, issues: TemplateValidati pushIssue(issues, `${path}.settings.link`, 'cell.settings.link must be a string') } + if ( + value.settings.hiddenOnMobile !== undefined + && typeof value.settings.hiddenOnMobile !== 'boolean' + ) { + pushIssue( + issues, + `${path}.settings.hiddenOnMobile`, + 'cell.settings.hiddenOnMobile must be a boolean', + ) + } + + if (value.settings.collapseOnMobile !== undefined) { + pushIssue( + issues, + `${path}.settings.collapseOnMobile`, + 'cell.settings.collapseOnMobile is no longer supported. Use row.settings.collapseOnMobile', + ) + } + if (value.settings.width !== undefined && !isFiniteNumber(value.settings.width)) { pushIssue(issues, `${path}.settings.width`, 'cell.settings.width must be a number') } @@ -798,6 +825,28 @@ function validateRowNode(value: unknown, path: string, issues: TemplateValidatio pushIssue(issues, `${path}.settings.gap`, 'row.settings.gap must be a number') } + if ( + value.settings.hiddenOnMobile !== undefined + && typeof value.settings.hiddenOnMobile !== 'boolean' + ) { + pushIssue( + issues, + `${path}.settings.hiddenOnMobile`, + 'row.settings.hiddenOnMobile must be a boolean', + ) + } + + if ( + value.settings.collapseOnMobile !== undefined + && typeof value.settings.collapseOnMobile !== 'boolean' + ) { + pushIssue( + issues, + `${path}.settings.collapseOnMobile`, + 'row.settings.collapseOnMobile must be a boolean', + ) + } + validateBackgroundImageValue( value.settings.backgroundImage, `${path}.settings.backgroundImage`, @@ -981,6 +1030,7 @@ function sanitizeAtoms(atoms: Atom[]): Atom[] { if (atom.type === 'text') { return { ...atom, + hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile), color: atom.color || '#111827', value: sanitizeTextEditorHtml(atom.value), } @@ -989,6 +1039,7 @@ function sanitizeAtoms(atoms: Atom[]): Atom[] { if (atom.type === 'image') { return { ...atom, + hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile), src: typeof atom.src === 'string' ? atom.src : '', link: typeof atom.link === 'string' ? atom.link : '', alt: typeof atom.alt === 'string' ? atom.alt : '', @@ -1013,6 +1064,7 @@ function sanitizeAtoms(atoms: Atom[]): Atom[] { return { ...atom, + hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile), itemType: normalizedItemType, gap: isFiniteNumber(atom.gap) && atom.gap >= 0 ? atom.gap : 10, items: Array.isArray(atom.items) @@ -1065,29 +1117,46 @@ function sanitizeAtoms(atoms: Atom[]): Atom[] { } } - return atom + return { + ...atom, + hiddenOnMobile: toOptionalBoolean(atom.hiddenOnMobile), + } }) } function sanitizeCellNodes(cells: CellNode[]): CellNode[] { - return cells.map(cell => ({ - ...cell, - settings: { + return cells.map((cell) => { + const settings = { ...cell.settings, link: typeof cell.settings?.link === 'string' ? cell.settings.link : undefined, + hiddenOnMobile: toOptionalBoolean(cell.settings?.hiddenOnMobile), borderRadius: isFiniteNumber(cell.settings?.borderRadius) && cell.settings.borderRadius >= 0 ? cell.settings.borderRadius : undefined, - }, - atoms: sanitizeAtoms(cell.atoms), - rows: sanitizeRowNodes(cell.rows || []), - })) + } + + // Hard-drop legacy per-cell collapse to keep exported schema clean. + delete (settings as Record).collapseOnMobile + + return { + ...cell, + settings, + atoms: sanitizeAtoms(cell.atoms), + rows: sanitizeRowNodes(cell.rows || []), + } + }) } function sanitizeRowNodes(rows: RowNode[]): RowNode[] { return rows.map(row => ({ ...row, + settings: { + ...row.settings, + hiddenOnMobile: toOptionalBoolean(row.settings?.hiddenOnMobile), + collapseOnMobile: + typeof row.settings?.collapseOnMobile === 'boolean' ? row.settings.collapseOnMobile : true, + }, cells: sanitizeCellNodes(row.cells), })) } diff --git a/src/features/editor/components/header/TheHeader.vue b/src/features/editor/components/header/TheHeader.vue index b69ce33..6ec5775 100644 --- a/src/features/editor/components/header/TheHeader.vue +++ b/src/features/editor/components/header/TheHeader.vue @@ -1,6 +1,6 @@ + + diff --git a/src/features/email-preview/ui/ExportBlockRenderer.vue b/src/features/email-preview/ui/ExportBlockRenderer.vue new file mode 100644 index 0000000..cb06aeb --- /dev/null +++ b/src/features/email-preview/ui/ExportBlockRenderer.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/features/email-preview/ui/ExportBlockRendererRowNode.vue b/src/features/email-preview/ui/ExportBlockRendererRowNode.vue new file mode 100644 index 0000000..b475b88 --- /dev/null +++ b/src/features/email-preview/ui/ExportBlockRendererRowNode.vue @@ -0,0 +1,436 @@ + + + diff --git a/src/shared/assets/css/preview.css b/src/shared/assets/css/preview.css index e63f2e2..226cd01 100644 --- a/src/shared/assets/css/preview.css +++ b/src/shared/assets/css/preview.css @@ -227,3 +227,25 @@ a { font-size: 16px; content: 'Drop Here'; } + +.p-preview-mobile td.e-col-mobile-collapse { + display: block !important; + width: 100% !important; + max-width: 100% !important; +} + +.p-preview-mobile td.e-col-gap-mobile-collapse { + display: block !important; + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + height: var(--e-row-gap, 0px) !important; + min-height: var(--e-row-gap, 0px) !important; + line-height: 0 !important; +} + +.p-preview-mobile .e-mobile-hidden { + display: none !important; + max-height: 0 !important; + overflow: hidden !important; +}