Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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` |
Expand All @@ -88,14 +91,15 @@ 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).

Общее реактивное состояние вынесено в `state.ts`:
- `installed` — `ref<CanvasBlockInstance[]>` — установленные блоки на канвасе.
- `editableId` — `ref<string>` — ID выбранного блока.
- `isDragging` — `ref<boolean>` — состояние перетаскивания.
- `previewMode` — `ref<'desktop' | 'mobile'>` — режим предпросмотра.
- `general` — `reactive<GeneralTool>` — глобальные настройки (padding, background, font, previewText).
- `list` — `shallowRef<ComponentList[]>` — категории пресетов с блоками (Menu, Header, Content, Feature, Call to Action, E-Commerce, Footer).
- `templateImportIssues` — `ref<TemplateValidationIssue[]>` — проблемы импорта.
Expand Down Expand Up @@ -216,6 +220,7 @@ Smoke-check вручную (критично для этого проекта):
3. Import в обоих режимах: `replace` и `append`.
4. Перезагрузка страницы восстанавливает состояние из localStorage.
5. В Shadow DOM стили элемента выглядят корректно и не текут наружу.
6. Export HTML повторяет ключевое поведение preview (mobile hide/collapse).

## 9. Антипаттерны (не делать)

Expand Down
3 changes: 3 additions & 0 deletions src/entities/block/block-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export function createDefaultRowSettings(): RowSettings {
return {
spacing: {},
backgroundColor: 'transparent',
hiddenOnMobile: false,
collapseOnMobile: true,
gap: 0,
}
}
Expand All @@ -35,6 +37,7 @@ export function createDefaultCellSettings(): CellSettings {
spacing: {},
backgroundColor: 'transparent',
link: undefined,
hiddenOnMobile: false,
verticalAlign: 'top',
horizontalAlign: 'left',
borderRadius: undefined,
Expand Down
4 changes: 4 additions & 0 deletions src/entities/block/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface BaseAtom {
id: string
type: AtomType
spacing?: SpacingValue
hiddenOnMobile?: boolean
}

export interface TextAtom extends BaseAtom {
Expand Down Expand Up @@ -80,6 +81,8 @@ export interface RowSettings {
spacing: SpacingValue
backgroundColor: string
backgroundImage?: BackgroundImageValue
hiddenOnMobile?: boolean
collapseOnMobile?: boolean
height?: number
gap: number
}
Expand All @@ -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
Expand Down
85 changes: 77 additions & 8 deletions src/entities/template/template-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
}
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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),
}
Expand All @@ -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 : '',
Expand All @@ -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)
Expand Down Expand Up @@ -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<string, unknown>).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),
}))
}
Expand Down
64 changes: 56 additions & 8 deletions src/features/editor/components/header/TheHeader.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { TemplateImportMode } from '@/entities/template'
import { MoreHorizontal as MoreHorizontalIcon } from 'lucide-vue-next'
import { Monitor, MoreHorizontal, Smartphone } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { useCanvas, useTemplateIO } from '@/features/editor/model'
import { Button } from '@/shared/ui/button'
Expand All @@ -10,6 +10,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/shared/ui/dropdown-menu'
import { Switch } from '@/shared/ui/switch'
Expand All @@ -25,8 +27,8 @@ const pendingImportRaw = ref('')
const pendingImportFileName = ref('')
const fileInputRef = ref<HTMLInputElement>()

const { exportTemplateJson, importTemplateFromJson } = useTemplateIO()
const { templateImportIssues, clearCanvas } = useCanvas()
const { exportTemplateHtml, exportTemplateJson, importTemplateFromJson } = useTemplateIO()
const { templateImportIssues, clearCanvas, previewMode } = useCanvas()

const importModeValue = computed<string>({
get: () => importMode.value,
Expand All @@ -36,6 +38,14 @@ const importModeValue = computed<string>({
},
})

const previewModeValue = computed<string>({
get: () => previewMode.value,
set: (next) => {
if (next === 'desktop' || next === 'mobile')
previewMode.value = next
},
})

const visibleImportIssues = computed(() => {
if (importStatus.value !== 'error')
return []
Expand Down Expand Up @@ -63,6 +73,24 @@ function exportTemplateToFile() {
importMessage.value = ''
}

function exportTemplateHtmlToFile() {
const now = new Date().toISOString().replace(/[:.]/g, '-')
const payload = exportTemplateHtml()
const file = new Blob([payload], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(file)

const link = document.createElement('a')
link.href = url
link.download = `email-template-${now}.html`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)

importStatus.value = 'idle'
importMessage.value = ''
}

function resetImportDialog() {
importStatus.value = 'idle'
importMessage.value = ''
Expand Down Expand Up @@ -140,8 +168,20 @@ watch(importDialogVisible, (open) => {
<div class="flex items-center">
<SvgLogoWhite width="100" />
</div>
<div class="tools">
tools
<div class="tools flex items-center justify-center">
<ToggleGroup
v-model="previewModeValue"
type="single"
variant="outline"
size="xs"
>
<ToggleGroupItem value="desktop">
<Monitor class="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="mobile">
<Smartphone class="size-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
<div class="actions flex items-center">
<ButtonGroup>
Expand All @@ -159,15 +199,23 @@ watch(importDialogVisible, (open) => {
size="icon-sm"
aria-label="More Options"
>
<MoreHorizontalIcon />
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Export</DropdownMenuLabel>
<DropdownMenuItem @select="exportTemplateHtmlToFile">
HTML
</DropdownMenuItem>
<DropdownMenuItem @select="exportTemplateToFile">
Export JSON
Template JSON
</DropdownMenuItem>

<DropdownMenuSeparator />

<DropdownMenuLabel>Import</DropdownMenuLabel>
<DropdownMenuItem @select="openImportDialog">
Import JSON
Template JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Expand Down
Loading