Implement a complete visual PDF document editor and generation system as a Single Page Application built with SvelteKit. This initial release includes: Core Engine: - Document model with hierarchical node types (document, section, row, column, primitives) - Layout engine with flex-like positioning algorithm - Tree traversal and manipulation utilities Visual Editor: - Canvas-based document preview with page rendering - Component toolbar with containers, primitives, and complex elements - Properties panel for editing node styles and content - Insertion popup for inline element placement - Strict hierarchy enforcement (document → section → row → column → elements) PDF Generation: - Abstract renderer interface for multiple backends - jsPDF implementation with text, image, and shape rendering - Document metadata support State Management: - Svelte stores for document, selection, and history - Undo/redo support with command pattern - JSON import/export for document persistence Configuration: - SvelteKit with static adapter - Tailwind CSS v4 integration - Vitest for testing - Prettier for code formatting - TypeScript with strict modemain
commit
0ab4e82cab
@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@ -0,0 +1,9 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/routes/layout.css"
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.13.0 create --template minimal --types ts --add prettier vitest="usages:component,unit" tailwindcss="plugins:none" sveltekit-adapter="adapter:static" --install npm pdf-engine-lab
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "pdf-engine-lab",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"playwright": "^1.58.2",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.0",
|
||||
"vitest-browser-svelte": "^2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"jspdf": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,883 @@
|
||||
# PDF Engine Lab - Comprehensive Architecture Plan
|
||||
|
||||
## Overview
|
||||
|
||||
**Project Goal**: Build a complete visual PDF document editor and generation system as a **Single Page Application (SPA)**.
|
||||
|
||||
The system enables users to design PDF documents visually using blocks, layouts, and composition rules without writing code.
|
||||
|
||||
---
|
||||
|
||||
## System Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph SPA[Single Page Application - SvelteKit]
|
||||
subgraph UI[Visual Editor UI]
|
||||
Canvas[Document Canvas]
|
||||
Toolbar[Block Toolbar]
|
||||
PropsPanel[Properties Panel]
|
||||
Preview[PDF Preview]
|
||||
end
|
||||
|
||||
subgraph Core[Core Engine]
|
||||
DocModel[Document Model]
|
||||
LayoutEngine[Layout Engine]
|
||||
Paginator[Paginator]
|
||||
end
|
||||
|
||||
subgraph Renderers[Renderer Layer]
|
||||
CanvasRender[Canvas Renderer]
|
||||
PDFRender[PDF Renderer]
|
||||
end
|
||||
end
|
||||
|
||||
UI --> DocModel
|
||||
DocModel --> LayoutEngine
|
||||
LayoutEngine --> Paginator
|
||||
Paginator --> Renderers
|
||||
CanvasRender --> Canvas
|
||||
PDFRender --> Preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── core/ # Core engine - framework agnostic
|
||||
│ │ ├── model/ # Document model types and utilities
|
||||
│ │ │ ├── types.ts # Node types, interfaces
|
||||
│ │ │ ├── node.ts # Node creation/manipulation
|
||||
│ │ │ ├── tree.ts # Tree traversal utilities
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── layout/ # Layout engine
|
||||
│ │ │ ├── engine.ts # Main layout calculation
|
||||
│ │ │ ├── constraints.ts # Size constraints
|
||||
│ │ │ ├── flex.ts # Flex-like layout logic
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── pagination/ # Pagination system
|
||||
│ │ │ ├── paginator.ts # Page break logic
|
||||
│ │ │ ├── fragmentation.ts # Node splitting across pages
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── components/ # Document component definitions
|
||||
│ │ ├── primitives/ # Basic building blocks
|
||||
│ │ │ ├── text.ts
|
||||
│ │ │ ├── image.ts
|
||||
│ │ │ ├── spacer.ts
|
||||
│ │ │ └── divider.ts
|
||||
│ │ │
|
||||
│ │ ├── containers/ # Layout containers
|
||||
│ │ │ ├── row.ts
|
||||
│ │ │ ├── column.ts
|
||||
│ │ │ └── stack.ts
|
||||
│ │ │
|
||||
│ │ ├── complex/ # Complex components
|
||||
│ │ │ ├── table.ts
|
||||
│ │ │ ├── box.ts
|
||||
│ │ │ └── page.ts
|
||||
│ │ │
|
||||
│ │ ├── registry.ts # Component registration system
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── renderer/ # Rendering layer
|
||||
│ │ ├── types.ts # Renderer interfaces
|
||||
│ │ ├── canvas/ # Canvas/HTML preview renderer
|
||||
│ │ │ ├── renderer.ts
|
||||
│ │ │ ├── text.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── jspdf/ # jsPDF renderer
|
||||
│ │ │ ├── renderer.ts
|
||||
│ │ │ ├── text.ts
|
||||
│ │ │ ├── shapes.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── editor/ # Visual editor - Svelte components
|
||||
│ │ ├── canvas/ # Document canvas
|
||||
│ │ │ ├── Canvas.svelte
|
||||
│ │ │ ├── Page.svelte
|
||||
│ │ │ └── NodeRenderer.svelte
|
||||
│ │ │
|
||||
│ │ ├── toolbar/ # Block insertion toolbar
|
||||
│ │ │ ├── Toolbar.svelte
|
||||
│ │ │ ├── BlockButton.svelte
|
||||
│ │ │ └── LayoutPicker.svelte
|
||||
│ │ │
|
||||
│ │ ├── panels/ # Side panels
|
||||
│ │ │ ├── PropertiesPanel.svelte
|
||||
│ │ │ ├── TreePanel.svelte
|
||||
│ │ │ └── StylesPanel.svelte
|
||||
│ │ │
|
||||
│ │ ├── interactions/ # User interactions
|
||||
│ │ │ ├── selection.ts
|
||||
│ │ │ ├── dragdrop.ts
|
||||
│ │ │ └── history.ts # Undo/redo
|
||||
│ │ │
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── stores/ # Svelte stores for state
|
||||
│ │ ├── document.ts # Document state
|
||||
│ │ ├── selection.ts # Selection state
|
||||
│ │ ├── history.ts # Undo/redo state
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ └── utils/ # Utility functions
|
||||
│ ├── math.ts
|
||||
│ ├── colors.ts
|
||||
│ └── index.ts
|
||||
│
|
||||
├── routes/
|
||||
│ └── +page.svelte # Main SPA entry point
|
||||
│
|
||||
└── app.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Document Model
|
||||
|
||||
### Node Types
|
||||
|
||||
```typescript
|
||||
// src/lib/core/model/types.ts
|
||||
|
||||
/** Base node structure */
|
||||
interface BaseNode {
|
||||
id: string
|
||||
type: string
|
||||
props?: Record<string, unknown>
|
||||
style?: NodeStyle
|
||||
children?: Node[]
|
||||
}
|
||||
|
||||
/** Style properties - CSS-inspired */
|
||||
interface NodeStyle {
|
||||
// Layout
|
||||
width?: Dimension
|
||||
height?: Dimension
|
||||
minWidth?: Dimension
|
||||
maxWidth?: Dimension
|
||||
minHeight?: Dimension
|
||||
minHeight?: Dimension
|
||||
|
||||
// Spacing
|
||||
margin?: Spacing
|
||||
padding?: Spacing
|
||||
|
||||
// Flex container
|
||||
direction?: 'row' | 'column'
|
||||
justify?: 'start' | 'center' | 'end' | 'between' | 'around'
|
||||
align?: 'start' | 'center' | 'end' | 'stretch'
|
||||
gap?: number
|
||||
|
||||
// Visual
|
||||
backgroundColor?: string
|
||||
borderColor?: string
|
||||
borderWidth?: number
|
||||
borderRadius?: number
|
||||
|
||||
// Typography
|
||||
fontFamily?: string
|
||||
fontSize?: number
|
||||
fontWeight?: string
|
||||
color?: string
|
||||
textAlign?: 'left' | 'center' | 'right'
|
||||
lineHeight?: number
|
||||
}
|
||||
|
||||
type Dimension = number | string // 100 or '100%' or 'auto'
|
||||
type Spacing = number | [number] | [number, number] | [number, number, number, number]
|
||||
|
||||
/** Union of all node types */
|
||||
type Node =
|
||||
| ContainerNode
|
||||
| TextNode
|
||||
| ImageNode
|
||||
| SpacerNode
|
||||
| DividerNode
|
||||
| TableNode
|
||||
| BoxNode
|
||||
```
|
||||
|
||||
### Component Definitions
|
||||
|
||||
```typescript
|
||||
// Container nodes
|
||||
interface ContainerNode extends BaseNode {
|
||||
type: 'row' | 'column' | 'stack'
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
// Primitive nodes
|
||||
interface TextNode extends BaseNode {
|
||||
type: 'text'
|
||||
props: {
|
||||
value: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ImageNode extends BaseNode {
|
||||
type: 'image'
|
||||
props: {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SpacerNode extends BaseNode {
|
||||
type: 'spacer'
|
||||
props?: {
|
||||
size?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface DividerNode extends BaseNode {
|
||||
type: 'divider'
|
||||
props?: {
|
||||
thickness?: number
|
||||
style?: 'solid' | 'dashed' | 'dotted'
|
||||
}
|
||||
}
|
||||
|
||||
// Complex nodes
|
||||
interface TableNode extends BaseNode {
|
||||
type: 'table'
|
||||
props: {
|
||||
columns: TableColumn[]
|
||||
data: Record<string, unknown>[]
|
||||
}
|
||||
}
|
||||
|
||||
interface BoxNode extends BaseNode {
|
||||
type: 'box'
|
||||
children: Node[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout Engine Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Input
|
||||
DocTree[Document Tree]
|
||||
PageSize[Page Size]
|
||||
end
|
||||
|
||||
subgraph LayoutEngine[Layout Engine]
|
||||
Constraints[Constraint Solver]
|
||||
FlexLayout[Flex Layout]
|
||||
Measure[Text Measurement]
|
||||
end
|
||||
|
||||
subgraph Output
|
||||
LayoutTree[Layout Tree]
|
||||
Boxes[Positioned Boxes]
|
||||
end
|
||||
|
||||
DocTree --> Constraints
|
||||
PageSize --> Constraints
|
||||
Constraints --> FlexLayout
|
||||
FlexLayout --> Measure
|
||||
Measure --> LayoutTree
|
||||
LayoutTree --> Boxes
|
||||
```
|
||||
|
||||
### Layout Algorithm
|
||||
|
||||
```typescript
|
||||
// src/lib/core/layout/engine.ts
|
||||
|
||||
interface LayoutContext {
|
||||
pageWidth: number
|
||||
pageHeight: number
|
||||
pageMargins: [number, number, number, number]
|
||||
}
|
||||
|
||||
interface LayoutBox {
|
||||
nodeId: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
children?: LayoutBox[]
|
||||
}
|
||||
|
||||
function calculateLayout(node: Node, context: LayoutContext): LayoutBox {
|
||||
// 1. Resolve constraints (%, auto, fixed)
|
||||
// 2. Calculate available space
|
||||
// 3. Layout children based on container type
|
||||
// 4. Return positioned box tree
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pagination System
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Content[Content Tree]
|
||||
Layout[Layout Boxes]
|
||||
|
||||
subgraph Pagination[Pagination Process]
|
||||
Check{Fits in page?}
|
||||
AddPage[Add new page]
|
||||
Fragment[Fragment node]
|
||||
Continue[Continue layout]
|
||||
end
|
||||
|
||||
Pages[Paginated Pages]
|
||||
|
||||
Content --> Layout
|
||||
Layout --> Check
|
||||
Check -->|Yes| Continue
|
||||
Check -->|No| Fragment
|
||||
Fragment --> AddPage
|
||||
AddPage --> Continue
|
||||
Continue --> Pages
|
||||
```
|
||||
|
||||
### Fragmentation Strategy
|
||||
|
||||
```typescript
|
||||
// src/lib/core/pagination/fragmentation.ts
|
||||
|
||||
interface FragmentResult {
|
||||
first: Node | null // Part that fits on current page
|
||||
second: Node | null // Part that goes to next page
|
||||
}
|
||||
|
||||
function canFragment(node: Node): boolean {
|
||||
// Text: yes
|
||||
// Table: yes
|
||||
// Container: yes (if children can be split)
|
||||
// Image: no
|
||||
}
|
||||
|
||||
function fragmentNode(node: Node, availableHeight: number): FragmentResult {
|
||||
// Split node across pages
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Renderer Abstraction
|
||||
|
||||
```typescript
|
||||
// src/lib/renderer/types.ts
|
||||
|
||||
interface RenderContext {
|
||||
pageWidth: number
|
||||
pageHeight: number
|
||||
currentPage: number
|
||||
}
|
||||
|
||||
interface Renderer {
|
||||
name: string
|
||||
|
||||
// Document lifecycle
|
||||
beginDocument(context: RenderContext): void
|
||||
endDocument(): void
|
||||
|
||||
// Page lifecycle
|
||||
beginPage(pageNumber: number): void
|
||||
endPage(): void
|
||||
|
||||
// Drawing primitives
|
||||
drawText(text: string, box: LayoutBox, style: TextStyle): void
|
||||
drawRect(box: LayoutBox, style: RectStyle): void
|
||||
drawImage(src: string, box: LayoutBox): void
|
||||
drawLine(from: Point, to: Point, style: LineStyle): void
|
||||
|
||||
// Output
|
||||
getOutput(): unknown // PDF blob, canvas, etc.
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual Editor Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph EditorState[Editor State - Svelte Stores]
|
||||
DocStore[Document Store]
|
||||
SelectStore[Selection Store]
|
||||
HistoryStore[History Store]
|
||||
end
|
||||
|
||||
subgraph UI[Editor UI Components]
|
||||
Canvas[Document Canvas]
|
||||
Toolbar[Block Toolbar]
|
||||
PropsPanel[Properties Panel]
|
||||
TreeView[Tree View]
|
||||
end
|
||||
|
||||
subgraph Interactions[User Interactions]
|
||||
Selection[Selection Manager]
|
||||
DragDrop[Drag and Drop]
|
||||
Keyboard[Keyboard Shortcuts]
|
||||
end
|
||||
|
||||
DocStore --> Canvas
|
||||
SelectStore --> PropsPanel
|
||||
SelectStore --> Canvas
|
||||
|
||||
Canvas --> Selection
|
||||
Toolbar --> DragDrop
|
||||
DragDrop --> DocStore
|
||||
|
||||
UI --> Interactions
|
||||
Interactions --> EditorState
|
||||
```
|
||||
|
||||
### Editor Stores
|
||||
|
||||
```typescript
|
||||
// src/lib/stores/document.ts
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
interface DocumentState {
|
||||
root: Node
|
||||
pageSettings: PageSettings
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
function createDocumentStore() {
|
||||
const { subscribe, set, update } = writable<DocumentState>({
|
||||
root: createEmptyDocument(),
|
||||
pageSettings: { size: 'A4', margins: [40, 40, 40, 40] },
|
||||
dirty: false
|
||||
})
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setRoot: (node: Node) => update(s => ({ ...s, root: node, dirty: true })),
|
||||
updateNode: (id: string, updates: Partial<Node>) => { /* ... */ },
|
||||
insertNode: (parent: string, node: Node, index?: number) => { /* ... */ },
|
||||
removeNode: (id: string) => { /* ... */ },
|
||||
moveNode: (id: string, newParent: string, newIndex: number) => { /* ... */ }
|
||||
}
|
||||
}
|
||||
|
||||
// src/lib/stores/selection.ts
|
||||
interface SelectionState {
|
||||
selectedId: string | null
|
||||
hoveredId: string | null
|
||||
}
|
||||
|
||||
// src/lib/stores/history.ts
|
||||
interface HistoryState {
|
||||
past: DocumentState[]
|
||||
future: DocumentState[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase-by-Phase Implementation Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal**: Core document model and basic PDF export
|
||||
|
||||
#### Deliverables:
|
||||
- [ ] **Document Model Types** (`src/lib/core/model/types.ts`)
|
||||
- Define all Node interfaces
|
||||
- Style type definitions
|
||||
- Type guards for node types
|
||||
|
||||
- [ ] **Node Utilities** (`src/lib/core/model/node.ts`)
|
||||
- `createNode()` factory function
|
||||
- `cloneNode()` deep clone
|
||||
- `traverseTree()` iterator
|
||||
- `findNodeById()` lookup
|
||||
|
||||
- [ ] **Basic Layout Engine** (`src/lib/core/layout/`)
|
||||
- Constraint resolution (%, px, auto)
|
||||
- Row layout algorithm
|
||||
- Column layout algorithm
|
||||
- Text measurement
|
||||
|
||||
- [ ] **jsPDF Renderer** (`src/lib/renderer/jspdf/`)
|
||||
- Implement Renderer interface
|
||||
- Text rendering
|
||||
- Rectangle rendering
|
||||
- Image rendering
|
||||
- PDF output generation
|
||||
|
||||
- [ ] **Component Registry** (`src/lib/components/registry.ts`)
|
||||
- Register primitive components
|
||||
- Component metadata (icon, label, defaults)
|
||||
|
||||
- [ ] **Basic Test Document**
|
||||
- Create sample document tree
|
||||
- Generate PDF output
|
||||
|
||||
#### Success Criteria:
|
||||
- Can define a document as JSON
|
||||
- Can render Text, Row, Column to PDF
|
||||
- PDF output is correctly positioned
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Visual Editor Core
|
||||
**Goal**: Basic visual editing capabilities
|
||||
|
||||
#### Deliverables:
|
||||
- [ ] **Document Store** (`src/lib/stores/document.ts`)
|
||||
- State management for document tree
|
||||
- CRUD operations for nodes
|
||||
|
||||
- [ ] **Selection Store** (`src/lib/stores/selection.ts`)
|
||||
- Track selected/hovered nodes
|
||||
|
||||
- [ ] **Canvas Component** (`src/lib/editor/canvas/`)
|
||||
- Page.svelte - Single page representation
|
||||
- Canvas.svelte - Document container
|
||||
- NodeRenderer.svelte - Render nodes to HTML preview
|
||||
|
||||
- [ ] **Block Toolbar** (`src/lib/editor/toolbar/`)
|
||||
- Toolbar.svelte - Component toolbar
|
||||
- BlockButton.svelte - Insert button for each block type
|
||||
- LayoutPicker.svelte - Predefined layout templates
|
||||
|
||||
- [ ] **Properties Panel** (`src/lib/editor/panels/`)
|
||||
- PropertiesPanel.svelte - Edit selected node props
|
||||
- Basic text/spacing inputs
|
||||
|
||||
- [ ] **Canvas Renderer** (`src/lib/renderer/canvas/`)
|
||||
- HTML-based preview renderer
|
||||
- Visual selection indicators
|
||||
|
||||
#### Success Criteria:
|
||||
- Can see document in browser
|
||||
- Can insert new blocks
|
||||
- Can select and edit basic properties
|
||||
- Preview updates in real-time
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Advanced Editing
|
||||
**Goal**: Professional editing experience
|
||||
|
||||
#### Deliverables:
|
||||
- [ ] **Drag and Drop** (`src/lib/editor/interactions/dragdrop.ts`)
|
||||
- Reorder nodes within container
|
||||
- Move nodes between containers
|
||||
- Visual drop indicators
|
||||
|
||||
- [ ] **History/Undo-Redo** (`src/lib/stores/history.ts`)
|
||||
- Command pattern implementation
|
||||
- Undo/redo stack
|
||||
- Keyboard shortcuts (Ctrl+Z, Ctrl+Y)
|
||||
|
||||
- [ ] **Tree Panel** (`src/lib/editor/panels/TreePanel.svelte`)
|
||||
- Hierarchical document view
|
||||
- Expand/collapse containers
|
||||
- Click to select
|
||||
|
||||
- [ ] **Styles Panel** (`src/lib/editor/panels/StylesPanel.svelte`)
|
||||
- Margin/padding controls
|
||||
- Typography controls
|
||||
- Color pickers
|
||||
|
||||
- [ ] **Keyboard Shortcuts**
|
||||
- Delete selected (Delete/Backspace)
|
||||
- Copy/paste nodes (Ctrl+C, Ctrl+V)
|
||||
- Duplicate (Ctrl+D)
|
||||
|
||||
- [ ] **Context Menu**
|
||||
- Right-click on selected node
|
||||
- Cut, copy, paste, delete, duplicate
|
||||
|
||||
#### Success Criteria:
|
||||
- Drag and drop works smoothly
|
||||
- Undo/redo functions correctly
|
||||
- All style properties editable
|
||||
- Keyboard shortcuts work
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Pagination & Complex Components
|
||||
**Goal**: Production-ready document generation
|
||||
|
||||
#### Deliverables:
|
||||
- [ ] **Pagination Engine** (`src/lib/core/pagination/`)
|
||||
- Page break calculation
|
||||
- Content fragmentation
|
||||
- Text splitting across pages
|
||||
- Table row pagination
|
||||
|
||||
- [ ] **Table Component** (`src/lib/components/complex/table.ts`)
|
||||
- Define table structure
|
||||
- Header row repeat
|
||||
- Cell layout
|
||||
- Border handling
|
||||
|
||||
- [ ] **Box Component** (`src/lib/components/complex/box.ts`)
|
||||
- Container with visual styling
|
||||
- Border, background, padding
|
||||
|
||||
- [ ] **Multi-page Preview**
|
||||
- Show all pages in canvas
|
||||
- Page navigation
|
||||
- Page thumbnails
|
||||
|
||||
- [ ] **Page Settings**
|
||||
- Size selection (A4, A5, Letter, etc.)
|
||||
- Margin configuration
|
||||
- Orientation (portrait/landscape)
|
||||
|
||||
- [ ] **Header/Footer**
|
||||
- Repeat on each page
|
||||
- Page numbers
|
||||
- Custom content
|
||||
|
||||
#### Success Criteria:
|
||||
- Long documents paginate correctly
|
||||
- Tables split across pages properly
|
||||
- Headers/footers appear on all pages
|
||||
- PDF output matches preview
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Extensibility & AI
|
||||
**Goal**: Platform capabilities
|
||||
|
||||
#### Deliverables:
|
||||
- [ ] **Plugin System**
|
||||
- Custom component registration
|
||||
- Custom renderer registration
|
||||
- Plugin lifecycle hooks
|
||||
|
||||
- [ ] **AI Integration Hooks**
|
||||
- Document generation from prompt
|
||||
- Smart layout suggestions
|
||||
- Content transformation
|
||||
|
||||
- [ ] **Template System**
|
||||
- Save/load document templates
|
||||
- Template variables
|
||||
- Data binding
|
||||
|
||||
- [ ] **Export Options**
|
||||
- PDF quality settings
|
||||
- PDF metadata (title, author)
|
||||
- Multiple renderer support
|
||||
|
||||
- [ ] **Import/Export**
|
||||
- JSON document export
|
||||
- JSON document import
|
||||
- Document validation
|
||||
|
||||
#### Success Criteria:
|
||||
- Plugins can extend functionality
|
||||
- AI can generate document structures
|
||||
- Templates work with dynamic data
|
||||
- Multiple export formats available
|
||||
|
||||
---
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### ✅ Recommended Patterns
|
||||
|
||||
| Aspect | Decision | Rationale |
|
||||
|--------|----------|-----------|
|
||||
| State Management | Svelte Stores | Built-in, reactive, simple |
|
||||
| Layout Model | Flex-like | Familiar, powerful, proven |
|
||||
| Document Format | JSON | Serializable, AI-compatible |
|
||||
| PDF Library | jsPDF | Mature, works in browser |
|
||||
| Component Model | Registry pattern | Extensible, decoupled |
|
||||
| History | Command pattern | Clean undo/redo |
|
||||
|
||||
### ❌ Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Why Avoid |
|
||||
|--------------|-----------|
|
||||
| Absolute positioning | Breaks responsiveness, pagination |
|
||||
| Coupling engine to jsPDF | Limits future renderer options |
|
||||
| Storing UI state in document | Mixes concerns |
|
||||
| Imperative rendering | Hard to maintain, debug |
|
||||
| Skipping TypeScript | Complex domain needs types |
|
||||
|
||||
---
|
||||
|
||||
## Component Registry Design
|
||||
|
||||
```typescript
|
||||
// src/lib/components/registry.ts
|
||||
|
||||
interface ComponentDefinition {
|
||||
type: string
|
||||
category: 'container' | 'primitive' | 'complex'
|
||||
label: string
|
||||
icon: string
|
||||
defaultProps?: Record<string, unknown>
|
||||
defaultStyle?: NodeStyle
|
||||
allowsChildren?: boolean
|
||||
canFragment?: boolean
|
||||
}
|
||||
|
||||
const componentRegistry = new Map<string, ComponentDefinition>()
|
||||
|
||||
function registerComponent(definition: ComponentDefinition) {
|
||||
componentRegistry.set(definition.type, definition)
|
||||
}
|
||||
|
||||
function getComponent(type: string): ComponentDefinition | undefined {
|
||||
return componentRegistry.get(type)
|
||||
}
|
||||
|
||||
function getComponentsByCategory(category: string): ComponentDefinition[] {
|
||||
return Array.from(componentRegistry.values())
|
||||
.filter(c => c.category === category)
|
||||
}
|
||||
|
||||
// Register built-in components
|
||||
registerComponent({
|
||||
type: 'text',
|
||||
category: 'primitive',
|
||||
label: 'Text',
|
||||
icon: 'type',
|
||||
defaultProps: { value: 'New text' },
|
||||
canFragment: true
|
||||
})
|
||||
|
||||
registerComponent({
|
||||
type: 'row',
|
||||
category: 'container',
|
||||
label: 'Row',
|
||||
icon: 'columns',
|
||||
allowsChildren: true,
|
||||
defaultStyle: { direction: 'row', gap: 8 }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout Templates
|
||||
|
||||
```typescript
|
||||
// Predefined layout structures for quick insertion
|
||||
|
||||
const layoutTemplates = [
|
||||
{
|
||||
name: 'Two Columns',
|
||||
icon: 'layout-two-columns',
|
||||
node: {
|
||||
type: 'row',
|
||||
style: { gap: 16 },
|
||||
children: [
|
||||
{ type: 'column', children: [] },
|
||||
{ type: 'column', children: [] }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Header + Content',
|
||||
icon: 'layout-header',
|
||||
node: {
|
||||
type: 'column',
|
||||
children: [
|
||||
{
|
||||
type: 'box',
|
||||
style: { padding: 16, backgroundColor: '#f0f0f0' },
|
||||
children: [{ type: 'text', props: { value: 'Header' } }]
|
||||
},
|
||||
{ type: 'column', children: [] }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Invoice Layout',
|
||||
icon: 'file-text',
|
||||
node: {
|
||||
type: 'column',
|
||||
style: { gap: 24 },
|
||||
children: [
|
||||
{ type: 'text', props: { value: 'INVOICE' }, style: { fontSize: 24, fontWeight: 'bold' } },
|
||||
{
|
||||
type: 'row',
|
||||
style: { justify: 'between' },
|
||||
children: [
|
||||
{ type: 'column', children: [{ type: 'text', props: { value: 'From:' } }] },
|
||||
{ type: 'column', children: [{ type: 'text', props: { value: 'To:' } }] }
|
||||
]
|
||||
},
|
||||
{ type: 'table', props: { columns: [], data: [] } },
|
||||
{
|
||||
type: 'row',
|
||||
style: { justify: 'end' },
|
||||
children: [{ type: 'text', props: { value: 'Total: $0.00' } }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Document model operations
|
||||
- Layout calculations
|
||||
- Pagination logic
|
||||
- Node fragmentation
|
||||
|
||||
### Integration Tests
|
||||
- Full render pipeline
|
||||
- Editor interactions
|
||||
- Store updates
|
||||
|
||||
### Visual Regression Tests
|
||||
- PDF output comparison
|
||||
- Canvas preview comparison
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Lazy Layout Calculation**
|
||||
- Only calculate layout for visible pages
|
||||
- Cache layout results
|
||||
|
||||
2. **Efficient Updates**
|
||||
- Incremental layout recalculation
|
||||
- Debounced property updates
|
||||
|
||||
3. **Large Documents**
|
||||
- Virtual scrolling in tree view
|
||||
- Chunked pagination
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review this plan** - Confirm architecture decisions
|
||||
2. **Prioritize Phase 1** - Begin with document model and basic rendering
|
||||
3. **Set up testing** - Establish test patterns early
|
||||
4. **Create component registry** - Foundation for extensibility
|
||||
|
||||
---
|
||||
|
||||
## Questions for Clarification
|
||||
|
||||
1. Should the editor support collaborative editing in the future?
|
||||
2. Are there specific PDF features required (forms, annotations, digital signatures)?
|
||||
3. What level of styling granularity is needed for text (inline styles vs blocks)?
|
||||
4. Should templates support conditional content and loops for data binding?
|
||||
@ -0,0 +1,361 @@
|
||||
# Divi-Like Visual Builder Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Transform the current PDF editor into a Divi-inspired visual builder with intuitive inline controls, drag-and-drop functionality, and seamless element manipulation.
|
||||
|
||||
## Current State vs Target State
|
||||
|
||||
### Current State
|
||||
- Toolbar-based insertion from sidebar
|
||||
- Basic selection with outline highlighting
|
||||
- No inline insertion controls
|
||||
- No drag-and-drop
|
||||
- No hover action menus
|
||||
|
||||
### Target State (Divi-like)
|
||||
- Inline '+' buttons between elements for contextual insertion
|
||||
- Hover action menus with settings, duplicate, delete, move
|
||||
- Full drag-and-drop support with visual feedback
|
||||
- Ghost previews and drop zone indicators
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Inline Insertion System
|
||||
|
||||
### Description
|
||||
Add '+' buttons that appear between elements and at the end of containers, allowing users to insert new elements at specific positions.
|
||||
|
||||
### Visual Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Section │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Row │ │
|
||||
│ │ ┌───────────┐ ┌───────────┐ │ │
|
||||
│ │ │ Column │ │ Column │ │ │
|
||||
│ │ │ [Text] │ │ [Image] │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ [+] │ │ [+] │ │ │
|
||||
│ │ └───────────┘ └───────────┘ │ │
|
||||
│ │ [+] │ │ ← Add column button
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ [+] │ ← Add row button
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Components Needed
|
||||
|
||||
#### InsertButton.svelte
|
||||
```typescript
|
||||
interface Props {
|
||||
position: 'before' | 'after' | 'inside'
|
||||
parentId: string
|
||||
index: number
|
||||
allowedTypes: NodeType[]
|
||||
oninsert: (type: NodeType, parentId: string, index: number) => void
|
||||
}
|
||||
```
|
||||
|
||||
#### InsertionPopup.svelte
|
||||
- Appears when clicking '+'
|
||||
- Shows allowed element types based on context
|
||||
- Respects hierarchy rules
|
||||
|
||||
### Behavior
|
||||
1. Insert buttons appear on hover over containers
|
||||
2. Clicking '+' shows a popup with allowed types
|
||||
3. Selecting a type inserts at that exact position
|
||||
4. Buttons are context-aware (only show valid options)
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Hover Action Menu
|
||||
|
||||
### Description
|
||||
Display a floating toolbar when hovering over elements with quick actions.
|
||||
|
||||
### Visual Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [⚙️] [📋] [🗑️] [⋮] ← Action menu │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Element Content │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Actions
|
||||
| Icon | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| ⚙️ | Settings | Open properties panel for this element |
|
||||
| 📋 | Duplicate | Clone the element |
|
||||
| 🗑️ | Delete | Remove the element |
|
||||
| ⋮ | More | Expand for additional options (move up/down, copy, paste) |
|
||||
|
||||
### Components Needed
|
||||
|
||||
#### ElementActions.svelte
|
||||
```typescript
|
||||
interface Props {
|
||||
nodeId: string
|
||||
nodeType: NodeType
|
||||
position: { x: number; y: number }
|
||||
onsettings: () => void
|
||||
onduplicate: () => void
|
||||
ondelete: () => void
|
||||
onmoveup: () => void
|
||||
onmovedown: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
1. Menu appears on element hover (with slight delay)
|
||||
2. Menu positioned at top-right of element
|
||||
3. Actions are context-aware (some may be disabled)
|
||||
4. Menu disappears when mouse leaves element
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Drag and Drop System
|
||||
|
||||
### Description
|
||||
Allow elements to be reordered by dragging them to new positions.
|
||||
|
||||
### Visual Design
|
||||
|
||||
```
|
||||
Before Drop:
|
||||
┌─────────────┐
|
||||
│ Element A │ ← Being dragged (ghost)
|
||||
└─────────────┘
|
||||
┌─────────────┐
|
||||
│ Element B │
|
||||
└─────────────┘
|
||||
┌─────────────┐
|
||||
│ Element C │
|
||||
└─────────────┘
|
||||
↓
|
||||
Drop zone indicator (blue line)
|
||||
|
||||
After Drop:
|
||||
┌─────────────┐
|
||||
│ Element B │
|
||||
└─────────────┘
|
||||
┌─────────────┐
|
||||
│ Element A │ ← Dropped here
|
||||
└─────────────┘
|
||||
┌─────────────┐
|
||||
│ Element C │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### Components Needed
|
||||
|
||||
#### Draggable.svelte
|
||||
```typescript
|
||||
interface Props {
|
||||
nodeId: string
|
||||
disabled?: boolean
|
||||
ondragstart: (nodeId: string, event: DragEvent) => void
|
||||
ondragend: (nodeId: string, event: DragEvent) => void
|
||||
}
|
||||
```
|
||||
|
||||
#### DropZone.svelte
|
||||
```typescript
|
||||
interface Props {
|
||||
parentId: string
|
||||
index: number
|
||||
acceptedTypes: NodeType[]
|
||||
ondrop: (draggedId: string, parentId: string, index: number) => void
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
1. Click and hold to start drag (with drag handle icon)
|
||||
2. Element becomes semi-transparent ghost
|
||||
3. Drop zones appear as blue indicator lines
|
||||
4. Invalid drop zones shown in red
|
||||
5. Release to drop at indicated position
|
||||
6. Escape cancels the drag operation
|
||||
|
||||
---
|
||||
|
||||
## Feature 4: Visual Feedback System
|
||||
|
||||
### Description
|
||||
Provide clear visual feedback during all interactions.
|
||||
|
||||
### Feedback Types
|
||||
|
||||
#### Hover State
|
||||
- Outline brightens
|
||||
- Action menu appears
|
||||
- Cursor changes to pointer
|
||||
|
||||
#### Selection State
|
||||
- Solid border (current)
|
||||
- Resize handles (future)
|
||||
|
||||
#### Drag State
|
||||
- Original element becomes ghost (50% opacity)
|
||||
- Drag preview follows cursor
|
||||
- Drop zones highlighted
|
||||
|
||||
#### Drop Zone State
|
||||
- Blue line indicator for valid drops
|
||||
- Red indicator for invalid drops
|
||||
- Pulsing animation for active zone
|
||||
|
||||
#### Insert Button State
|
||||
- Fade in on container hover
|
||||
- Scale up on button hover
|
||||
- Tooltip showing 'Add [element type]'
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### New File Structure
|
||||
|
||||
```
|
||||
src/lib/editor/
|
||||
├── canvas/
|
||||
│ ├── Canvas.svelte
|
||||
│ ├── NodeRenderer.svelte
|
||||
│ ├── Page.svelte
|
||||
│ ├── InsertButton.svelte # NEW
|
||||
│ ├── InsertionPopup.svelte # NEW
|
||||
│ ├── ElementActions.svelte # NEW
|
||||
│ ├── DraggableWrapper.svelte # NEW
|
||||
│ └── DropZone.svelte # NEW
|
||||
├── toolbar/
|
||||
│ ├── Toolbar.svelte
|
||||
│ ├── BlockButton.svelte
|
||||
│ └── ElementQuickMenu.svelte # NEW
|
||||
├── stores/
|
||||
│ ├── drag.ts # NEW - drag state store
|
||||
│ └── ...
|
||||
└── Editor.svelte
|
||||
```
|
||||
|
||||
### Store Additions
|
||||
|
||||
#### dragStore (new)
|
||||
```typescript
|
||||
interface DragState {
|
||||
isDragging: boolean
|
||||
draggedNodeId: string | null
|
||||
draggedNodeType: NodeType | null
|
||||
dropTargetId: string | null
|
||||
dropIndex: number | null
|
||||
isValidDrop: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### Event Flow Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant NodeRenderer
|
||||
participant ElementActions
|
||||
participant Editor
|
||||
participant DocumentStore
|
||||
|
||||
User->>NodeRenderer: Hover over element
|
||||
NodeRenderer->>ElementActions: Show action menu
|
||||
User->>ElementActions: Click drag handle
|
||||
ElementActions->>Editor: Start drag
|
||||
Editor->>DocumentStore: Set drag state
|
||||
User->>NodeRenderer: Drag over drop zone
|
||||
NodeRenderer->>Editor: Check valid drop
|
||||
Editor->>DocumentStore: Update drop target
|
||||
User->>NodeRenderer: Drop
|
||||
NodeRenderer->>Editor: Complete drop
|
||||
Editor->>DocumentStore: Move node
|
||||
DocumentStore->>NodeRenderer: Re-render
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Inline Insertion
|
||||
1. Create InsertButton.svelte component
|
||||
2. Create InsertionPopup.svelte with element selector
|
||||
3. Modify NodeRenderer to show insert buttons
|
||||
4. Wire up insertion logic with correct positioning
|
||||
|
||||
### Phase 2: Hover Actions
|
||||
1. Create ElementActions.svelte component
|
||||
2. Add hover detection with delay
|
||||
3. Implement duplicate, delete, settings actions
|
||||
4. Add move up/down functionality
|
||||
|
||||
### Phase 3: Drag and Drop
|
||||
1. Create drag store for state management
|
||||
2. Create DraggableWrapper.svelte
|
||||
3. Create DropZone.svelte
|
||||
4. Implement drop validation logic
|
||||
5. Add visual feedback during drag
|
||||
|
||||
### Phase 4: Polish
|
||||
1. Add animations and transitions
|
||||
2. Implement keyboard shortcuts
|
||||
3. Add undo/redo support for all operations
|
||||
4. Mobile touch support (future)
|
||||
|
||||
---
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Performance
|
||||
- Use CSS transforms for drag preview (GPU accelerated)
|
||||
- Debounce hover events
|
||||
- Virtualize insertion popups (only one open at a time)
|
||||
|
||||
### Accessibility
|
||||
- Keyboard navigation for action menus
|
||||
- Focus management during drag
|
||||
- ARIA labels for all interactive elements
|
||||
|
||||
### Browser Compatibility
|
||||
- Use HTML5 Drag and Drop API
|
||||
- Fallback for touch devices
|
||||
- Handle Safari quirks
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Existing Files
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/lib/editor/Editor.svelte` | Add drag state, coordinate drop logic |
|
||||
| `src/lib/editor/canvas/NodeRenderer.svelte` | Add action menu, insert buttons, drag handlers |
|
||||
| `src/lib/stores/index.ts` | Export new drag store |
|
||||
|
||||
### New Files to Create
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/editor/canvas/InsertButton.svelte` | Inline insertion trigger |
|
||||
| `src/lib/editor/canvas/InsertionPopup.svelte` | Element type selector |
|
||||
| `src/lib/editor/canvas/ElementActions.svelte` | Hover action toolbar |
|
||||
| `src/lib/editor/canvas/DropZone.svelte` | Drop target indicator |
|
||||
| `src/lib/stores/drag.ts` | Drag state management |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Users can insert elements at specific positions using inline buttons
|
||||
2. ✅ Hover menus provide quick access to common actions
|
||||
3. ✅ Drag and drop works smoothly with visual feedback
|
||||
4. ✅ All operations respect hierarchy constraints
|
||||
5. ✅ All operations are undoable
|
||||
6. ✅ Keyboard users can perform all actions
|
||||
@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Components Module
|
||||
*
|
||||
* Export component registry and utilities.
|
||||
*/
|
||||
|
||||
export type { ComponentCategory, ComponentDefinition } from './registry'
|
||||
export {
|
||||
componentRegistry,
|
||||
registerBuiltinComponents,
|
||||
getComponentLabel,
|
||||
getComponentIcon,
|
||||
componentAllowsChildren,
|
||||
componentCanFragment,
|
||||
} from './registry'
|
||||
@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Component Registry
|
||||
*
|
||||
* Central registry for document components. Allows registration and lookup
|
||||
* of component definitions, metadata, and default values.
|
||||
*/
|
||||
|
||||
import type { Node, NodeStyle, NodeType } from '../core/model/types'
|
||||
|
||||
// ============================================================================
|
||||
// Component Definition Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Component category
|
||||
*/
|
||||
export type ComponentCategory = 'container' | 'primitive' | 'complex'
|
||||
|
||||
/**
|
||||
* Component definition
|
||||
*/
|
||||
export interface ComponentDefinition {
|
||||
/** Component type identifier */
|
||||
type: NodeType
|
||||
|
||||
/** Category for grouping in UI */
|
||||
category: ComponentCategory
|
||||
|
||||
/** Human-readable label */
|
||||
label: string
|
||||
|
||||
/** Icon identifier for UI */
|
||||
icon: string
|
||||
|
||||
/** Default props for new instances */
|
||||
defaultProps?: Record<string, unknown>
|
||||
|
||||
/** Default style for new instances */
|
||||
defaultStyle?: NodeStyle
|
||||
|
||||
/** Whether this component can have children */
|
||||
allowsChildren: boolean
|
||||
|
||||
/** Whether this component can be fragmented across pages */
|
||||
canFragment: boolean
|
||||
|
||||
/** Description for tooltips/help */
|
||||
description?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Registry Class
|
||||
// ============================================================================
|
||||
|
||||
class ComponentRegistry {
|
||||
private components: Map<NodeType, ComponentDefinition> = new Map()
|
||||
|
||||
/**
|
||||
* Register a component definition
|
||||
*/
|
||||
register(definition: ComponentDefinition): void {
|
||||
if (this.components.has(definition.type)) {
|
||||
console.warn(`Component "${definition.type}" is already registered. Overwriting.`)
|
||||
}
|
||||
this.components.set(definition.type, definition)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a component definition by type
|
||||
*/
|
||||
get(type: NodeType): ComponentDefinition | undefined {
|
||||
return this.components.get(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a component type is registered
|
||||
*/
|
||||
has(type: NodeType): boolean {
|
||||
return this.components.has(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered components
|
||||
*/
|
||||
getAll(): ComponentDefinition[] {
|
||||
return Array.from(this.components.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get components by category
|
||||
*/
|
||||
getByCategory(category: ComponentCategory): ComponentDefinition[] {
|
||||
return this.getAll().filter(c => c.category === category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all container components
|
||||
*/
|
||||
getContainers(): ComponentDefinition[] {
|
||||
return this.getByCategory('container')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all primitive components
|
||||
*/
|
||||
getPrimitives(): ComponentDefinition[] {
|
||||
return this.getByCategory('primitive')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all complex components
|
||||
*/
|
||||
getComplex(): ComponentDefinition[] {
|
||||
return this.getByCategory('complex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered components
|
||||
*/
|
||||
clear(): void {
|
||||
this.components.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Singleton Instance
|
||||
// ============================================================================
|
||||
|
||||
export const componentRegistry = new ComponentRegistry()
|
||||
|
||||
// ============================================================================
|
||||
// Built-in Component Definitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Register all built-in components
|
||||
*/
|
||||
export function registerBuiltinComponents(): void {
|
||||
// Containers
|
||||
componentRegistry.register({
|
||||
type: 'section',
|
||||
category: 'container',
|
||||
label: 'Section',
|
||||
icon: 'view-agenda',
|
||||
defaultStyle: {
|
||||
direction: 'column',
|
||||
gap: 0,
|
||||
align: 'stretch',
|
||||
justify: 'start',
|
||||
},
|
||||
allowsChildren: true,
|
||||
canFragment: true,
|
||||
description: 'Document section that contains rows',
|
||||
})
|
||||
|
||||
componentRegistry.register({
|
||||
type: 'row',
|
||||
category: 'container',
|
||||
label: 'Row',
|
||||
icon: 'view-column',
|
||||
defaultStyle: {
|
||||
direction: 'row',
|
||||
gap: 8,
|
||||
align: 'stretch',
|
||||
justify: 'start',
|
||||
},
|
||||
allowsChildren: true,
|
||||
canFragment: true,
|
||||
description: 'Horizontal container that arranges children in a row',
|
||||
})
|
||||
|
||||
componentRegistry.register({
|
||||
type: 'column',
|
||||
category: 'container',
|
||||
label: 'Column',
|
||||
icon: 'view-stream',
|
||||
defaultStyle: {
|
||||
direction: 'column',
|
||||
gap: 8,
|
||||
align: 'stretch',
|
||||
justify: 'start',
|
||||
width: '100%',
|
||||
},
|
||||
allowsChildren: true,
|
||||
canFragment: true,
|
||||
description: 'Vertical container that arranges children in a column',
|
||||
})
|
||||
|
||||
componentRegistry.register({
|
||||
type: 'stack',
|
||||
category: 'container',
|
||||
label: 'Stack',
|
||||
icon: 'layers',
|
||||
allowsChildren: true,
|
||||
canFragment: false,
|
||||
description: 'Container that overlays children on top of each other',
|
||||
})
|
||||
|
||||
// Primitives
|
||||
componentRegistry.register({
|
||||
type: 'text',
|
||||
category: 'primitive',
|
||||
label: 'Text',
|
||||
icon: 'text-fields',
|
||||
defaultProps: {
|
||||
value: 'New text',
|
||||
},
|
||||
defaultStyle: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'Helvetica',
|
||||
color: '#000000',
|
||||
},
|
||||
allowsChildren: false,
|
||||
canFragment: true,
|
||||
description: 'Text content block',
|
||||
})
|
||||
|
||||
componentRegistry.register({
|
||||
type: 'image',
|
||||
category: 'primitive',
|
||||
label: 'Image',
|
||||
icon: 'image',
|
||||
defaultProps: {
|
||||
src: '',
|
||||
alt: '',
|
||||
fit: 'contain',
|
||||
},
|
||||
allowsChildren: false,
|
||||
canFragment: false,
|
||||
description: 'Image from URL or base64 data',
|
||||
})
|
||||
|
||||
componentRegistry.register({
|
||||
type: 'spacer',
|
||||
category: 'primitive',
|
||||
label: 'Spacer',
|
||||
icon: 'space-bar',
|
||||
allowsChildren: false,
|
||||
canFragment: true,
|
||||
description: 'Flexible or fixed spacing element',
|
||||
})
|
||||
|
||||
componentRegistry.register({
|
||||
type: 'divider',
|
||||
category: 'primitive',
|
||||
label: 'Divider',
|
||||
icon: 'horizontal-rule',
|
||||
defaultProps: {
|
||||
thickness: 1,
|
||||
style: 'solid',
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
defaultStyle: {
|
||||
borderColor: '#000000',
|
||||
margin: [8, 0],
|
||||
},
|
||||
allowsChildren: false,
|
||||
canFragment: false,
|
||||
description: 'Horizontal or vertical dividing line',
|
||||
})
|
||||
|
||||
// Complex
|
||||
componentRegistry.register({
|
||||
type: 'box',
|
||||
category: 'complex',
|
||||
label: 'Box',
|
||||
icon: 'square',
|
||||
defaultStyle: {
|
||||
padding: 8,
|
||||
borderWidth: 0,
|
||||
borderColor: '#000000',
|
||||
},
|
||||
allowsChildren: true,
|
||||
canFragment: true,
|
||||
description: 'Container with optional border, background, and padding',
|
||||
})
|
||||
|
||||
componentRegistry.register({
|
||||
type: 'table',
|
||||
category: 'complex',
|
||||
label: 'Table',
|
||||
icon: 'table',
|
||||
defaultProps: {
|
||||
columns: [],
|
||||
data: [],
|
||||
repeatHeader: true,
|
||||
},
|
||||
defaultStyle: {
|
||||
fontSize: 10,
|
||||
},
|
||||
allowsChildren: false,
|
||||
canFragment: true,
|
||||
description: 'Tabular data display with columns and rows',
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-register built-in components
|
||||
registerBuiltinComponents()
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the label for a component type
|
||||
*/
|
||||
export function getComponentLabel(type: NodeType): string {
|
||||
return componentRegistry.get(type)?.label ?? type
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon for a component type
|
||||
*/
|
||||
export function getComponentIcon(type: NodeType): string {
|
||||
return componentRegistry.get(type)?.icon ?? 'widget'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a component type allows children
|
||||
*/
|
||||
export function componentAllowsChildren(type: NodeType): boolean {
|
||||
return componentRegistry.get(type)?.allowsChildren ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a component type can be fragmented
|
||||
*/
|
||||
export function componentCanFragment(type: NodeType): boolean {
|
||||
return componentRegistry.get(type)?.canFragment ?? false
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Core Module
|
||||
*
|
||||
* Export all core functionality: document model, layout engine, and utilities.
|
||||
*/
|
||||
|
||||
// Document Model
|
||||
export * from './model'
|
||||
|
||||
// Layout Engine
|
||||
export * from './layout'
|
||||
@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Layout Engine
|
||||
*
|
||||
* Calculates positions and sizes for all nodes in the document tree.
|
||||
* Uses a flex-like layout model inspired by CSS Flexbox.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Node,
|
||||
NodeStyle,
|
||||
LayoutBox,
|
||||
LayoutContext,
|
||||
NormalizedSpacing,
|
||||
Dimension,
|
||||
ContainerNode,
|
||||
TextNode,
|
||||
ImageNode,
|
||||
SpacerNode,
|
||||
DividerNode,
|
||||
} from '../model/types'
|
||||
|
||||
import {
|
||||
hasChildren,
|
||||
isContainerNode,
|
||||
isTextNode,
|
||||
isImageNode,
|
||||
isSpacerNode,
|
||||
isDividerNode,
|
||||
isSectionNode,
|
||||
isRowNode,
|
||||
isColumnNode,
|
||||
isStackNode,
|
||||
} from '../model/types'
|
||||
|
||||
import { normalizeSpacing, resolveDimension, isAutoDimension, getPageDimensions } from '../model/node'
|
||||
|
||||
// ============================================================================
|
||||
// Layout Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Constraints for layout calculation
|
||||
*/
|
||||
export interface LayoutConstraints {
|
||||
minWidth: number
|
||||
maxWidth: number
|
||||
minHeight: number
|
||||
maxHeight: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of measuring a node's intrinsic size
|
||||
*/
|
||||
export interface IntrinsicSize {
|
||||
width: number | 'auto'
|
||||
height: number | 'auto'
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout configuration options
|
||||
*/
|
||||
export interface LayoutOptions {
|
||||
/** Scale factor for text measurement */
|
||||
textScale?: number
|
||||
/** Default font size */
|
||||
defaultFontSize?: number
|
||||
/** Default line height */
|
||||
defaultLineHeight?: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_FONT_SIZE = 12
|
||||
const DEFAULT_LINE_HEIGHT = 1.2
|
||||
const DEFAULT_GAP = 0
|
||||
const DEFAULT_PADDING: NormalizedSpacing = { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
const DEFAULT_MARGIN: NormalizedSpacing = { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
|
||||
// ============================================================================
|
||||
// Layout Engine Class
|
||||
// ============================================================================
|
||||
|
||||
export class LayoutEngine {
|
||||
private options: Required<LayoutOptions>
|
||||
|
||||
constructor(options: LayoutOptions = {}) {
|
||||
this.options = {
|
||||
textScale: options.textScale ?? 1,
|
||||
defaultFontSize: options.defaultFontSize ?? DEFAULT_FONT_SIZE,
|
||||
defaultLineHeight: options.defaultLineHeight ?? DEFAULT_LINE_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate layout for a document tree
|
||||
*/
|
||||
layout(root: Node, pageWidth: number, pageHeight: number): LayoutBox {
|
||||
const context: LayoutContext = {
|
||||
availableWidth: pageWidth,
|
||||
availableHeight: pageHeight,
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
margins: DEFAULT_MARGIN,
|
||||
}
|
||||
|
||||
return this.layoutNode(root, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout a single node and its children
|
||||
*/
|
||||
private layoutNode(node: Node, context: LayoutContext): LayoutBox {
|
||||
const style = node.style ?? {}
|
||||
const margin = normalizeSpacing(style.margin, 0)
|
||||
const padding = normalizeSpacing(style.padding, 0)
|
||||
|
||||
// Calculate available space after margin
|
||||
const innerContext: LayoutContext = {
|
||||
...context,
|
||||
availableWidth: context.availableWidth - margin.left - margin.right,
|
||||
availableHeight: context.availableHeight - margin.top - margin.bottom,
|
||||
currentX: context.currentX + margin.left,
|
||||
currentY: context.currentY + margin.top,
|
||||
}
|
||||
|
||||
// Layout based on node type
|
||||
let box: LayoutBox
|
||||
|
||||
if (isContainerNode(node)) {
|
||||
box = this.layoutContainer(node, innerContext, padding)
|
||||
} else if (isTextNode(node)) {
|
||||
box = this.layoutText(node, innerContext)
|
||||
} else if (isImageNode(node)) {
|
||||
box = this.layoutImage(node, innerContext)
|
||||
} else if (isSpacerNode(node)) {
|
||||
box = this.layoutSpacer(node, innerContext)
|
||||
} else if (isDividerNode(node)) {
|
||||
box = this.layoutDivider(node, innerContext)
|
||||
} else {
|
||||
// Fallback for unknown node types
|
||||
box = {
|
||||
nodeId: node.id,
|
||||
x: innerContext.currentX,
|
||||
y: innerContext.currentY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Apply margin to the final box
|
||||
return {
|
||||
...box,
|
||||
x: box.x - margin.left,
|
||||
y: box.y - margin.top,
|
||||
width: box.width + margin.left + margin.right,
|
||||
height: box.height + margin.top + margin.bottom,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout a container node (row, column, stack)
|
||||
*/
|
||||
private layoutContainer(
|
||||
node: ContainerNode,
|
||||
context: LayoutContext,
|
||||
padding: NormalizedSpacing
|
||||
): LayoutBox {
|
||||
const style = node.style ?? {}
|
||||
const gap = style.gap ?? DEFAULT_GAP
|
||||
// Section nodes are always column layout, Row nodes are row layout, others check style or default to column
|
||||
const direction = style.direction ??
|
||||
(isSectionNode(node) ? 'column' : isRowNode(node) ? 'row' : 'column')
|
||||
const justify = style.justify ?? 'start'
|
||||
const align = style.align ?? 'start'
|
||||
|
||||
// Calculate content area (after padding)
|
||||
const contentX = context.currentX + padding.left
|
||||
const contentY = context.currentY + padding.top
|
||||
const contentWidth = context.availableWidth - padding.left - padding.right
|
||||
const contentHeight = context.availableHeight - padding.top - padding.bottom
|
||||
|
||||
// Resolve container dimensions
|
||||
const containerWidth = this.resolveDimension(style.width, contentWidth, contentWidth) as number
|
||||
const containerHeight = this.resolveDimension(style.height, contentHeight, contentHeight) as number
|
||||
|
||||
// Layout children
|
||||
const children = node.children ?? []
|
||||
const childBoxes: LayoutBox[] = []
|
||||
|
||||
if (isStackNode(node)) {
|
||||
// Stack: all children at the same position
|
||||
for (const child of children) {
|
||||
const childContext: LayoutContext = {
|
||||
...context,
|
||||
availableWidth: contentWidth,
|
||||
availableHeight: contentHeight,
|
||||
currentX: contentX,
|
||||
currentY: contentY,
|
||||
}
|
||||
childBoxes.push(this.layoutNode(child, childContext))
|
||||
}
|
||||
} else {
|
||||
// Row/Column: sequential layout
|
||||
let currentX = contentX
|
||||
let currentY = contentY
|
||||
const isRow = direction === 'row'
|
||||
|
||||
// First pass: measure all children
|
||||
const childSizes: { width: number; height: number }[] = []
|
||||
let totalMainSize = 0
|
||||
let maxCrossSize = 0
|
||||
|
||||
for (const child of children) {
|
||||
const childContext: LayoutContext = {
|
||||
...context,
|
||||
availableWidth: isRow ? contentWidth - (childSizes.length * gap) : contentWidth,
|
||||
availableHeight: isRow ? contentHeight : contentHeight - (childSizes.length * gap),
|
||||
currentX: 0, // Temporary, for measurement
|
||||
currentY: 0,
|
||||
}
|
||||
|
||||
const childBox = this.layoutNode(child, childContext)
|
||||
childSizes.push({ width: childBox.width, height: childBox.height })
|
||||
totalMainSize += isRow ? childBox.width : childBox.height
|
||||
maxCrossSize = Math.max(maxCrossSize, isRow ? childBox.height : childBox.width)
|
||||
}
|
||||
|
||||
// Add gaps to total size
|
||||
totalMainSize += (children.length - 1) * gap
|
||||
|
||||
// Calculate starting position based on justification
|
||||
const mainAxisSize = isRow ? containerWidth : containerHeight
|
||||
let extraSpace = mainAxisSize - totalMainSize
|
||||
|
||||
if (justify === 'center') {
|
||||
if (isRow) {
|
||||
currentX += extraSpace / 2
|
||||
} else {
|
||||
currentY += extraSpace / 2
|
||||
}
|
||||
} else if (justify === 'end') {
|
||||
if (isRow) {
|
||||
currentX += extraSpace
|
||||
} else {
|
||||
currentY += extraSpace
|
||||
}
|
||||
} else if (justify === 'between') {
|
||||
// Space between items
|
||||
const gapCount = Math.max(1, children.length - 1)
|
||||
extraSpace = children.length > 1 ? (mainAxisSize - totalMainSize + (children.length - 1) * gap) / gapCount : gap
|
||||
} else if (justify === 'around') {
|
||||
const gapCount = children.length + 1
|
||||
extraSpace = (mainAxisSize - totalMainSize) / gapCount
|
||||
if (isRow) {
|
||||
currentX += extraSpace
|
||||
} else {
|
||||
currentY += extraSpace
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: position children
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i]
|
||||
const childSize = childSizes[i]
|
||||
|
||||
// Calculate cross-axis position
|
||||
let childX = currentX
|
||||
let childY = currentY
|
||||
const crossSize = isRow ? containerHeight : containerWidth
|
||||
|
||||
if (align === 'center') {
|
||||
if (isRow) {
|
||||
childY = contentY + (crossSize - childSize.height) / 2
|
||||
} else {
|
||||
childX = contentX + (crossSize - childSize.width) / 2
|
||||
}
|
||||
} else if (align === 'end') {
|
||||
if (isRow) {
|
||||
childY = contentY + crossSize - childSize.height
|
||||
} else {
|
||||
childX = contentX + crossSize - childSize.width
|
||||
}
|
||||
} else if (align === 'stretch') {
|
||||
// Stretch child to fill cross-axis
|
||||
// This would require modifying the child's size
|
||||
}
|
||||
|
||||
const childContext: LayoutContext = {
|
||||
...context,
|
||||
availableWidth: isRow ? childSize.width : contentWidth,
|
||||
availableHeight: isRow ? contentHeight : childSize.height,
|
||||
currentX: childX,
|
||||
currentY: childY,
|
||||
}
|
||||
|
||||
const childBox = this.layoutNode(child, childContext)
|
||||
childBoxes.push(childBox)
|
||||
|
||||
// Move to next position
|
||||
if (isRow) {
|
||||
currentX += childSize.width
|
||||
if (justify === 'between' && i < children.length - 1) {
|
||||
currentX += extraSpace
|
||||
} else if (justify !== 'between') {
|
||||
currentX += gap
|
||||
}
|
||||
} else {
|
||||
currentY += childSize.height
|
||||
if (justify === 'between' && i < children.length - 1) {
|
||||
currentY += extraSpace
|
||||
} else if (justify !== 'between') {
|
||||
currentY += gap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final container size
|
||||
let finalWidth: number = typeof containerWidth === 'number' ? containerWidth : 0
|
||||
let finalHeight: number = typeof containerHeight === 'number' ? containerHeight : 0
|
||||
|
||||
if (isAutoDimension(style.width)) {
|
||||
// Calculate width from children
|
||||
if (isRowNode(node)) {
|
||||
finalWidth = childBoxes.reduce((sum, box) => sum + box.width, 0) +
|
||||
(children.length - 1) * gap +
|
||||
padding.left + padding.right
|
||||
} else {
|
||||
finalWidth = Math.max(...childBoxes.map(box => box.width), 0) + padding.left + padding.right
|
||||
}
|
||||
}
|
||||
|
||||
if (isAutoDimension(style.height)) {
|
||||
// Calculate height from children
|
||||
if (isColumnNode(node)) {
|
||||
finalHeight = childBoxes.reduce((sum, box) => sum + box.height, 0) +
|
||||
(children.length - 1) * gap +
|
||||
padding.top + padding.bottom
|
||||
} else {
|
||||
finalHeight = Math.max(...childBoxes.map(box => box.height), 0) + padding.top + padding.bottom
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
x: context.currentX,
|
||||
y: context.currentY,
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
children: childBoxes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout a text node
|
||||
*/
|
||||
private layoutText(node: TextNode, context: LayoutContext): LayoutBox {
|
||||
const style = node.style ?? {}
|
||||
const text = node.props.value
|
||||
|
||||
const fontSize = style.fontSize ?? this.options.defaultFontSize
|
||||
const lineHeight = (style.lineHeight ?? this.options.defaultLineHeight) * fontSize
|
||||
const fontFamily = style.fontFamily ?? 'Helvetica'
|
||||
|
||||
// Estimate text width (rough approximation)
|
||||
// In a real implementation, this would use actual font metrics
|
||||
const avgCharWidth = fontSize * 0.5 // Approximate
|
||||
const textWidth = text.length * avgCharWidth
|
||||
|
||||
// Resolve dimensions
|
||||
const width = this.resolveDimension(style.width, context.availableWidth, textWidth) as number
|
||||
|
||||
// Calculate lines needed
|
||||
const lines = Math.ceil(textWidth / (typeof width === 'number' ? width : textWidth))
|
||||
const height = lines * lineHeight
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
x: context.currentX,
|
||||
y: context.currentY,
|
||||
width: typeof width === 'number' ? width : textWidth,
|
||||
height: this.resolveDimension(style.height, context.availableHeight, height) as number,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout an image node
|
||||
*/
|
||||
private layoutImage(node: ImageNode, context: LayoutContext): LayoutBox {
|
||||
const style = node.style ?? {}
|
||||
|
||||
// For now, use specified dimensions or defaults
|
||||
// In a real implementation, this would load the image and get actual dimensions
|
||||
const width = this.resolveDimension(style.width, context.availableWidth, 100)
|
||||
const height = this.resolveDimension(style.height, context.availableHeight, 100)
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
x: context.currentX,
|
||||
y: context.currentY,
|
||||
width: typeof width === 'number' ? width : 100,
|
||||
height: typeof height === 'number' ? height : 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout a spacer node
|
||||
*/
|
||||
private layoutSpacer(node: SpacerNode, context: LayoutContext): LayoutBox {
|
||||
const style = node.style ?? {}
|
||||
const size = node.props?.size
|
||||
|
||||
// Spacer takes remaining space or fixed size
|
||||
const width = size ?? this.resolveDimension(style.width, context.availableWidth, 0)
|
||||
const height = size ?? this.resolveDimension(style.height, context.availableHeight, 0)
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
x: context.currentX,
|
||||
y: context.currentY,
|
||||
width: typeof width === 'number' ? width : 0,
|
||||
height: typeof height === 'number' ? height : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout a divider node
|
||||
*/
|
||||
private layoutDivider(node: DividerNode, context: LayoutContext): LayoutBox {
|
||||
const style = node.style ?? {}
|
||||
const thickness = node.props?.thickness ?? 1
|
||||
const orientation = node.props?.orientation ?? 'horizontal'
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
return {
|
||||
nodeId: node.id,
|
||||
x: context.currentX,
|
||||
y: context.currentY,
|
||||
width: this.resolveDimension(style.width, context.availableWidth, context.availableWidth) as number,
|
||||
height: thickness,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
nodeId: node.id,
|
||||
x: context.currentX,
|
||||
y: context.currentY,
|
||||
width: thickness,
|
||||
height: this.resolveDimension(style.height, context.availableHeight, context.availableHeight) as number,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dimension value
|
||||
*/
|
||||
private resolveDimension(
|
||||
dimension: Dimension | undefined,
|
||||
reference: number,
|
||||
defaultValue: number
|
||||
): number | 'auto' {
|
||||
return resolveDimension(dimension, reference, defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a layout engine with default options
|
||||
*/
|
||||
export function createLayoutEngine(options?: LayoutOptions): LayoutEngine {
|
||||
return new LayoutEngine(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate layout for a document
|
||||
*/
|
||||
export function calculateLayout(
|
||||
root: Node,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
options?: LayoutOptions
|
||||
): LayoutBox {
|
||||
const engine = new LayoutEngine(options)
|
||||
return engine.layout(root, pageWidth, pageHeight)
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Layout Engine Module
|
||||
*
|
||||
* Export layout calculation utilities.
|
||||
*/
|
||||
|
||||
export type { LayoutConstraints, IntrinsicSize, LayoutOptions } from './engine'
|
||||
export { LayoutEngine, createLayoutEngine, calculateLayout } from './engine'
|
||||
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Core Document Model
|
||||
*
|
||||
* Export all types and utilities for the document model.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Dimension,
|
||||
Spacing,
|
||||
NormalizedSpacing,
|
||||
TextAlign,
|
||||
FlexDirection,
|
||||
JustifyContent,
|
||||
AlignItems,
|
||||
NodeStyle,
|
||||
NodeType,
|
||||
BaseNode,
|
||||
ContainerNode,
|
||||
PrimitiveNode,
|
||||
ComplexNode,
|
||||
Node,
|
||||
RowNode,
|
||||
ColumnNode,
|
||||
StackNode,
|
||||
TextNode,
|
||||
ImageNode,
|
||||
SpacerNode,
|
||||
DividerNode,
|
||||
TableNode,
|
||||
TableColumn,
|
||||
BoxNode,
|
||||
DocumentNode,
|
||||
PageSizePreset,
|
||||
PageOrientation,
|
||||
PageSettings,
|
||||
LayoutBox,
|
||||
LayoutContext,
|
||||
} from './types'
|
||||
|
||||
export {
|
||||
isContainerNode,
|
||||
isRowNode,
|
||||
isColumnNode,
|
||||
isStackNode,
|
||||
isTextNode,
|
||||
isImageNode,
|
||||
isSpacerNode,
|
||||
isDividerNode,
|
||||
isTableNode,
|
||||
isBoxNode,
|
||||
isDocumentNode,
|
||||
hasChildren,
|
||||
} from './types'
|
||||
|
||||
// Node utilities
|
||||
export {
|
||||
generateId,
|
||||
resetIdCounter,
|
||||
normalizeSpacing,
|
||||
resolveDimension,
|
||||
isAutoDimension,
|
||||
createDocument,
|
||||
createRow,
|
||||
createColumn,
|
||||
createStack,
|
||||
createText,
|
||||
createImage,
|
||||
createSpacer,
|
||||
createDivider,
|
||||
createTable,
|
||||
createBox,
|
||||
cloneNode,
|
||||
mergeStyle,
|
||||
updateProps,
|
||||
createStyledNode,
|
||||
PAGE_SIZES,
|
||||
getPageDimensions,
|
||||
getContentArea,
|
||||
} from './node'
|
||||
|
||||
// Tree utilities
|
||||
export {
|
||||
traverseTree,
|
||||
traverseBreadthFirst,
|
||||
findNodeById,
|
||||
findParent,
|
||||
findPath,
|
||||
findNodes,
|
||||
findNodesByType,
|
||||
getNodeIndex,
|
||||
insertNode,
|
||||
removeNode,
|
||||
moveNode,
|
||||
updateNode,
|
||||
updateNodeStyle,
|
||||
mapTree,
|
||||
filterTree,
|
||||
countNodes,
|
||||
getTreeDepth,
|
||||
isAncestor,
|
||||
getAncestors,
|
||||
getDescendants,
|
||||
validateTree,
|
||||
serializeTree,
|
||||
deserializeTree,
|
||||
deepCloneTree,
|
||||
} from './tree'
|
||||
|
||||
export type { ValidationResult } from './tree'
|
||||
@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Node Utilities
|
||||
*
|
||||
* Factory functions and utilities for creating and manipulating document nodes.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Node,
|
||||
BaseNode,
|
||||
DocumentNode,
|
||||
SectionNode,
|
||||
RowNode,
|
||||
ColumnNode,
|
||||
StackNode,
|
||||
TextNode,
|
||||
ImageNode,
|
||||
SpacerNode,
|
||||
DividerNode,
|
||||
TableNode,
|
||||
BoxNode,
|
||||
NodeStyle,
|
||||
PageSettings,
|
||||
TableColumn,
|
||||
NormalizedSpacing,
|
||||
Spacing,
|
||||
Dimension,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// ID Generation
|
||||
// ============================================================================
|
||||
|
||||
let idCounter = 0
|
||||
|
||||
/**
|
||||
* Generate a unique node ID
|
||||
*/
|
||||
export function generateId(prefix: string = 'node'): string {
|
||||
idCounter += 1
|
||||
return `${prefix}_${idCounter}_${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the ID counter (useful for testing)
|
||||
*/
|
||||
export function resetIdCounter(): void {
|
||||
idCounter = 0
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Spacing Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Normalize spacing value to explicit top/right/bottom/left
|
||||
*/
|
||||
export function normalizeSpacing(spacing: Spacing | undefined, defaultValue: number = 0): NormalizedSpacing {
|
||||
if (spacing === undefined) {
|
||||
return { top: defaultValue, right: defaultValue, bottom: defaultValue, left: defaultValue }
|
||||
}
|
||||
|
||||
if (typeof spacing === 'number') {
|
||||
return { top: spacing, right: spacing, bottom: spacing, left: spacing }
|
||||
}
|
||||
|
||||
if (spacing.length === 1) {
|
||||
const [v] = spacing
|
||||
return { top: v, right: v, bottom: v, left: v }
|
||||
}
|
||||
|
||||
if (spacing.length === 2) {
|
||||
const [vertical, horizontal] = spacing
|
||||
return { top: vertical, right: horizontal, bottom: vertical, left: horizontal }
|
||||
}
|
||||
|
||||
const [top, right, bottom, left] = spacing
|
||||
return { top, right, bottom, left }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dimension Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolve a dimension value to an absolute number
|
||||
* @param dimension The dimension value to resolve
|
||||
* @param reference The reference value for percentage calculations
|
||||
* @param defaultValue Default value if dimension is 'auto' or undefined
|
||||
*/
|
||||
export function resolveDimension(
|
||||
dimension: Dimension | undefined,
|
||||
reference: number,
|
||||
defaultValue: number = 0
|
||||
): number | 'auto' {
|
||||
if (dimension === undefined || dimension === 'auto') {
|
||||
return defaultValue === 0 ? 'auto' : defaultValue
|
||||
}
|
||||
|
||||
if (typeof dimension === 'number') {
|
||||
return dimension
|
||||
}
|
||||
|
||||
// Percentage string like "50%"
|
||||
const match = dimension.match(/^(\d+(?:\.\d+)?)%$/)
|
||||
if (match) {
|
||||
const percentage = parseFloat(match[1])
|
||||
return (percentage / 100) * reference
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a dimension is 'auto'
|
||||
*/
|
||||
export function isAutoDimension(dimension: Dimension | undefined): boolean {
|
||||
return dimension === undefined || dimension === 'auto'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Node Factory Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a document root node
|
||||
*/
|
||||
export function createDocument(
|
||||
children: Node[] = [],
|
||||
options: {
|
||||
id?: string
|
||||
pageSettings?: Partial<PageSettings>
|
||||
style?: NodeStyle
|
||||
metadata?: DocumentNode['props']['metadata']
|
||||
} = {}
|
||||
): DocumentNode {
|
||||
const defaultPageSettings: PageSettings = {
|
||||
size: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: [40, 40, 40, 40],
|
||||
}
|
||||
|
||||
return {
|
||||
id: options.id ?? generateId('doc'),
|
||||
type: 'document',
|
||||
props: {
|
||||
pageSettings: { ...defaultPageSettings, ...options.pageSettings },
|
||||
metadata: options.metadata,
|
||||
},
|
||||
style: options.style,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a section container
|
||||
* Sections are the primary organizational unit within a document
|
||||
*/
|
||||
export function createSection(
|
||||
children: Node[] = [],
|
||||
options: {
|
||||
id?: string
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): SectionNode {
|
||||
return {
|
||||
id: options.id ?? generateId('section'),
|
||||
type: 'section',
|
||||
style: { direction: 'column', ...options.style },
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a row container
|
||||
*/
|
||||
export function createRow(
|
||||
children: Node[] = [],
|
||||
options: {
|
||||
id?: string
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): RowNode {
|
||||
return {
|
||||
id: options.id ?? generateId('row'),
|
||||
type: 'row',
|
||||
style: { direction: 'row', ...options.style },
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a column container
|
||||
*/
|
||||
export function createColumn(
|
||||
children: Node[] = [],
|
||||
options: {
|
||||
id?: string
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): ColumnNode {
|
||||
return {
|
||||
id: options.id ?? generateId('col'),
|
||||
type: 'column',
|
||||
style: { direction: 'column', ...options.style },
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stack container
|
||||
*/
|
||||
export function createStack(
|
||||
children: Node[] = [],
|
||||
options: {
|
||||
id?: string
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): StackNode {
|
||||
return {
|
||||
id: options.id ?? generateId('stack'),
|
||||
type: 'stack',
|
||||
style: options.style,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a text node
|
||||
*/
|
||||
export function createText(
|
||||
value: string,
|
||||
options: {
|
||||
id?: string
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): TextNode {
|
||||
return {
|
||||
id: options.id ?? generateId('text'),
|
||||
type: 'text',
|
||||
props: { value },
|
||||
style: options.style,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image node
|
||||
*/
|
||||
export function createImage(
|
||||
src: string,
|
||||
options: {
|
||||
id?: string
|
||||
alt?: string
|
||||
fit?: ImageNode['props']['fit']
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): ImageNode {
|
||||
return {
|
||||
id: options.id ?? generateId('img'),
|
||||
type: 'image',
|
||||
props: {
|
||||
src,
|
||||
alt: options.alt,
|
||||
fit: options.fit ?? 'contain',
|
||||
},
|
||||
style: options.style,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spacer node
|
||||
*/
|
||||
export function createSpacer(
|
||||
options: {
|
||||
id?: string
|
||||
size?: number
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): SpacerNode {
|
||||
return {
|
||||
id: options.id ?? generateId('spacer'),
|
||||
type: 'spacer',
|
||||
props: options.size !== undefined ? { size: options.size } : undefined,
|
||||
style: options.style,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a divider node
|
||||
*/
|
||||
export function createDivider(
|
||||
options: {
|
||||
id?: string
|
||||
thickness?: number
|
||||
style?: 'solid' | 'dashed' | 'dotted'
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
nodeStyle?: NodeStyle
|
||||
} = {}
|
||||
): DividerNode {
|
||||
return {
|
||||
id: options.id ?? generateId('divider'),
|
||||
type: 'divider',
|
||||
props: {
|
||||
thickness: options.thickness ?? 1,
|
||||
style: options.style ?? 'solid',
|
||||
orientation: options.orientation,
|
||||
},
|
||||
style: options.nodeStyle,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a table node
|
||||
*/
|
||||
export function createTable(
|
||||
columns: TableColumn[],
|
||||
data: Record<string, unknown>[],
|
||||
options: {
|
||||
id?: string
|
||||
repeatHeader?: boolean
|
||||
rowHeight?: number
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): TableNode {
|
||||
return {
|
||||
id: options.id ?? generateId('table'),
|
||||
type: 'table',
|
||||
props: {
|
||||
columns,
|
||||
data,
|
||||
repeatHeader: options.repeatHeader ?? true,
|
||||
rowHeight: options.rowHeight,
|
||||
},
|
||||
style: options.style,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a box node
|
||||
*/
|
||||
export function createBox(
|
||||
children: Node[] = [],
|
||||
options: {
|
||||
id?: string
|
||||
style?: NodeStyle
|
||||
} = {}
|
||||
): BoxNode {
|
||||
return {
|
||||
id: options.id ?? generateId('box'),
|
||||
type: 'box',
|
||||
style: options.style,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Node Manipulation Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Clone a node with a new ID
|
||||
*/
|
||||
export function cloneNode<T extends Node>(node: T, deep: boolean = true): T {
|
||||
const newNode = {
|
||||
...node,
|
||||
id: generateId(node.type),
|
||||
}
|
||||
|
||||
if (deep && newNode.children) {
|
||||
newNode.children = newNode.children.map(child => cloneNode(child, true))
|
||||
}
|
||||
|
||||
return newNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge style properties into a node
|
||||
*/
|
||||
export function mergeStyle<T extends Node>(node: T, style: Partial<NodeStyle>): T {
|
||||
return {
|
||||
...node,
|
||||
style: { ...node.style, ...style },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node props
|
||||
*/
|
||||
export function updateProps<T extends Node>(node: T, props: Partial<NonNullable<T['props']>>): T {
|
||||
return {
|
||||
...node,
|
||||
props: { ...node.props, ...props } as T['props'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a node with default styling for a type
|
||||
*/
|
||||
export function createStyledNode<T extends Node>(
|
||||
node: T,
|
||||
defaultStyle: NodeStyle
|
||||
): T {
|
||||
return {
|
||||
...node,
|
||||
style: { ...defaultStyle, ...node.style },
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Page Size Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard page sizes in points (width x height for portrait)
|
||||
*/
|
||||
export const PAGE_SIZES: Record<string, [number, number]> = {
|
||||
A4: [595.28, 841.89],
|
||||
A3: [841.89, 1190.55],
|
||||
A5: [420.94, 595.28],
|
||||
Letter: [612, 792],
|
||||
Legal: [612, 1008],
|
||||
Tabloid: [792, 1224],
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page dimensions considering orientation
|
||||
*/
|
||||
export function getPageDimensions(
|
||||
size: PageSettings['size'],
|
||||
orientation: PageSettings['orientation'] = 'portrait'
|
||||
): { width: number; height: number } {
|
||||
let dimensions: [number, number]
|
||||
|
||||
if (typeof size === 'object') {
|
||||
dimensions = [size.width, size.height]
|
||||
} else {
|
||||
dimensions = PAGE_SIZES[size] ?? PAGE_SIZES.A4
|
||||
}
|
||||
|
||||
const [width, height] = dimensions
|
||||
|
||||
if (orientation === 'landscape') {
|
||||
return { width: height, height: width }
|
||||
}
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content area dimensions (page size minus margins)
|
||||
*/
|
||||
export function getContentArea(pageSettings: PageSettings): {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
} {
|
||||
const { width, height } = getPageDimensions(pageSettings.size, pageSettings.orientation)
|
||||
const margins = normalizeSpacing(pageSettings.margins ?? [40, 40, 40, 40])
|
||||
|
||||
return {
|
||||
x: margins.left,
|
||||
y: margins.top,
|
||||
width: width - margins.left - margins.right,
|
||||
height: height - margins.top - margins.bottom,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Tree Utilities
|
||||
*
|
||||
* Functions for traversing, searching, and manipulating document trees.
|
||||
*/
|
||||
|
||||
import type { Node, BaseNode } from './types'
|
||||
import { hasChildren } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Tree Traversal
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Callback function for tree traversal
|
||||
* @param node The current node
|
||||
* @param parent The parent node (null for root)
|
||||
* @param index The index of the node in its parent's children array
|
||||
* @param depth The depth of the node in the tree (0 for root)
|
||||
* @returns false to stop traversal, void to continue
|
||||
*/
|
||||
export type TraverseCallback = (
|
||||
node: Node,
|
||||
parent: Node | null,
|
||||
index: number,
|
||||
depth: number
|
||||
) => false | void
|
||||
|
||||
/**
|
||||
* Traverse the tree in depth-first order (pre-order)
|
||||
*/
|
||||
export function traverseTree(
|
||||
node: Node,
|
||||
callback: TraverseCallback,
|
||||
parent: Node | null = null,
|
||||
index: number = 0,
|
||||
depth: number = 0
|
||||
): boolean {
|
||||
const result = callback(node, parent, index, depth)
|
||||
|
||||
// Stop traversal if callback returns false
|
||||
if (result === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Traverse children
|
||||
if (hasChildren(node) && node.children) {
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const shouldContinue = traverseTree(node.children[i], callback, node, i, depth + 1)
|
||||
if (!shouldContinue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse the tree in breadth-first order
|
||||
*/
|
||||
export function traverseBreadthFirst(
|
||||
root: Node,
|
||||
callback: TraverseCallback
|
||||
): void {
|
||||
const queue: Array<{ node: Node; parent: Node | null; index: number; depth: number }> = [
|
||||
{ node: root, parent: null, index: 0, depth: 0 },
|
||||
]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { node, parent, index, depth } = queue.shift()!
|
||||
|
||||
const result = callback(node, parent, index, depth)
|
||||
if (result === false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasChildren(node) && node.children) {
|
||||
node.children.forEach((child, i) => {
|
||||
queue.push({ node: child, parent: node, index: i, depth: depth + 1 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tree Search
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find a node by ID in the tree
|
||||
*/
|
||||
export function findNodeById(root: Node, id: string): Node | null {
|
||||
let found: Node | null = null
|
||||
|
||||
traverseTree(root, (node) => {
|
||||
if (node.id === id) {
|
||||
found = node
|
||||
return false // Stop traversal
|
||||
}
|
||||
})
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the parent of a node by ID
|
||||
*/
|
||||
export function findParent(root: Node, nodeId: string): Node | null {
|
||||
let parent: Node | null = null
|
||||
|
||||
traverseTree(root, (node, parentNode) => {
|
||||
if (node.id === nodeId) {
|
||||
parent = parentNode
|
||||
return false // Stop traversal
|
||||
}
|
||||
})
|
||||
|
||||
return parent
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the path from root to a node
|
||||
*/
|
||||
export function findPath(root: Node, nodeId: string): Node[] {
|
||||
const path: Node[] = []
|
||||
|
||||
function search(node: Node, target: string, currentPath: Node[]): boolean {
|
||||
currentPath.push(node)
|
||||
|
||||
if (node.id === target) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (hasChildren(node) && node.children) {
|
||||
for (const child of node.children) {
|
||||
if (search(child, target, currentPath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPath.pop()
|
||||
return false
|
||||
}
|
||||
|
||||
search(root, nodeId, path)
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all nodes matching a predicate
|
||||
*/
|
||||
export function findNodes(root: Node, predicate: (node: Node) => boolean): Node[] {
|
||||
const nodes: Node[] = []
|
||||
|
||||
traverseTree(root, (node) => {
|
||||
if (predicate(node)) {
|
||||
nodes.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all nodes of a specific type
|
||||
*/
|
||||
export function findNodesByType(root: Node, type: string): Node[] {
|
||||
return findNodes(root, (node) => node.type === type)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tree Manipulation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the index of a node in its parent's children array
|
||||
*/
|
||||
export function getNodeIndex(parent: Node, nodeId: string): number {
|
||||
if (!hasChildren(parent) || !parent.children) {
|
||||
return -1
|
||||
}
|
||||
|
||||
return parent.children.findIndex((child) => child.id === nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a node into a parent at a specific index
|
||||
* Returns a new tree (immutable)
|
||||
*/
|
||||
export function insertNode(root: Node, parentId: string, node: Node, index?: number): Node {
|
||||
return mapTree(root, (currentNode) => {
|
||||
if (currentNode.id === parentId && hasChildren(currentNode)) {
|
||||
const children = [...(currentNode.children || [])]
|
||||
const insertIndex = index ?? children.length
|
||||
children.splice(insertIndex, 0, node)
|
||||
return { ...currentNode, children }
|
||||
}
|
||||
return currentNode
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node from the tree
|
||||
* Returns a new tree (immutable)
|
||||
*/
|
||||
export function removeNode(root: Node, nodeId: string): Node {
|
||||
return mapTree(root, (currentNode, parent) => {
|
||||
if (parent && hasChildren(parent)) {
|
||||
const children = (parent.children || []).filter((child) => child.id !== nodeId)
|
||||
if (children.length !== (parent.children || []).length) {
|
||||
// This is handled by the parent's mapping
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChildren(currentNode)) {
|
||||
const children = (currentNode.children || []).filter((child) => child.id !== nodeId)
|
||||
if (children.length !== (currentNode.children || []).length) {
|
||||
return { ...currentNode, children }
|
||||
}
|
||||
}
|
||||
|
||||
return currentNode
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a node to a new parent
|
||||
* Returns a new tree (immutable)
|
||||
*/
|
||||
export function moveNode(
|
||||
root: Node,
|
||||
nodeId: string,
|
||||
newParentId: string,
|
||||
newIndex?: number
|
||||
): Node {
|
||||
// First, find the node to move
|
||||
const nodeToMove = findNodeById(root, nodeId)
|
||||
if (!nodeToMove) {
|
||||
return root
|
||||
}
|
||||
|
||||
// Clone the node with a new ID (optional, depending on requirements)
|
||||
const clonedNode = { ...nodeToMove }
|
||||
|
||||
// Remove from current location and insert at new location
|
||||
let newTree = removeNode(root, nodeId)
|
||||
newTree = insertNode(newTree, newParentId, clonedNode, newIndex)
|
||||
|
||||
return newTree
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a node's properties
|
||||
* Returns a new tree (immutable)
|
||||
*/
|
||||
export function updateNode(
|
||||
root: Node,
|
||||
nodeId: string,
|
||||
updates: Partial<BaseNode>
|
||||
): Node {
|
||||
return mapTree(root, (node) => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, ...updates } as Node
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a node's style
|
||||
* Returns a new tree (immutable)
|
||||
*/
|
||||
export function updateNodeStyle(
|
||||
root: Node,
|
||||
nodeId: string,
|
||||
styleUpdates: Record<string, unknown>
|
||||
): Node {
|
||||
return mapTree(root, (node) => {
|
||||
if (node.id === nodeId) {
|
||||
return {
|
||||
...node,
|
||||
style: { ...node.style, ...styleUpdates },
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tree Mapping
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Map over all nodes in the tree, creating a new tree
|
||||
*/
|
||||
export function mapTree(
|
||||
node: Node,
|
||||
mapper: (node: Node, parent: Node | null) => Node,
|
||||
parent: Node | null = null
|
||||
): Node {
|
||||
const mappedNode = mapper(node, parent)
|
||||
|
||||
if (hasChildren(mappedNode) && mappedNode.children) {
|
||||
return {
|
||||
...mappedNode,
|
||||
children: mappedNode.children.map((child) => mapTree(child, mapper, mappedNode)),
|
||||
}
|
||||
}
|
||||
|
||||
return mappedNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter nodes from the tree (removes nodes where predicate returns false)
|
||||
* Note: If a parent is filtered out, all children are also removed
|
||||
*/
|
||||
export function filterTree(
|
||||
node: Node,
|
||||
predicate: (node: Node) => boolean
|
||||
): Node | null {
|
||||
if (!predicate(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasChildren(node) && node.children) {
|
||||
const filteredChildren = node.children
|
||||
.map((child) => filterTree(child, predicate))
|
||||
.filter((child): child is Node => child !== null)
|
||||
|
||||
return { ...node, children: filteredChildren }
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tree Analysis
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Count all nodes in the tree
|
||||
*/
|
||||
export function countNodes(root: Node): number {
|
||||
let count = 0
|
||||
traverseTree(root, () => {
|
||||
count++
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum depth of the tree
|
||||
*/
|
||||
export function getTreeDepth(root: Node): number {
|
||||
let maxDepth = 0
|
||||
traverseTree(root, (_node, _parent, _index, depth) => {
|
||||
maxDepth = Math.max(maxDepth, depth)
|
||||
})
|
||||
return maxDepth
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is an ancestor of another node
|
||||
*/
|
||||
export function isAncestor(root: Node, ancestorId: string, descendantId: string): boolean {
|
||||
const ancestor = findNodeById(root, ancestorId)
|
||||
if (!ancestor) {
|
||||
return false
|
||||
}
|
||||
|
||||
let found = false
|
||||
traverseTree(ancestor, (node) => {
|
||||
if (node.id === descendantId && node.id !== ancestorId) {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ancestors of a node
|
||||
*/
|
||||
export function getAncestors(root: Node, nodeId: string): Node[] {
|
||||
const path = findPath(root, nodeId)
|
||||
return path.slice(0, -1) // Remove the node itself
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendants of a node
|
||||
*/
|
||||
export function getDescendants(node: Node): Node[] {
|
||||
const descendants: Node[] = []
|
||||
|
||||
if (hasChildren(node) && node.children) {
|
||||
for (const child of node.children) {
|
||||
descendants.push(child)
|
||||
descendants.push(...getDescendants(child))
|
||||
}
|
||||
}
|
||||
|
||||
return descendants
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tree Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate the tree structure
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function validateTree(root: Node): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const ids = new Set<string>()
|
||||
|
||||
traverseTree(root, (node) => {
|
||||
// Check for duplicate IDs
|
||||
if (ids.has(node.id)) {
|
||||
errors.push(`Duplicate node ID: ${node.id}`)
|
||||
}
|
||||
ids.add(node.id)
|
||||
|
||||
// Check for empty IDs
|
||||
if (!node.id) {
|
||||
errors.push('Node with empty ID found')
|
||||
}
|
||||
|
||||
// Check for invalid node type
|
||||
const validTypes = [
|
||||
'document', 'row', 'column', 'stack', 'text',
|
||||
'image', 'spacer', 'divider', 'table', 'box'
|
||||
]
|
||||
if (!validTypes.includes(node.type)) {
|
||||
errors.push(`Invalid node type: ${node.type}`)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Serialize the tree to a JSON string
|
||||
*/
|
||||
export function serializeTree(root: Node, pretty: boolean = false): string {
|
||||
return JSON.stringify(root, null, pretty ? 2 : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a tree from a JSON string
|
||||
*/
|
||||
export function deserializeTree(json: string): Node {
|
||||
return JSON.parse(json) as Node
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone a tree
|
||||
*/
|
||||
export function deepCloneTree(root: Node): Node {
|
||||
return JSON.parse(JSON.stringify(root))
|
||||
}
|
||||
@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Core Document Model Types
|
||||
*
|
||||
* These types define the structure of documents in the PDF Engine.
|
||||
* The model is designed to be:
|
||||
* - Serializable to JSON
|
||||
* - Editable in the visual editor
|
||||
* - Renderable to PDF
|
||||
* - Compatible with AI generation
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Dimension Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Dimension value - can be absolute (number in points) or relative (percentage string)
|
||||
*/
|
||||
export type Dimension = number | `${number}%` | 'auto'
|
||||
|
||||
/**
|
||||
* Spacing value - CSS-style shorthand for box model spacing
|
||||
* - number: uniform spacing on all sides
|
||||
* - [number]: uniform spacing on all sides (array form)
|
||||
* - [number, number]: [vertical, horizontal]
|
||||
* - [number, number, number, number]: [top, right, bottom, left]
|
||||
*/
|
||||
export type Spacing = number | [number] | [number, number] | [number, number, number, number]
|
||||
|
||||
/**
|
||||
* Normalized spacing with all four sides explicitly defined
|
||||
*/
|
||||
export interface NormalizedSpacing {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Style Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Text alignment options
|
||||
*/
|
||||
export type TextAlign = 'left' | 'center' | 'right' | 'justify'
|
||||
|
||||
/**
|
||||
* Flex direction for containers
|
||||
*/
|
||||
export type FlexDirection = 'row' | 'column'
|
||||
|
||||
/**
|
||||
* Flex justification (main axis alignment)
|
||||
*/
|
||||
export type JustifyContent = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'
|
||||
|
||||
/**
|
||||
* Flex alignment (cross axis alignment)
|
||||
*/
|
||||
export type AlignItems = 'start' | 'center' | 'end' | 'stretch' | 'baseline'
|
||||
|
||||
/**
|
||||
* Node style properties - CSS-inspired but simplified for PDF rendering
|
||||
*/
|
||||
export interface NodeStyle {
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout dimensions
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** Width of the node */
|
||||
width?: Dimension
|
||||
|
||||
/** Height of the node */
|
||||
height?: Dimension
|
||||
|
||||
/** Minimum width constraint */
|
||||
minWidth?: Dimension
|
||||
|
||||
/** Maximum width constraint */
|
||||
maxWidth?: Dimension
|
||||
|
||||
/** Minimum height constraint */
|
||||
minHeight?: Dimension
|
||||
|
||||
/** Maximum height constraint */
|
||||
maxHeight?: Dimension
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Spacing (box model)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** External spacing around the node */
|
||||
margin?: Spacing
|
||||
|
||||
/** Internal spacing inside the node */
|
||||
padding?: Spacing
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Flex container properties
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** Direction of flex layout (only for containers) */
|
||||
direction?: FlexDirection
|
||||
|
||||
/** Main axis alignment */
|
||||
justify?: JustifyContent
|
||||
|
||||
/** Cross axis alignment */
|
||||
align?: AlignItems
|
||||
|
||||
/** Gap between flex items */
|
||||
gap?: number
|
||||
|
||||
/** Whether flex items should wrap */
|
||||
wrap?: boolean
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Visual properties
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** Background color (hex, rgb, or named color) */
|
||||
backgroundColor?: string
|
||||
|
||||
/** Border color */
|
||||
borderColor?: string
|
||||
|
||||
/** Border width (uniform for all sides) */
|
||||
borderWidth?: number
|
||||
|
||||
/** Border radius for rounded corners */
|
||||
borderRadius?: number
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Typography properties
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** Font family name */
|
||||
fontFamily?: string
|
||||
|
||||
/** Font size in points */
|
||||
fontSize?: number
|
||||
|
||||
/** Font weight (normal, bold, or numeric 100-900) */
|
||||
fontWeight?: string | number
|
||||
|
||||
/** Font style */
|
||||
fontStyle?: 'normal' | 'italic'
|
||||
|
||||
/** Text color */
|
||||
color?: string
|
||||
|
||||
/** Text alignment within the node */
|
||||
textAlign?: TextAlign
|
||||
|
||||
/** Line height multiplier (1.0 = single spacing) */
|
||||
lineHeight?: number
|
||||
|
||||
/** Text decoration */
|
||||
textDecoration?: 'none' | 'underline' | 'line-through'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Node Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Node type identifiers
|
||||
*/
|
||||
export type NodeType =
|
||||
// Containers
|
||||
| 'section'
|
||||
| 'row'
|
||||
| 'column'
|
||||
| 'stack'
|
||||
// Primitives
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'spacer'
|
||||
| 'divider'
|
||||
// Complex
|
||||
| 'table'
|
||||
| 'box'
|
||||
// Special
|
||||
| 'document'
|
||||
|
||||
/**
|
||||
* Base node structure - all nodes share these properties
|
||||
*/
|
||||
export interface BaseNode {
|
||||
/** Unique identifier for the node */
|
||||
id: string
|
||||
|
||||
/** Type of the node */
|
||||
type: NodeType
|
||||
|
||||
/** Node-specific properties */
|
||||
props?: Record<string, unknown>
|
||||
|
||||
/** Style properties */
|
||||
style?: NodeStyle
|
||||
|
||||
/** Child nodes (only for container nodes) */
|
||||
children?: Node[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Container Nodes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Section container - represents a document section that contains rows
|
||||
* Sections are the primary organizational unit within a document
|
||||
*/
|
||||
export interface SectionNode extends BaseNode {
|
||||
type: 'section'
|
||||
children: Node[] // Typically contains RowNode children
|
||||
}
|
||||
|
||||
/**
|
||||
* Row container - arranges children horizontally
|
||||
*/
|
||||
export interface RowNode extends BaseNode {
|
||||
type: 'row'
|
||||
children: Node[] // Typically contains ColumnNode children
|
||||
}
|
||||
|
||||
/**
|
||||
* Column container - arranges children vertically
|
||||
*/
|
||||
export interface ColumnNode extends BaseNode {
|
||||
type: 'column'
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack container - overlays children on top of each other
|
||||
*/
|
||||
export interface StackNode extends BaseNode {
|
||||
type: 'stack'
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Primitive Nodes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Text node - displays text content
|
||||
*/
|
||||
export interface TextNode extends BaseNode {
|
||||
type: 'text'
|
||||
props: {
|
||||
/** The text content to display */
|
||||
value: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image node - displays an image
|
||||
*/
|
||||
export interface ImageNode extends BaseNode {
|
||||
type: 'image'
|
||||
props: {
|
||||
/** Image source (URL, base64, or data URI) */
|
||||
src: string
|
||||
/** Alternative text for accessibility */
|
||||
alt?: string
|
||||
/** Image fit mode */
|
||||
fit?: 'contain' | 'cover' | 'fill' | 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spacer node - adds flexible or fixed spacing
|
||||
*/
|
||||
export interface SpacerNode extends BaseNode {
|
||||
type: 'spacer'
|
||||
props?: {
|
||||
/** Fixed size (if not provided, spacer is flexible) */
|
||||
size?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider node - horizontal or vertical line separator
|
||||
*/
|
||||
export interface DividerNode extends BaseNode {
|
||||
type: 'divider'
|
||||
props?: {
|
||||
/** Line thickness in points */
|
||||
thickness?: number
|
||||
/** Line style */
|
||||
style?: 'solid' | 'dashed' | 'dotted'
|
||||
/** Orientation (auto-detects based on container if not specified) */
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Complex Nodes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Table column definition
|
||||
*/
|
||||
export interface TableColumn {
|
||||
/** Column identifier */
|
||||
key: string
|
||||
/** Column header text */
|
||||
header: string
|
||||
/** Column width (percentage or fixed) */
|
||||
width?: Dimension
|
||||
/** Column-specific style */
|
||||
style?: NodeStyle
|
||||
}
|
||||
|
||||
/**
|
||||
* Table node - displays tabular data
|
||||
*/
|
||||
export interface TableNode extends BaseNode {
|
||||
type: 'table'
|
||||
props: {
|
||||
/** Column definitions */
|
||||
columns: TableColumn[]
|
||||
/** Row data (array of objects with keys matching columns) */
|
||||
data: Record<string, unknown>[]
|
||||
/** Whether to repeat header on each page */
|
||||
repeatHeader?: boolean
|
||||
/** Row height (auto if not specified) */
|
||||
rowHeight?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Box node - container with visual styling (border, background, padding)
|
||||
*/
|
||||
export interface BoxNode extends BaseNode {
|
||||
type: 'box'
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document Node
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Page size presets
|
||||
*/
|
||||
export type PageSizePreset = 'A4' | 'A3' | 'A5' | 'Letter' | 'Legal' | 'Tabloid'
|
||||
|
||||
/**
|
||||
* Page orientation
|
||||
*/
|
||||
export type PageOrientation = 'portrait' | 'landscape'
|
||||
|
||||
/**
|
||||
* Page settings for the document
|
||||
*/
|
||||
export interface PageSettings {
|
||||
/** Page size preset or custom dimensions */
|
||||
size: PageSizePreset | { width: number; height: number }
|
||||
/** Page orientation */
|
||||
orientation?: PageOrientation
|
||||
/** Page margins [top, right, bottom, left] */
|
||||
margins?: [number, number, number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Document node - root node of a document tree
|
||||
*/
|
||||
export interface DocumentNode extends BaseNode {
|
||||
type: 'document'
|
||||
props: {
|
||||
/** Page settings */
|
||||
pageSettings: PageSettings
|
||||
/** Document metadata */
|
||||
metadata?: {
|
||||
title?: string
|
||||
author?: string
|
||||
subject?: string
|
||||
keywords?: string[]
|
||||
}
|
||||
}
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Union Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Union of all container node types
|
||||
*/
|
||||
export type ContainerNode = SectionNode | RowNode | ColumnNode | StackNode | BoxNode
|
||||
|
||||
/**
|
||||
* Union of all primitive node types
|
||||
*/
|
||||
export type PrimitiveNode = TextNode | ImageNode | SpacerNode | DividerNode
|
||||
|
||||
/**
|
||||
* Union of all complex node types
|
||||
*/
|
||||
export type ComplexNode = TableNode
|
||||
|
||||
/**
|
||||
* Union of all node types
|
||||
*/
|
||||
export type Node =
|
||||
| DocumentNode
|
||||
| ContainerNode
|
||||
| PrimitiveNode
|
||||
| ComplexNode
|
||||
|
||||
// ============================================================================
|
||||
// Layout Types (computed during layout phase)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Computed layout box with absolute positioning
|
||||
*/
|
||||
export interface LayoutBox {
|
||||
/** ID of the node this box represents */
|
||||
nodeId: string
|
||||
|
||||
/** X position from left edge */
|
||||
x: number
|
||||
|
||||
/** Y position from top edge */
|
||||
y: number
|
||||
|
||||
/** Width of the box */
|
||||
width: number
|
||||
|
||||
/** Height of the box */
|
||||
height: number
|
||||
|
||||
/** Child layout boxes (for containers) */
|
||||
children?: LayoutBox[]
|
||||
|
||||
/** Page number this box appears on */
|
||||
pageNumber?: number
|
||||
|
||||
/** Whether this box is continued from a previous page */
|
||||
continuedFrom?: number
|
||||
|
||||
/** Whether this box continues to a next page */
|
||||
continuesTo?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout context passed during layout calculation
|
||||
*/
|
||||
export interface LayoutContext {
|
||||
/** Available width for layout */
|
||||
availableWidth: number
|
||||
|
||||
/** Available height for layout */
|
||||
availableHeight: number
|
||||
|
||||
/** Current X position */
|
||||
currentX: number
|
||||
|
||||
/** Current Y position */
|
||||
currentY: number
|
||||
|
||||
/** Page width */
|
||||
pageWidth: number
|
||||
|
||||
/** Page height */
|
||||
pageHeight: number
|
||||
|
||||
/** Page margins */
|
||||
margins: NormalizedSpacing
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Guards
|
||||
// ============================================================================
|
||||
|
||||
/** Check if a node is a container node */
|
||||
export function isContainerNode(node: Node): node is ContainerNode {
|
||||
return ['section', 'row', 'column', 'stack', 'box'].includes(node.type)
|
||||
}
|
||||
|
||||
/** Check if a node is a section */
|
||||
export function isSectionNode(node: Node): node is SectionNode {
|
||||
return node.type === 'section'
|
||||
}
|
||||
|
||||
/** Check if a node is a row */
|
||||
export function isRowNode(node: Node): node is RowNode {
|
||||
return node.type === 'row'
|
||||
}
|
||||
|
||||
/** Check if a node is a column */
|
||||
export function isColumnNode(node: Node): node is ColumnNode {
|
||||
return node.type === 'column'
|
||||
}
|
||||
|
||||
/** Check if a node is a stack */
|
||||
export function isStackNode(node: Node): node is StackNode {
|
||||
return node.type === 'stack'
|
||||
}
|
||||
|
||||
/** Check if a node is a text node */
|
||||
export function isTextNode(node: Node): node is TextNode {
|
||||
return node.type === 'text'
|
||||
}
|
||||
|
||||
/** Check if a node is an image node */
|
||||
export function isImageNode(node: Node): node is ImageNode {
|
||||
return node.type === 'image'
|
||||
}
|
||||
|
||||
/** Check if a node is a spacer */
|
||||
export function isSpacerNode(node: Node): node is SpacerNode {
|
||||
return node.type === 'spacer'
|
||||
}
|
||||
|
||||
/** Check if a node is a divider */
|
||||
export function isDividerNode(node: Node): node is DividerNode {
|
||||
return node.type === 'divider'
|
||||
}
|
||||
|
||||
/** Check if a node is a table */
|
||||
export function isTableNode(node: Node): node is TableNode {
|
||||
return node.type === 'table'
|
||||
}
|
||||
|
||||
/** Check if a node is a box */
|
||||
export function isBoxNode(node: Node): node is BoxNode {
|
||||
return node.type === 'box'
|
||||
}
|
||||
|
||||
/** Check if a node is a document root */
|
||||
export function isDocumentNode(node: Node): node is DocumentNode {
|
||||
return node.type === 'document'
|
||||
}
|
||||
|
||||
/** Check if a node can have children */
|
||||
export function hasChildren(node: Node): node is Node & { children: Node[] } {
|
||||
return isContainerNode(node) || isDocumentNode(node)
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Canvas.svelte
|
||||
*
|
||||
* Main document canvas component that displays the document pages
|
||||
* and handles user interactions.
|
||||
*/
|
||||
import type { DocumentNode } from '../../core/model/types'
|
||||
import { isDocumentNode } from '../../core/model/types'
|
||||
import Page from './Page.svelte'
|
||||
|
||||
// Props using Svelte5 runes
|
||||
let {
|
||||
document,
|
||||
selectedId = null,
|
||||
hoveredId = null,
|
||||
zoom = 1,
|
||||
onselect = undefined,
|
||||
onhover = undefined,
|
||||
oninsertbutton = undefined,
|
||||
}: {
|
||||
document: DocumentNode
|
||||
selectedId?: string | null
|
||||
hoveredId?: string | null
|
||||
zoom?: number
|
||||
onselect?: ((nodeId: string | null, event: MouseEvent) => void)
|
||||
onhover?: ((nodeId: string | null, event: MouseEvent) => void)
|
||||
oninsertbutton?: (parentId: string, index: number) => void
|
||||
} = $props()
|
||||
|
||||
// Get page settings from document
|
||||
let pageSettings = $derived(document.props.pageSettings)
|
||||
let children = $derived(document.children || [])
|
||||
|
||||
// Handle background click to deselect
|
||||
function handleBackgroundClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
onselect?.(null, event)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle node selection
|
||||
function handleNodeSelect(nodeId: string, event: MouseEvent) {
|
||||
onselect?.(nodeId, event)
|
||||
}
|
||||
|
||||
// Handle node hover
|
||||
function handleNodeHover(nodeId: string | null, event: MouseEvent) {
|
||||
onhover?.(nodeId, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="canvas-container" onclick={handleBackgroundClick}>
|
||||
<div class="canvas-scroll">
|
||||
<div class="canvas" style="transform: scale({zoom}); transform-origin: top center;">
|
||||
<Page
|
||||
{pageSettings}
|
||||
{children}
|
||||
pageNumber={1}
|
||||
{selectedId}
|
||||
{hoveredId}
|
||||
onselect={handleNodeSelect}
|
||||
onhover={handleNodeHover}
|
||||
{oninsertbutton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #e5e5e5;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.canvas-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* InsertButton.svelte
|
||||
*
|
||||
* A '+' button that appears between elements for inline insertion.
|
||||
* Similar to Divi's inline insertion controls.
|
||||
*/
|
||||
import type { NodeType } from '../../core/model/types'
|
||||
|
||||
// Props using Svelte5 runes
|
||||
let {
|
||||
position = 'after',
|
||||
parentId,
|
||||
index,
|
||||
allowedTypes = [],
|
||||
orientation = 'horizontal',
|
||||
disabled = false,
|
||||
oninsert = undefined,
|
||||
}: {
|
||||
position?: 'before' | 'after'
|
||||
parentId: string
|
||||
index: number
|
||||
allowedTypes?: NodeType[]
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
disabled?: boolean
|
||||
oninsert?: (parentId: string, index: number) => void
|
||||
} = $props()
|
||||
|
||||
let isHovered = $state(false)
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
if (!disabled) {
|
||||
oninsert?.(parentId, index)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
isHovered = true
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isHovered = false
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if allowedTypes.length > 0}
|
||||
<div
|
||||
class="insert-button {orientation}"
|
||||
class:hovered={isHovered}
|
||||
class:disabled
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
title="Add element"
|
||||
>
|
||||
<span class="plus-icon">+</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.insert-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px dashed #d1d5db;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.insert-button.horizontal {
|
||||
/* Default horizontal layout */
|
||||
}
|
||||
|
||||
.insert-button.vertical {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border-radius: 12px;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* Show buttons on hover of parent container */
|
||||
:global(.section-node:hover) .insert-button,
|
||||
:global(.row-node:hover) .insert-button,
|
||||
:global(.column-node:hover) .insert-button,
|
||||
:global(.box-node:hover) .insert-button {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.insert-button:hover,
|
||||
.insert-button.hovered {
|
||||
opacity: 1 !important;
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
border-style: solid;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.insert-button.vertical:hover,
|
||||
.insert-button.vertical.hovered {
|
||||
transform: none;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.insert-button.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3 !important;
|
||||
}
|
||||
|
||||
.insert-button.disabled:hover {
|
||||
background: white;
|
||||
border-color: #d1d5db;
|
||||
border-style: dashed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.plus-icon {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #9ca3af;
|
||||
line-height: 1;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.insert-button:hover .plus-icon,
|
||||
.insert-button.hovered .plus-icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.insert-button:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,745 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* NodeRenderer.svelte
|
||||
*
|
||||
* Renders a document node to HTML for the visual editor canvas.
|
||||
* This provides a WYSIWYG-like preview of the document.
|
||||
*/
|
||||
import type { Node, NodeStyle, NodeType } from '../../core/model/types'
|
||||
import {
|
||||
hasChildren,
|
||||
isTextNode,
|
||||
isImageNode,
|
||||
isSpacerNode,
|
||||
isDividerNode,
|
||||
isSectionNode,
|
||||
isRowNode,
|
||||
isColumnNode,
|
||||
isBoxNode,
|
||||
} from '../../core/model/types'
|
||||
import { normalizeSpacing } from '../../core/model/node'
|
||||
import InsertButton from './InsertButton.svelte'
|
||||
|
||||
// Props using Svelte5 runes
|
||||
let {
|
||||
node,
|
||||
depth = 0,
|
||||
selectedId = null,
|
||||
hoveredId = null,
|
||||
onselect = undefined,
|
||||
onhover = undefined,
|
||||
oninsertbutton = undefined,
|
||||
}: {
|
||||
node: Node
|
||||
depth?: number
|
||||
selectedId?: string | null
|
||||
hoveredId?: string | null
|
||||
onselect?: ((nodeId: string, event: MouseEvent) => void)
|
||||
onhover?: ((nodeId: string | null, event: MouseEvent) => void)
|
||||
oninsertbutton?: (parentId: string, index: number) => void
|
||||
} = $props()
|
||||
|
||||
let isSelected = $derived(selectedId === node.id)
|
||||
let isHovered = $derived(hoveredId === node.id)
|
||||
|
||||
// Convert node style to CSS
|
||||
function styleToCss(style: NodeStyle | undefined): string {
|
||||
if (!style) return ''
|
||||
|
||||
const css: string[] = []
|
||||
|
||||
// Dimensions
|
||||
if (style.width !== undefined && style.width !== 'auto') {
|
||||
css.push(`width: ${typeof style.width === 'number' ? style.width + 'pt' : style.width}`)
|
||||
}
|
||||
if (style.height !== undefined && style.height !== 'auto') {
|
||||
css.push(`height: ${typeof style.height === 'number' ? style.height + 'pt' : style.height}`)
|
||||
}
|
||||
|
||||
// Spacing
|
||||
const margin = normalizeSpacing(style.margin)
|
||||
const padding = normalizeSpacing(style.padding)
|
||||
|
||||
css.push(`margin: ${margin.top}pt ${margin.right}pt ${margin.bottom}pt ${margin.left}pt`)
|
||||
css.push(`padding: ${padding.top}pt ${padding.right}pt ${padding.bottom}pt ${padding.left}pt`)
|
||||
|
||||
// Flex
|
||||
if (style.direction) {
|
||||
css.push(`display: flex`)
|
||||
css.push(`flex-direction: ${style.direction}`)
|
||||
}
|
||||
if (style.justify) {
|
||||
const justifyMap: Record<string, string> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
between: 'space-between',
|
||||
around: 'space-around',
|
||||
evenly: 'space-evenly',
|
||||
}
|
||||
css.push(`justify-content: ${justifyMap[style.justify] || style.justify}`)
|
||||
}
|
||||
if (style.align) {
|
||||
const alignMap: Record<string, string> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
stretch: 'stretch',
|
||||
baseline: 'baseline',
|
||||
}
|
||||
css.push(`align-items: ${alignMap[style.align] || style.align}`)
|
||||
}
|
||||
if (style.gap !== undefined) {
|
||||
css.push(`gap: ${style.gap}pt`)
|
||||
}
|
||||
if (style.wrap) {
|
||||
css.push(`flex-wrap: wrap`)
|
||||
}
|
||||
|
||||
// Visual
|
||||
if (style.backgroundColor) {
|
||||
css.push(`background-color: ${style.backgroundColor}`)
|
||||
}
|
||||
if (style.borderColor) {
|
||||
css.push(`border-color: ${style.borderColor}`)
|
||||
}
|
||||
if (style.borderWidth !== undefined) {
|
||||
css.push(`border-width: ${style.borderWidth}pt`)
|
||||
css.push(`border-style: solid`)
|
||||
}
|
||||
if (style.borderRadius !== undefined) {
|
||||
css.push(`border-radius: ${style.borderRadius}pt`)
|
||||
}
|
||||
|
||||
// Typography
|
||||
if (style.fontFamily) {
|
||||
css.push(`font-family: ${style.fontFamily}`)
|
||||
}
|
||||
if (style.fontSize !== undefined) {
|
||||
css.push(`font-size: ${style.fontSize}pt`)
|
||||
}
|
||||
if (style.fontWeight !== undefined) {
|
||||
css.push(`font-weight: ${style.fontWeight}`)
|
||||
}
|
||||
if (style.fontStyle) {
|
||||
css.push(`font-style: ${style.fontStyle}`)
|
||||
}
|
||||
if (style.color) {
|
||||
css.push(`color: ${style.color}`)
|
||||
}
|
||||
if (style.textAlign) {
|
||||
css.push(`text-align: ${style.textAlign}`)
|
||||
}
|
||||
if (style.lineHeight !== undefined) {
|
||||
css.push(`line-height: ${style.lineHeight}`)
|
||||
}
|
||||
if (style.textDecoration) {
|
||||
css.push(`text-decoration: ${style.textDecoration}`)
|
||||
}
|
||||
|
||||
return css.join('; ')
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
onselect?.(node.id, event)
|
||||
}
|
||||
|
||||
function handleMouseEnter(event: MouseEvent) {
|
||||
onhover?.(node.id, event)
|
||||
}
|
||||
|
||||
function handleMouseLeave(event: MouseEvent) {
|
||||
onhover?.(null, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Text Node -->
|
||||
{#if isTextNode(node)}
|
||||
<div
|
||||
class="node text-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{node.props.value}
|
||||
</div>
|
||||
|
||||
<!-- Image Node -->
|
||||
{:else if isImageNode(node)}
|
||||
<div
|
||||
class="node image-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if node.props.src}
|
||||
<img src={node.props.src} alt={node.props.alt || ''} />
|
||||
{:else}
|
||||
<div class="image-placeholder">
|
||||
<span>No image</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Spacer Node -->
|
||||
{:else if isSpacerNode(node)}
|
||||
<div
|
||||
class="node spacer-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if node.props?.size}
|
||||
<!-- Fixed spacer -->
|
||||
{:else}
|
||||
<div class="spacer-indicator"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Divider Node -->
|
||||
{:else if isDividerNode(node)}
|
||||
<div
|
||||
class="node divider-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<hr
|
||||
style="
|
||||
border: none;
|
||||
border-top: {node.props?.thickness || 1}pt {node.props?.style || 'solid'} {node.style?.borderColor || '#000'};
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Section Container -->
|
||||
{:else if isSectionNode(node)}
|
||||
<div
|
||||
class="node section-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#each node.children || [] as child, i (child.id)}
|
||||
{#if i === 0}
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={0}
|
||||
orientation="vertical"
|
||||
allowedTypes={['row']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/if}
|
||||
<svelte:self
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
{selectedId}
|
||||
{hoveredId}
|
||||
{onselect}
|
||||
{onhover}
|
||||
{oninsertbutton}
|
||||
/>
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={i + 1}
|
||||
orientation="vertical"
|
||||
allowedTypes={['row']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/each}
|
||||
{#if !node.children?.length}
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={0}
|
||||
orientation="vertical"
|
||||
allowedTypes={['row']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Row Container -->
|
||||
{:else if isRowNode(node)}
|
||||
<div
|
||||
class="node row-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#each node.children || [] as child, i (child.id)}
|
||||
{#if i === 0}
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={0}
|
||||
orientation="horizontal"
|
||||
allowedTypes={['column']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/if}
|
||||
<svelte:self
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
{selectedId}
|
||||
{hoveredId}
|
||||
{onselect}
|
||||
{onhover}
|
||||
{oninsertbutton}
|
||||
/>
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={i + 1}
|
||||
orientation="horizontal"
|
||||
allowedTypes={['column']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/each}
|
||||
{#if !node.children?.length}
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={0}
|
||||
orientation="horizontal"
|
||||
allowedTypes={['column']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Column Container -->
|
||||
{:else if isColumnNode(node)}
|
||||
<div
|
||||
class="node column-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#each node.children || [] as child, i (child.id)}
|
||||
{#if i === 0}
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={0}
|
||||
orientation="vertical"
|
||||
allowedTypes={['text', 'image', 'spacer', 'divider', 'box']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/if}
|
||||
<svelte:self
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
{selectedId}
|
||||
{hoveredId}
|
||||
{onselect}
|
||||
{onhover}
|
||||
{oninsertbutton}
|
||||
/>
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={i + 1}
|
||||
orientation="vertical"
|
||||
allowedTypes={['text', 'image', 'spacer', 'divider', 'box']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/each}
|
||||
{#if !node.children?.length}
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={0}
|
||||
orientation="vertical"
|
||||
allowedTypes={['text', 'image', 'spacer', 'divider', 'box']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Box Container -->
|
||||
{:else if isBoxNode(node)}
|
||||
<div
|
||||
class="node box-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#each node.children || [] as child, i (child.id)}
|
||||
{#if i === 0}
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={0}
|
||||
orientation="vertical"
|
||||
allowedTypes={['text', 'image', 'spacer', 'divider']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/if}
|
||||
<svelte:self
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
{selectedId}
|
||||
{hoveredId}
|
||||
{onselect}
|
||||
{onhover}
|
||||
{oninsertbutton}
|
||||
/>
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={i + 1}
|
||||
orientation="vertical"
|
||||
allowedTypes={['text', 'image', 'spacer', 'divider']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/each}
|
||||
{#if !node.children?.length}
|
||||
<InsertButton
|
||||
parentId={node.id}
|
||||
index={0}
|
||||
orientation="vertical"
|
||||
allowedTypes={['text', 'image', 'spacer', 'divider']}
|
||||
oninsert={oninsertbutton}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Fallback for unknown nodes -->
|
||||
{:else if hasChildren(node)}
|
||||
<div
|
||||
class="node container-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
style={styleToCss(node.style)}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#each (node as any).children || [] as child (child.id)}
|
||||
<svelte:self
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
{selectedId}
|
||||
{hoveredId}
|
||||
{onselect}
|
||||
{onhover}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div
|
||||
class="node unknown-node"
|
||||
class:selected={isSelected}
|
||||
class:hovered={isHovered}
|
||||
onclick={handleClick}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Unknown: {node.type}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.node {
|
||||
position: relative;
|
||||
min-height: 1em;
|
||||
cursor: pointer;
|
||||
outline-offset: 2px;
|
||||
transition: outline 0.15s ease;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.node.hovered {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.node.selected {
|
||||
outline-width: 2px !important;
|
||||
outline-style: solid !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Section node - Blue theme */
|
||||
.section-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 50pt;
|
||||
outline: 2px dashed rgba(59, 130, 246, 0.4);
|
||||
background: linear-gradient(to right, rgba(59, 130, 246, 0.05) 0%, transparent 100%);
|
||||
border-radius: 4px;
|
||||
padding: 4pt;
|
||||
}
|
||||
|
||||
.section-node:hover {
|
||||
outline: 2px dashed rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
|
||||
.section-node.hovered {
|
||||
outline: 2px dashed rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
|
||||
.section-node.selected {
|
||||
outline: 2px solid #3b82f6 !important;
|
||||
background: linear-gradient(to right, rgba(59, 130, 246, 0.1) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Row node - Green theme */
|
||||
.row-node {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 35pt;
|
||||
outline: 2px dashed rgba(16, 185, 129, 0.4);
|
||||
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.05) 0%, transparent 100%);
|
||||
border-radius: 4px;
|
||||
padding: 2pt;
|
||||
}
|
||||
|
||||
.row-node:hover {
|
||||
outline: 2px dashed rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
|
||||
.row-node.hovered {
|
||||
outline: 2px dashed rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
|
||||
.row-node.selected {
|
||||
outline: 2px solid #10b981 !important;
|
||||
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.1) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Column node - Orange theme */
|
||||
.column-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 25pt;
|
||||
outline: 2px dashed rgba(245, 158, 11, 0.4);
|
||||
background: linear-gradient(to right, rgba(245, 158, 11, 0.05) 0%, transparent 100%);
|
||||
border-radius: 4px;
|
||||
padding: 2pt;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.column-node:hover {
|
||||
outline: 2px dashed rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.column-node.hovered {
|
||||
outline: 2px dashed rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.column-node.selected {
|
||||
outline: 2px solid #f59e0b !important;
|
||||
background: linear-gradient(to right, rgba(245, 158, 11, 0.1) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Box node - Purple theme */
|
||||
.box-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 25pt;
|
||||
outline: 2px dashed rgba(139, 92, 246, 0.4);
|
||||
background: rgba(139, 92, 246, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.box-node:hover {
|
||||
outline: 2px dashed rgba(139, 92, 246, 0.7);
|
||||
}
|
||||
|
||||
.box-node.hovered {
|
||||
outline: 2px dashed rgba(139, 92, 246, 0.7);
|
||||
}
|
||||
|
||||
.box-node.selected {
|
||||
outline: 2px solid #8b5cf6 !important;
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Text node - Gray theme */
|
||||
.text-node {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
min-height: 15pt;
|
||||
outline: 1px dashed rgba(107, 114, 128, 0.4);
|
||||
background: rgba(107, 114, 128, 0.03);
|
||||
border-radius: 2px;
|
||||
padding: 2pt;
|
||||
}
|
||||
|
||||
.text-node:hover {
|
||||
outline: 1px dashed rgba(107, 114, 128, 0.7);
|
||||
}
|
||||
|
||||
.text-node.hovered {
|
||||
outline: 1px dashed rgba(107, 114, 128, 0.7);
|
||||
}
|
||||
|
||||
.text-node.selected {
|
||||
outline: 2px solid #6b7280 !important;
|
||||
background: rgba(107, 114, 128, 0.08);
|
||||
}
|
||||
|
||||
/* Image node - Cyan theme */
|
||||
.image-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
min-height: 40pt;
|
||||
outline: 1px dashed rgba(6, 182, 212, 0.4);
|
||||
background: rgba(6, 182, 212, 0.03);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.image-node:hover {
|
||||
outline: 1px dashed rgba(6, 182, 212, 0.7);
|
||||
}
|
||||
|
||||
.image-node.hovered {
|
||||
outline: 1px dashed rgba(6, 182, 212, 0.7);
|
||||
}
|
||||
|
||||
.image-node.selected {
|
||||
outline: 2px solid #06b6d4 !important;
|
||||
background: rgba(6, 182, 212, 0.08);
|
||||
}
|
||||
|
||||
.image-node img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 60pt;
|
||||
background: #f0f0f0;
|
||||
border: 1px dashed #ccc;
|
||||
color: #999;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
/* Spacer node - Light gray */
|
||||
.spacer-node {
|
||||
min-height: 10pt;
|
||||
outline: 1px dashed rgba(156, 163, 175, 0.4);
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 5px,
|
||||
rgba(200, 200, 200, 0.15) 5px,
|
||||
rgba(200, 200, 200, 0.15) 10px
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.spacer-node:hover {
|
||||
outline: 1px dashed rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
.spacer-node.hovered {
|
||||
outline: 1px dashed rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
.spacer-node.selected {
|
||||
outline: 2px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
.spacer-indicator {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 10pt;
|
||||
}
|
||||
|
||||
/* Divider node - Gray */
|
||||
.divider-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 8pt;
|
||||
outline: 1px dashed rgba(156, 163, 175, 0.4);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.divider-node:hover {
|
||||
outline: 1px dashed rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
.divider-node.hovered {
|
||||
outline: 1px dashed rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
.divider-node.selected {
|
||||
outline: 2px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Container fallback */
|
||||
.container-node {
|
||||
outline: 1px dashed rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.container-node:hover {
|
||||
outline: 1px dashed rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
|
||||
.container-node.hovered {
|
||||
outline: 1px dashed rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
|
||||
.container-node.selected {
|
||||
outline: 2px solid #3b82f6 !important;
|
||||
}
|
||||
|
||||
.unknown-node {
|
||||
padding: 8pt;
|
||||
background: #fff3cd;
|
||||
border: 1px dashed #ffc107;
|
||||
color: #856404;
|
||||
font-size: 10pt;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Page.svelte
|
||||
*
|
||||
* Represents a single page in the document canvas.
|
||||
* Displays at the correct page size with proper margins.
|
||||
*/
|
||||
import type { PageSettings, Node } from '../../core/model/types'
|
||||
import { getPageDimensions, normalizeSpacing } from '../../core/model/node'
|
||||
import NodeRenderer from './NodeRenderer.svelte'
|
||||
|
||||
// Props using Svelte5 runes
|
||||
let {
|
||||
pageSettings,
|
||||
children,
|
||||
pageNumber = 1,
|
||||
selectedId = null,
|
||||
hoveredId = null,
|
||||
onselect = undefined,
|
||||
onhover = undefined,
|
||||
oninsertbutton = undefined,
|
||||
}: {
|
||||
pageSettings: PageSettings
|
||||
children: Node[]
|
||||
pageNumber?: number
|
||||
selectedId?: string | null
|
||||
hoveredId?: string | null
|
||||
onselect?: ((nodeId: string, event: MouseEvent) => void)
|
||||
onhover?: ((nodeId: string | null, event: MouseEvent) => void)
|
||||
oninsertbutton?: (parentId: string, index: number) => void
|
||||
} = $props()
|
||||
|
||||
// Calculate page dimensions
|
||||
let dimensions = $derived(getPageDimensions(pageSettings.size, pageSettings.orientation))
|
||||
let margins = $derived(normalizeSpacing(pageSettings.margins ?? [40, 40, 40, 40]))
|
||||
|
||||
// Scale factor for display (convert pt to px, assuming 96dpi / 72dpi)
|
||||
const SCALE = 96 / 72
|
||||
|
||||
// Page dimensions in pixels for display
|
||||
let pageWidth = $derived(dimensions.width * SCALE)
|
||||
let pageHeight = $derived(dimensions.height * SCALE)
|
||||
|
||||
// Content area
|
||||
let contentWidth = $derived(dimensions.width - margins.left - margins.right)
|
||||
let contentHeight = $derived(dimensions.height - margins.top - margins.bottom)
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="page"
|
||||
style="
|
||||
width: {pageWidth}px;
|
||||
height: {pageHeight}px;
|
||||
padding: {margins.top * SCALE}px {margins.right * SCALE}px {margins.bottom * SCALE}px {margins.left * SCALE}px;
|
||||
"
|
||||
>
|
||||
<div class="page-content" style="width: {contentWidth * SCALE}px; min-height: {contentHeight * SCALE}px;">
|
||||
{#each children as child (child.id)}
|
||||
<NodeRenderer
|
||||
node={child}
|
||||
depth={0}
|
||||
{selectedId}
|
||||
{hoveredId}
|
||||
{onselect}
|
||||
{onhover}
|
||||
{oninsertbutton}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if pageNumber}
|
||||
<div class="page-number">{pageNumber}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
position: relative;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
margin: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,395 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* JsonDialog.svelte
|
||||
*
|
||||
* Dialog for exporting and importing document JSON.
|
||||
* Allows saving to database and loading from JSON.
|
||||
*/
|
||||
import type { DocumentNode } from '../../core/model/types'
|
||||
|
||||
// Props using Svelte5 runes
|
||||
let {
|
||||
open = false,
|
||||
documentJson = '',
|
||||
onexport,
|
||||
onimport,
|
||||
onclose,
|
||||
}: {
|
||||
open?: boolean
|
||||
documentJson?: string
|
||||
onexport?: () => void
|
||||
onimport?: (json: string) => void
|
||||
onclose?: () => void
|
||||
} = $props()
|
||||
|
||||
// Local state
|
||||
let jsonInput = $state(documentJson)
|
||||
let activeTab = $state<'export' | 'import'>('export')
|
||||
let copySuccess = $state(false)
|
||||
|
||||
// Sync documentJson to local state
|
||||
$effect(() => {
|
||||
if (documentJson) {
|
||||
jsonInput = documentJson
|
||||
}
|
||||
})
|
||||
|
||||
// Handle copy to clipboard
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonInput)
|
||||
copySuccess = true
|
||||
setTimeout(() => {
|
||||
copySuccess = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle download as file
|
||||
function handleDownload() {
|
||||
const blob = new Blob([jsonInput], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `document-${Date.now()}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Handle file upload
|
||||
function handleFileUpload(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string
|
||||
jsonInput = content
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle import
|
||||
function handleImport() {
|
||||
if (jsonInput.trim()) {
|
||||
try {
|
||||
// Validate JSON
|
||||
JSON.parse(jsonInput)
|
||||
onimport?.(jsonInput)
|
||||
} catch (err) {
|
||||
alert('JSON inválido. Por favor, verifica el formato.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close
|
||||
function handleClose() {
|
||||
onclose?.()
|
||||
}
|
||||
|
||||
// Handle backdrop click
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="dialog-overlay" onclick={handleBackdropClick}>
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>Exportar / Importar JSON</h2>
|
||||
<button class="close-btn" onclick={handleClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'export'}
|
||||
onclick={() => activeTab = 'export'}
|
||||
>
|
||||
📤 Exportar
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'import'}
|
||||
onclick={() => activeTab = 'import'}
|
||||
>
|
||||
📥 Importar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'export'}
|
||||
<div class="tab-content">
|
||||
<p class="info-text">
|
||||
Este JSON representa el documento completo y puede guardarse en una base de datos.
|
||||
</p>
|
||||
|
||||
<div class="json-container">
|
||||
<textarea
|
||||
readonly
|
||||
value={jsonInput}
|
||||
rows="15"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions-row">
|
||||
<button class="btn-secondary" onclick={handleCopy}>
|
||||
{copySuccess ? '✓ Copiado!' : '📋 Copiar'}
|
||||
</button>
|
||||
<button class="btn-secondary" onclick={handleDownload}>
|
||||
💾 Descargar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tab-content">
|
||||
<p class="info-text">
|
||||
Carga un documento desde un archivo JSON o pega el JSON directamente.
|
||||
</p>
|
||||
|
||||
<div class="file-upload">
|
||||
<label class="file-label">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onchange={handleFileUpload}
|
||||
/>
|
||||
<span class="file-btn">📁 Seleccionar Archivo</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="json-container">
|
||||
<textarea
|
||||
placeholder="Pega el JSON aquí o carga un archivo..."
|
||||
value={jsonInput}
|
||||
oninput={(e) => jsonInput = e.currentTarget.value}
|
||||
rows="12"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions-row">
|
||||
<button class="btn-primary" onclick={handleImport}>
|
||||
Cargar Documento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
border-color: #93c5fd;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.json-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.json-container textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.json-container textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-label input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.file-label:hover .file-btn {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #3b82f6;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Editor Module
|
||||
*
|
||||
* Export all editor components.
|
||||
*/
|
||||
|
||||
// Canvas components
|
||||
export { default as Canvas } from './canvas/Canvas.svelte'
|
||||
export { default as Page } from './canvas/Page.svelte'
|
||||
export { default as NodeRenderer } from './canvas/NodeRenderer.svelte'
|
||||
|
||||
// Toolbar components
|
||||
export { default as Toolbar } from './toolbar/Toolbar.svelte'
|
||||
export { default as BlockButton } from './toolbar/BlockButton.svelte'
|
||||
|
||||
// Panel components
|
||||
export { default as PropertiesPanel } from './panels/PropertiesPanel.svelte'
|
||||
|
||||
// Dialog components
|
||||
export { default as PageSettingsDialog } from './dialogs/PageSettingsDialog.svelte'
|
||||
export { default as JsonDialog } from './dialogs/JsonDialog.svelte'
|
||||
|
||||
// Main editor
|
||||
export { default as Editor } from './Editor.svelte'
|
||||
@ -0,0 +1,562 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* PropertiesPanel.svelte
|
||||
*
|
||||
* Panel for editing the properties of the selected node.
|
||||
*/
|
||||
import type { Node, NodeStyle } from '../../core/model/types'
|
||||
import { isTextNode, isImageNode, isDividerNode } from '../../core/model/types'
|
||||
import { componentRegistry } from '../../components/registry'
|
||||
import { normalizeSpacing } from '../../core/model/node'
|
||||
|
||||
// Props using Svelte5 runes
|
||||
let {
|
||||
node,
|
||||
onupdatestyle = undefined,
|
||||
onupdateprops = undefined,
|
||||
ondelete = undefined,
|
||||
}: {
|
||||
node: Node | null
|
||||
onupdatestyle?: (style: Partial<NodeStyle>) => void
|
||||
onupdateprops?: (props: Record<string, unknown>) => void
|
||||
ondelete?: () => void
|
||||
} = $props()
|
||||
|
||||
// Get component definition
|
||||
let component = $derived(node ? componentRegistry.get(node.type) : null)
|
||||
|
||||
// Current style values
|
||||
let style = $derived(node?.style ?? {})
|
||||
|
||||
// Spacing values
|
||||
let margin = $derived(normalizeSpacing(style.margin))
|
||||
let padding = $derived(normalizeSpacing(style.padding))
|
||||
|
||||
// Text-specific props - with null safety
|
||||
let textValue = $derived(node && isTextNode(node) ? (node as any).props?.value ?? '' : '')
|
||||
|
||||
// Image-specific props - with null safety
|
||||
let imageSrc = $derived(node && isImageNode(node) ? (node as any).props?.src ?? '' : '')
|
||||
let imageAlt = $derived(node && isImageNode(node) ? (node as any).props?.alt ?? '' : '')
|
||||
|
||||
// Divider-specific props - with null safety
|
||||
let dividerThickness = $derived(node && isDividerNode(node) ? (node as any).props?.thickness ?? 1 : 1)
|
||||
|
||||
// Handle style changes
|
||||
function handleStyleChange(key: keyof NodeStyle, value: any) {
|
||||
onupdatestyle?.({ [key]: value })
|
||||
}
|
||||
|
||||
// Handle margin changes
|
||||
function handleMarginChange(side: 'top' | 'right' | 'bottom' | 'left', value: number) {
|
||||
const newMargin = { ...margin, [side]: value }
|
||||
const arr: [number, number, number, number] = [
|
||||
newMargin.top,
|
||||
newMargin.right,
|
||||
newMargin.bottom,
|
||||
newMargin.left,
|
||||
]
|
||||
onupdatestyle?.({ margin: arr })
|
||||
}
|
||||
|
||||
// Handle padding changes
|
||||
function handlePaddingChange(side: 'top' | 'right' | 'bottom' | 'left', value: number) {
|
||||
const newPadding = { ...padding, [side]: value }
|
||||
const arr: [number, number, number, number] = [
|
||||
newPadding.top,
|
||||
newPadding.right,
|
||||
newPadding.bottom,
|
||||
newPadding.left,
|
||||
]
|
||||
onupdatestyle?.({ padding: arr })
|
||||
}
|
||||
|
||||
// Handle text value change
|
||||
function handleTextChange(value: string) {
|
||||
onupdateprops?.({ value })
|
||||
}
|
||||
|
||||
// Handle image src change
|
||||
function handleImageSrcChange(src: string) {
|
||||
onupdateprops?.({ src })
|
||||
}
|
||||
|
||||
// Handle image alt change
|
||||
function handleImageAltChange(alt: string) {
|
||||
onupdateprops?.({ alt })
|
||||
}
|
||||
|
||||
// Handle divider thickness change
|
||||
function handleDividerThicknessChange(thickness: number) {
|
||||
onupdateprops?.({ thickness })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="properties-panel">
|
||||
{#if node}
|
||||
<div class="panel-header">
|
||||
<h2>{component?.label ?? node.type}</h2>
|
||||
<button class="delete-btn" onclick={() => ondelete?.()} title="Delete">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Text-specific properties -->
|
||||
{#if isTextNode(node)}
|
||||
<div class="property-section">
|
||||
<h3>Content</h3>
|
||||
<label>
|
||||
<span>Text</span>
|
||||
<textarea
|
||||
value={textValue}
|
||||
onchange={(e) => handleTextChange(e.currentTarget.value)}
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Image-specific properties -->
|
||||
{#if isImageNode(node)}
|
||||
<div class="property-section">
|
||||
<h3>Image</h3>
|
||||
<label>
|
||||
<span>Source URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={imageSrc}
|
||||
onchange={(e) => handleImageSrcChange(e.currentTarget.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Alt Text</span>
|
||||
<input
|
||||
type="text"
|
||||
value={imageAlt}
|
||||
onchange={(e) => handleImageAltChange(e.currentTarget.value)}
|
||||
placeholder="Image description"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Divider-specific properties -->
|
||||
{#if isDividerNode(node)}
|
||||
<div class="property-section">
|
||||
<h3>Divider</h3>
|
||||
<label>
|
||||
<span>Thickness</span>
|
||||
<input
|
||||
type="number"
|
||||
value={dividerThickness}
|
||||
onchange={(e) => handleDividerThicknessChange(parseFloat(e.currentTarget.value) || 1)}
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dimensions -->
|
||||
<div class="property-section">
|
||||
<h3>Dimensions</h3>
|
||||
|
||||
<div class="dimension-row">
|
||||
<label class="dimension-label">
|
||||
<span>Width</span>
|
||||
<input
|
||||
type="text"
|
||||
value={style.width ?? 'auto'}
|
||||
onchange={(e) => {
|
||||
const val = e.currentTarget.value
|
||||
const num = parseFloat(val)
|
||||
if (val === 'auto' || val === '') {
|
||||
handleStyleChange('width', 'auto')
|
||||
} else if (!isNaN(num)) {
|
||||
handleStyleChange('width', val.endsWith('%') ? val : num)
|
||||
}
|
||||
}}
|
||||
placeholder="auto"
|
||||
/>
|
||||
</label>
|
||||
<label class="dimension-label">
|
||||
<span>Height</span>
|
||||
<input
|
||||
type="text"
|
||||
value={style.height ?? 'auto'}
|
||||
onchange={(e) => {
|
||||
const val = e.currentTarget.value
|
||||
const num = parseFloat(val)
|
||||
if (val === 'auto' || val === '') {
|
||||
handleStyleChange('height', 'auto')
|
||||
} else if (!isNaN(num)) {
|
||||
handleStyleChange('height', val.endsWith('%') ? val : num)
|
||||
}
|
||||
}}
|
||||
placeholder="auto"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typography -->
|
||||
<div class="property-section">
|
||||
<h3>Typography</h3>
|
||||
|
||||
<label>
|
||||
<span>Font Size</span>
|
||||
<input
|
||||
type="number"
|
||||
value={style.fontSize ?? 12}
|
||||
onchange={(e) => handleStyleChange('fontSize', parseFloat(e.currentTarget.value))}
|
||||
min="6"
|
||||
max="72"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Font Weight</span>
|
||||
<select
|
||||
value={style.fontWeight ?? 'normal'}
|
||||
onchange={(e) => handleStyleChange('fontWeight', e.currentTarget.value)}
|
||||
>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="bold">Bold</option>
|
||||
<option value="100">Thin</option>
|
||||
<option value="300">Light</option>
|
||||
<option value="400">Regular</option>
|
||||
<option value="500">Medium</option>
|
||||
<option value="700">Bold</option>
|
||||
<option value="900">Black</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Text Color</span>
|
||||
<input
|
||||
type="color"
|
||||
value={style.color ?? '#000000'}
|
||||
onchange={(e) => handleStyleChange('color', e.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Text Align</span>
|
||||
<select
|
||||
value={style.textAlign ?? 'left'}
|
||||
onchange={(e) => handleStyleChange('textAlign', e.currentTarget.value)}
|
||||
>
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="justify">Justify</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Spacing -->
|
||||
<div class="property-section">
|
||||
<h3>Margin</h3>
|
||||
<div class="spacing-grid">
|
||||
<input
|
||||
type="number"
|
||||
value={margin.top}
|
||||
onchange={(e) => handleMarginChange('top', parseFloat(e.currentTarget.value) || 0)}
|
||||
placeholder="Top"
|
||||
class="spacing-top"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={margin.right}
|
||||
onchange={(e) => handleMarginChange('right', parseFloat(e.currentTarget.value) || 0)}
|
||||
placeholder="Right"
|
||||
class="spacing-right"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={margin.bottom}
|
||||
onchange={(e) => handleMarginChange('bottom', parseFloat(e.currentTarget.value) || 0)}
|
||||
placeholder="Bottom"
|
||||
class="spacing-bottom"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={margin.left}
|
||||
onchange={(e) => handleMarginChange('left', parseFloat(e.currentTarget.value) || 0)}
|
||||
placeholder="Left"
|
||||
class="spacing-left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="property-section">
|
||||
<h3>Padding</h3>
|
||||
<div class="spacing-grid">
|
||||
<input
|
||||
type="number"
|
||||
value={padding.top}
|
||||
onchange={(e) => handlePaddingChange('top', parseFloat(e.currentTarget.value) || 0)}
|
||||
placeholder="Top"
|
||||
class="spacing-top"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={padding.right}
|
||||
onchange={(e) => handlePaddingChange('right', parseFloat(e.currentTarget.value) || 0)}
|
||||
placeholder="Right"
|
||||
class="spacing-right"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={padding.bottom}
|
||||
onchange={(e) => handlePaddingChange('bottom', parseFloat(e.currentTarget.value) || 0)}
|
||||
placeholder="Bottom"
|
||||
class="spacing-bottom"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={padding.left}
|
||||
onchange={(e) => handlePaddingChange('left', parseFloat(e.currentTarget.value) || 0)}
|
||||
placeholder="Left"
|
||||
class="spacing-left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual -->
|
||||
<div class="property-section">
|
||||
<h3>Visual</h3>
|
||||
|
||||
<label>
|
||||
<span>Background</span>
|
||||
<input
|
||||
type="color"
|
||||
value={style.backgroundColor ?? '#ffffff'}
|
||||
onchange={(e) => handleStyleChange('backgroundColor', e.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Border Color</span>
|
||||
<input
|
||||
type="color"
|
||||
value={style.borderColor ?? '#000000'}
|
||||
onchange={(e) => handleStyleChange('borderColor', e.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Border Width</span>
|
||||
<input
|
||||
type="number"
|
||||
value={style.borderWidth ?? 0}
|
||||
onchange={(e) => handleStyleChange('borderWidth', parseFloat(e.currentTarget.value))}
|
||||
min="0"
|
||||
max="20"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Layout (for containers) -->
|
||||
{#if component?.allowsChildren}
|
||||
<div class="property-section">
|
||||
<h3>Layout</h3>
|
||||
|
||||
<label>
|
||||
<span>Gap</span>
|
||||
<input
|
||||
type="number"
|
||||
value={style.gap ?? 0}
|
||||
onchange={(e) => handleStyleChange('gap', parseFloat(e.currentTarget.value))}
|
||||
min="0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Justify</span>
|
||||
<select
|
||||
value={style.justify ?? 'start'}
|
||||
onchange={(e) => handleStyleChange('justify', e.currentTarget.value)}
|
||||
>
|
||||
<option value="start">Start</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="end">End</option>
|
||||
<option value="between">Space Between</option>
|
||||
<option value="around">Space Around</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Align</span>
|
||||
<select
|
||||
value={style.align ?? 'start'}
|
||||
onchange={(e) => handleStyleChange('align', e.currentTarget.value)}
|
||||
>
|
||||
<option value="start">Start</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="end">End</option>
|
||||
<option value="stretch">Stretch</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Node ID (info only) -->
|
||||
<div class="property-section">
|
||||
<h3>Info</h3>
|
||||
<div class="info-row">
|
||||
<span>ID:</span>
|
||||
<code>{node.id}</code>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Type:</span>
|
||||
<code>{node.type}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="no-selection">
|
||||
<p>Select an element to edit its properties</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.properties-panel {
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
overflow-y: auto;
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.property-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.property-section h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
label span {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
textarea,
|
||||
select {
|
||||
padding: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
padding: 2px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spacing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dimension-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dimension-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.info-row code {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
background: #e5e7eb;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* BlockButton.svelte
|
||||
*
|
||||
* Button component for inserting a specific block type.
|
||||
*/
|
||||
import type { ComponentDefinition } from '../../components/registry'
|
||||
|
||||
// Props using Svelte5 runes
|
||||
let {
|
||||
component,
|
||||
disabled = false,
|
||||
onclick = undefined,
|
||||
}: {
|
||||
component: ComponentDefinition
|
||||
disabled?: boolean
|
||||
onclick?: () => void
|
||||
} = $props()
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled) {
|
||||
onclick?.()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="block-button"
|
||||
class:disabled
|
||||
{disabled}
|
||||
onclick={handleClick}
|
||||
title={component.description}
|
||||
>
|
||||
<span class="icon">{component.icon}</span>
|
||||
<span class="label">{component.label}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.block-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.block-button:hover:not(.disabled) {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.block-button:active:not(.disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.block-button.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
color: #374151;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Toolbar.svelte
|
||||
*
|
||||
* Main toolbar component for inserting blocks and layouts.
|
||||
*/
|
||||
import { componentRegistry } from '../../components/registry'
|
||||
import type { NodeType } from '../../core/model/types'
|
||||
import BlockButton from './BlockButton.svelte'
|
||||
|
||||
// Props using Svelte5 runes
|
||||
let {
|
||||
oninsert = undefined,
|
||||
canInsertType = undefined,
|
||||
}: {
|
||||
oninsert?: (type: NodeType) => void
|
||||
canInsertType?: (type: NodeType) => boolean
|
||||
} = $props()
|
||||
|
||||
// Get components by category
|
||||
let containers = $derived(componentRegistry.getContainers())
|
||||
let primitives = $derived(componentRegistry.getPrimitives())
|
||||
let complex = $derived(componentRegistry.getComplex())
|
||||
|
||||
function handleInsert(type: NodeType) {
|
||||
oninsert?.(type)
|
||||
}
|
||||
|
||||
// Check if a component type can be inserted
|
||||
function isInsertable(type: NodeType): boolean {
|
||||
if (!canInsertType) return true
|
||||
return canInsertType(type)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-section">
|
||||
<h3>Containers</h3>
|
||||
<div class="button-grid">
|
||||
{#each containers as component (component.type)}
|
||||
<BlockButton
|
||||
{component}
|
||||
disabled={!isInsertable(component.type)}
|
||||
onclick={() => handleInsert(component.type)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-section">
|
||||
<h3>Primitives</h3>
|
||||
<div class="button-grid">
|
||||
{#each primitives as component (component.type)}
|
||||
<BlockButton
|
||||
{component}
|
||||
disabled={!isInsertable(component.type)}
|
||||
onclick={() => handleInsert(component.type)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if complex.length > 0}
|
||||
<div class="toolbar-section">
|
||||
<h3>Complex</h3>
|
||||
<div class="button-grid">
|
||||
{#each complex as component (component.type)}
|
||||
<BlockButton
|
||||
{component}
|
||||
disabled={!isInsertable(component.type)}
|
||||
onclick={() => handleInsert(component.type)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow-y: auto;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar-section h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Sample Document
|
||||
*
|
||||
* Example document demonstrating the document model and PDF export.
|
||||
* Uses the structure: Document → Section → Row → Column → Elements
|
||||
*/
|
||||
|
||||
import {
|
||||
createDocument,
|
||||
createSection,
|
||||
createRow,
|
||||
createColumn,
|
||||
createText,
|
||||
createDivider,
|
||||
createBox,
|
||||
createSpacer,
|
||||
} from '../core/model/node'
|
||||
|
||||
import { createJsPdfRenderer } from '../renderer/jspdf'
|
||||
import { renderDocument } from '../renderer/document-renderer'
|
||||
|
||||
/**
|
||||
* Create a sample invoice document
|
||||
* Structure: Document → Section → Row → Column → Elements
|
||||
*/
|
||||
export function createSampleInvoice() {
|
||||
return createDocument(
|
||||
[
|
||||
// Section 1: Header
|
||||
createSection([
|
||||
// Header Row
|
||||
createRow([
|
||||
createColumn([
|
||||
createText('INVOICE', {
|
||||
style: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
], { style: { justify: 'center', margin: [0, 0, 20, 0] } }),
|
||||
|
||||
// Invoice info row
|
||||
createRow([
|
||||
createColumn([
|
||||
createText('From:', {
|
||||
style: { fontWeight: 'bold', fontSize: 10, color: '#666666' },
|
||||
}),
|
||||
createText('Your Company Name', {
|
||||
style: { fontSize: 12 },
|
||||
}),
|
||||
createText('123 Business Street', {
|
||||
style: { fontSize: 10, color: '#666666' },
|
||||
}),
|
||||
createText('City, State 12345', {
|
||||
style: { fontSize: 10, color: '#666666' },
|
||||
}),
|
||||
]),
|
||||
createSpacer(),
|
||||
createColumn([
|
||||
createText('Invoice #: 001', {
|
||||
style: { fontWeight: 'bold', textAlign: 'right' },
|
||||
}),
|
||||
createText('Date: January 15, 2024', {
|
||||
style: { fontSize: 10, textAlign: 'right' },
|
||||
}),
|
||||
createText('Due: February 15, 2024', {
|
||||
style: { fontSize: 10, textAlign: 'right', color: '#666666' },
|
||||
}),
|
||||
], { style: { align: 'end' } }),
|
||||
], { style: { justify: 'between', margin: [0, 0, 30, 0] } }),
|
||||
]),
|
||||
|
||||
// Section 2: Bill To
|
||||
createSection([
|
||||
createRow([
|
||||
createColumn([
|
||||
createText('Bill To:', {
|
||||
style: { fontWeight: 'bold', fontSize: 10, color: '#666666', margin: [0, 0, 5, 0] },
|
||||
}),
|
||||
createText('Client Name', {
|
||||
style: { fontSize: 14, margin: [0, 0, 5, 0] },
|
||||
}),
|
||||
createText('456 Client Avenue', {
|
||||
style: { fontSize: 10, color: '#666666' },
|
||||
}),
|
||||
createText('Client City, State 67890', {
|
||||
style: { fontSize: 10, color: '#666666', margin: [0, 0, 20, 0] },
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Section 3: Items Table
|
||||
createSection([
|
||||
// Table Header
|
||||
createRow([
|
||||
createBox([
|
||||
createRow([
|
||||
createText('Description', { style: { fontWeight: 'bold', fontSize: 10 } }),
|
||||
createText('Qty', { style: { fontWeight: 'bold', fontSize: 10, width: 60, textAlign: 'center' } }),
|
||||
createText('Price', { style: { fontWeight: 'bold', fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
createText('Total', { style: { fontWeight: 'bold', fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
], { style: { gap: 10 } }),
|
||||
], {
|
||||
style: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 10,
|
||||
width: '100%',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
|
||||
// Item 1
|
||||
createRow([
|
||||
createText('Web Development Services', { style: { fontSize: 10 } }),
|
||||
createText('1', { style: { fontSize: 10, width: 60, textAlign: 'center' } }),
|
||||
createText('$3,000.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
createText('$3,000.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
], { style: { gap: 10, padding: [8, 10] } }),
|
||||
|
||||
// Item 2
|
||||
createRow([
|
||||
createText('UI/UX Design', { style: { fontSize: 10 } }),
|
||||
createText('1', { style: { fontSize: 10, width: 60, textAlign: 'center' } }),
|
||||
createText('$1,500.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
createText('$1,500.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
], { style: { gap: 10, padding: [8, 10] } }),
|
||||
|
||||
// Item 3
|
||||
createRow([
|
||||
createText('Hosting (1 year)', { style: { fontSize: 10 } }),
|
||||
createText('1', { style: { fontSize: 10, width: 60, textAlign: 'center' } }),
|
||||
createText('$200.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
createText('$200.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
], { style: { gap: 10, padding: [8, 10] } }),
|
||||
]),
|
||||
|
||||
// Section 4: Totals
|
||||
createSection([
|
||||
createSpacer({ size: 20 }),
|
||||
createDivider({
|
||||
nodeStyle: { margin: [10, 0, 10, 0], borderColor: '#cccccc' },
|
||||
}),
|
||||
createRow([
|
||||
createSpacer(),
|
||||
createColumn([
|
||||
createRow([
|
||||
createText('Subtotal:', { style: { fontSize: 10 } }),
|
||||
createText('$4,700.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
], { style: { gap: 20, margin: [0, 0, 5, 0] } }),
|
||||
createRow([
|
||||
createText('Tax (10%):', { style: { fontSize: 10 } }),
|
||||
createText('$470.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }),
|
||||
], { style: { gap: 20, margin: [0, 0, 5, 0] } }),
|
||||
createRow([
|
||||
createText('Total:', { style: { fontSize: 12, fontWeight: 'bold' } }),
|
||||
createText('$5,170.00', { style: { fontSize: 12, fontWeight: 'bold', width: 80, textAlign: 'right' } }),
|
||||
], { style: { gap: 20 } }),
|
||||
]),
|
||||
], { style: { justify: 'end' } }),
|
||||
]),
|
||||
|
||||
// Section 5: Footer
|
||||
createSection([
|
||||
createSpacer({ size: 40 }),
|
||||
createDivider({
|
||||
nodeStyle: { margin: [0, 0, 10, 0], borderColor: '#cccccc' },
|
||||
}),
|
||||
createRow([
|
||||
createColumn([
|
||||
createText('Thank you for your business!', {
|
||||
style: { fontSize: 10, textAlign: 'center', color: '#666666' },
|
||||
}),
|
||||
]),
|
||||
], { style: { justify: 'center' } }),
|
||||
]),
|
||||
],
|
||||
{
|
||||
pageSettings: {
|
||||
size: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: [40, 50, 40, 50],
|
||||
},
|
||||
metadata: {
|
||||
title: 'Invoice #001',
|
||||
author: 'Your Company',
|
||||
subject: 'Invoice for Web Development Services',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple test document
|
||||
* Structure: Document → Section → Row → Column → Elements
|
||||
*/
|
||||
export function createSimpleTestDocument() {
|
||||
return createDocument(
|
||||
[
|
||||
// Section 1: Title
|
||||
createSection([
|
||||
createRow([
|
||||
createColumn([
|
||||
createText('Hello, PDF Engine!', {
|
||||
style: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
margin: [0, 0, 20, 0],
|
||||
},
|
||||
}),
|
||||
createText('This is a test document demonstrating the PDF engine capabilities.', {
|
||||
style: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
color: '#666666',
|
||||
margin: [0, 0, 30, 0],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
], { style: { justify: 'center' } }),
|
||||
]),
|
||||
|
||||
// Section 2: Two columns
|
||||
createSection([
|
||||
createRow([
|
||||
createColumn([
|
||||
createBox([createText('Column 1', { style: { textAlign: 'center' } })], {
|
||||
style: { backgroundColor: '#e3f2fd', padding: 20 },
|
||||
}),
|
||||
], { style: { width: '50%' } }),
|
||||
createColumn([
|
||||
createBox([createText('Column 2', { style: { textAlign: 'center' } })], {
|
||||
style: { backgroundColor: '#e8f5e9', padding: 20 },
|
||||
}),
|
||||
], { style: { width: '50%' } }),
|
||||
], { style: { justify: 'between', gap: 20 } }),
|
||||
]),
|
||||
],
|
||||
{
|
||||
pageSettings: {
|
||||
size: 'A4',
|
||||
margins: [40, 40, 40, 40],
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty document with proper structure
|
||||
*/
|
||||
export function createEmptyDocument() {
|
||||
return createDocument(
|
||||
[
|
||||
createSection([
|
||||
createRow([
|
||||
createColumn([
|
||||
createText('New Document', {
|
||||
style: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
], { style: { justify: 'center' } }),
|
||||
]),
|
||||
],
|
||||
{
|
||||
pageSettings: {
|
||||
size: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: [40, 40, 40, 40],
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export sample document to PDF and download
|
||||
*/
|
||||
export async function downloadSamplePdf(): Promise<void> {
|
||||
const doc = createSampleInvoice()
|
||||
const renderer = createJsPdfRenderer()
|
||||
|
||||
await renderDocument(doc, { renderer })
|
||||
|
||||
// Get the jsPDF instance and download using save method
|
||||
const pdf = renderer.getOutput()
|
||||
pdf.save('invoice.pdf')
|
||||
}
|
||||
|
||||
/**
|
||||
* Export simple test document to PDF and download
|
||||
*/
|
||||
export async function downloadSimpleTestPdf(): Promise<void> {
|
||||
const doc = createSimpleTestDocument()
|
||||
const renderer = createJsPdfRenderer()
|
||||
|
||||
await renderDocument(doc, { renderer })
|
||||
|
||||
// Get the jsPDF instance and download using save method
|
||||
const pdf = renderer.getOutput()
|
||||
pdf.save('test-document.pdf')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF blob for preview
|
||||
*/
|
||||
export async function generatePdfBlob(): Promise<Blob> {
|
||||
const doc = createSampleInvoice()
|
||||
const renderer = createJsPdfRenderer()
|
||||
|
||||
await renderDocument(doc, { renderer })
|
||||
|
||||
return renderer.toBlob()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF data URL for iframe preview
|
||||
*/
|
||||
export async function generatePdfDataUrl(): Promise<string> {
|
||||
const doc = createSampleInvoice()
|
||||
const renderer = createJsPdfRenderer()
|
||||
|
||||
await renderDocument(doc, { renderer })
|
||||
|
||||
return renderer.toDataURL()
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* PDF Engine Lab - Main Library Export
|
||||
*
|
||||
* This is the main entry point for the PDF Engine library.
|
||||
*/
|
||||
|
||||
// Core module - document model and layout engine
|
||||
export * from './core'
|
||||
|
||||
// Renderer module - PDF rendering
|
||||
export * from './renderer'
|
||||
|
||||
// Components module - component registry
|
||||
export * from './components'
|
||||
|
||||
// Stores module - state management
|
||||
export * from './stores'
|
||||
|
||||
// Editor module - visual editor components
|
||||
export * from './editor'
|
||||
|
||||
// Examples (optional - can be tree-shaken out)
|
||||
export {
|
||||
createSampleInvoice,
|
||||
createSimpleTestDocument,
|
||||
downloadSamplePdf,
|
||||
downloadSimpleTestPdf,
|
||||
generatePdfBlob,
|
||||
generatePdfDataUrl,
|
||||
} from './examples/sample-document'
|
||||
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Document Renderer
|
||||
*
|
||||
* High-level document rendering that combines layout calculation with PDF rendering.
|
||||
*/
|
||||
|
||||
import type { Node, LayoutBox, DocumentNode } from '../core/model/types'
|
||||
import { isDocumentNode, hasChildren, isTextNode, isImageNode, isDividerNode, isBoxNode } from '../core/model/types'
|
||||
import { getPageDimensions, normalizeSpacing } from '../core/model/node'
|
||||
import { calculateLayout } from '../core/layout'
|
||||
import type { Renderer } from './types'
|
||||
import { nodeStyleToTextStyle, nodeStyleToRectStyle } from './types'
|
||||
|
||||
/**
|
||||
* Options for document rendering
|
||||
*/
|
||||
export interface DocumentRenderOptions {
|
||||
/** Renderer instance to use */
|
||||
renderer: Renderer
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a document tree to PDF
|
||||
*/
|
||||
export async function renderDocument(root: Node, options: DocumentRenderOptions): Promise<Renderer> {
|
||||
const { renderer } = options
|
||||
|
||||
// Validate root is a document node
|
||||
if (!isDocumentNode(root)) {
|
||||
throw new Error('Root node must be a document node')
|
||||
}
|
||||
|
||||
// Get page dimensions
|
||||
const pageSettings = root.props.pageSettings
|
||||
const { width: pageWidth, height: pageHeight } = getPageDimensions(
|
||||
pageSettings.size,
|
||||
pageSettings.orientation
|
||||
)
|
||||
const margins = normalizeSpacing(pageSettings.margins ?? [40, 40, 40, 40])
|
||||
|
||||
// Begin document
|
||||
renderer.beginDocument({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
metadata: root.props.metadata,
|
||||
})
|
||||
|
||||
// Calculate layout for each child
|
||||
const contentWidth = pageWidth - margins.left - margins.right
|
||||
const contentHeight = pageHeight - margins.top - margins.bottom
|
||||
|
||||
let currentPage = 1
|
||||
let currentY = margins.top
|
||||
let remainingHeight = contentHeight
|
||||
|
||||
renderer.beginPage(currentPage)
|
||||
|
||||
// Render children
|
||||
const children = root.children ?? []
|
||||
for (const child of children) {
|
||||
const layoutBox = calculateLayout(child, contentWidth, remainingHeight)
|
||||
|
||||
// Check if content fits on current page
|
||||
if (layoutBox.height > remainingHeight && currentY > margins.top) {
|
||||
// Need new page
|
||||
currentPage++
|
||||
renderer.endPage()
|
||||
renderer.beginPage(currentPage)
|
||||
currentY = margins.top
|
||||
remainingHeight = contentHeight
|
||||
}
|
||||
|
||||
// Offset the layout box to correct position
|
||||
const offsetBox: LayoutBox = {
|
||||
...layoutBox,
|
||||
x: margins.left + layoutBox.x,
|
||||
y: currentY + layoutBox.y,
|
||||
}
|
||||
|
||||
// Render the node
|
||||
await renderNode(child, offsetBox, renderer)
|
||||
|
||||
// Update position
|
||||
currentY += layoutBox.height
|
||||
remainingHeight -= layoutBox.height
|
||||
}
|
||||
|
||||
renderer.endPage()
|
||||
renderer.endDocument()
|
||||
|
||||
return renderer
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single node and its children
|
||||
*/
|
||||
async function renderNode(node: Node, box: LayoutBox, renderer: Renderer): Promise<void> {
|
||||
// Render background and border for containers
|
||||
if (hasChildren(node)) {
|
||||
const rectStyle = nodeStyleToRectStyle(node.style)
|
||||
if (rectStyle.backgroundColor || rectStyle.borderColor) {
|
||||
renderer.drawRect(box, rectStyle)
|
||||
}
|
||||
}
|
||||
|
||||
// Render based on node type
|
||||
if (isTextNode(node)) {
|
||||
const textStyle = nodeStyleToTextStyle(node.style)
|
||||
renderer.drawText(node.props.value, box, textStyle)
|
||||
} else if (isImageNode(node)) {
|
||||
await renderer.drawImage(node.props.src, box)
|
||||
} else if (isDividerNode(node)) {
|
||||
const thickness = node.props?.thickness ?? 1
|
||||
const color = node.style?.borderColor ?? '#000000'
|
||||
const style = node.props?.style ?? 'solid'
|
||||
|
||||
if (node.props?.orientation === 'vertical') {
|
||||
renderer.drawLine(
|
||||
{ x: box.x + box.width / 2, y: box.y },
|
||||
{ x: box.x + box.width / 2, y: box.y + box.height },
|
||||
{ color, thickness, style }
|
||||
)
|
||||
} else {
|
||||
renderer.drawLine(
|
||||
{ x: box.x, y: box.y + thickness / 2 },
|
||||
{ x: box.x + box.width, y: box.y + thickness / 2 },
|
||||
{ color, thickness, style }
|
||||
)
|
||||
}
|
||||
} else if (hasChildren(node) && box.children) {
|
||||
// Render children
|
||||
for (let i = 0; i < node.children!.length; i++) {
|
||||
const child = node.children![i]
|
||||
const childBox = box.children[i]
|
||||
await renderNode(child, childBox, renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export document to PDF blob
|
||||
*/
|
||||
export async function exportToBlob(root: Node, renderer: Renderer): Promise<Blob> {
|
||||
await renderDocument(root, { renderer })
|
||||
return renderer.toBlob()
|
||||
}
|
||||
|
||||
/**
|
||||
* Export document to data URL
|
||||
*/
|
||||
export async function exportToDataURL(root: Node, renderer: Renderer): Promise<string> {
|
||||
await renderDocument(root, { renderer })
|
||||
return renderer.toDataURL()
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Renderer Module
|
||||
*
|
||||
* Export renderer types and implementations.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Point,
|
||||
Size,
|
||||
TextStyle,
|
||||
RectStyle,
|
||||
LineStyle,
|
||||
Renderer,
|
||||
RenderContext,
|
||||
} from './types'
|
||||
|
||||
export {
|
||||
nodeStyleToTextStyle,
|
||||
nodeStyleToRectStyle,
|
||||
parseColor,
|
||||
} from './types'
|
||||
|
||||
// jsPDF Renderer
|
||||
export { JsPdfRenderer, createJsPdfRenderer } from './jspdf'
|
||||
|
||||
// Document Renderer
|
||||
export type { DocumentRenderOptions } from './document-renderer'
|
||||
export { renderDocument, exportToBlob, exportToDataURL } from './document-renderer'
|
||||
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* jsPDF Renderer Module
|
||||
*
|
||||
* Export jsPDF renderer implementation.
|
||||
*/
|
||||
|
||||
export { JsPdfRenderer, createJsPdfRenderer } from './renderer'
|
||||
@ -0,0 +1,295 @@
|
||||
/**
|
||||
* jsPDF Renderer
|
||||
*
|
||||
* Implementation of the Renderer interface using jsPDF library.
|
||||
*/
|
||||
|
||||
import { jsPDF } from 'jspdf'
|
||||
import type { LayoutBox } from '../../core/model/types'
|
||||
import type {
|
||||
Renderer,
|
||||
RenderContext,
|
||||
Point,
|
||||
TextStyle,
|
||||
RectStyle,
|
||||
LineStyle
|
||||
} from '../types'
|
||||
import { parseColor } from '../types'
|
||||
|
||||
/**
|
||||
* jsPDF Renderer implementation
|
||||
*/
|
||||
export class JsPdfRenderer implements Renderer {
|
||||
readonly name = 'jspdf'
|
||||
|
||||
private doc: jsPDF | null = null
|
||||
private context: RenderContext | null = null
|
||||
private currentPage: number = 0
|
||||
|
||||
// Default styles
|
||||
private defaultFontSize = 12
|
||||
private defaultFontFamily = 'helvetica'
|
||||
private defaultTextColor = { r: 0, g: 0, b: 0 }
|
||||
|
||||
beginDocument(context: RenderContext): void {
|
||||
this.context = context
|
||||
|
||||
// Create new jsPDF document
|
||||
this.doc = new jsPDF({
|
||||
orientation: context.pageWidth > context.pageHeight ? 'landscape' : 'portrait',
|
||||
unit: 'pt',
|
||||
format: [context.pageWidth, context.pageHeight],
|
||||
})
|
||||
|
||||
// Set metadata if provided using setProperties
|
||||
if (context.metadata) {
|
||||
const props: Record<string, string> = {}
|
||||
if (context.metadata.title) {
|
||||
props.title = context.metadata.title
|
||||
}
|
||||
if (context.metadata.author) {
|
||||
props.author = context.metadata.author
|
||||
}
|
||||
if (context.metadata.subject) {
|
||||
props.subject = context.metadata.subject
|
||||
}
|
||||
if (Object.keys(props).length > 0) {
|
||||
this.doc.setProperties(props)
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPage = 1
|
||||
}
|
||||
|
||||
endDocument(): void {
|
||||
// Document is complete
|
||||
}
|
||||
|
||||
beginPage(pageNumber: number): void {
|
||||
if (!this.doc) return
|
||||
|
||||
if (pageNumber > 1) {
|
||||
this.doc.addPage()
|
||||
}
|
||||
|
||||
this.currentPage = pageNumber
|
||||
}
|
||||
|
||||
endPage(): void {
|
||||
// Page is complete
|
||||
}
|
||||
|
||||
drawText(text: string, box: LayoutBox, style: TextStyle): void {
|
||||
if (!this.doc) return
|
||||
|
||||
const fontSize = style.fontSize ?? this.defaultFontSize
|
||||
const fontFamily = this.mapFontFamily(style.fontFamily ?? this.defaultFontFamily)
|
||||
const fontStyle = this.mapFontStyle(style)
|
||||
const color = parseColor(style.color) ?? this.defaultTextColor
|
||||
|
||||
// Set font
|
||||
this.doc.setFont(fontFamily, fontStyle)
|
||||
this.doc.setFontSize(fontSize)
|
||||
this.doc.setTextColor(color.r, color.g, color.b)
|
||||
|
||||
// Calculate text position
|
||||
let x = box.x
|
||||
const y = box.y + fontSize * 0.8 // Baseline adjustment
|
||||
|
||||
// Handle text alignment
|
||||
if (style.textAlign === 'center') {
|
||||
x = box.x + box.width / 2
|
||||
this.doc.text(text, x, y, { align: 'center' })
|
||||
} else if (style.textAlign === 'right') {
|
||||
x = box.x + box.width
|
||||
this.doc.text(text, x, y, { align: 'right' })
|
||||
} else {
|
||||
this.doc.text(text, x, y, { align: 'left' })
|
||||
}
|
||||
|
||||
// Handle text decoration
|
||||
if (style.textDecoration === 'underline') {
|
||||
const textWidth = this.doc.getTextWidth(text)
|
||||
const underlineY = y + 2
|
||||
this.doc.setDrawColor(color.r, color.g, color.b)
|
||||
this.doc.setLineWidth(0.5)
|
||||
|
||||
if (style.textAlign === 'center') {
|
||||
this.doc.line(x - textWidth / 2, underlineY, x + textWidth / 2, underlineY)
|
||||
} else if (style.textAlign === 'right') {
|
||||
this.doc.line(x - textWidth, underlineY, x, underlineY)
|
||||
} else {
|
||||
this.doc.line(x, underlineY, x + textWidth, underlineY)
|
||||
}
|
||||
} else if (style.textDecoration === 'line-through') {
|
||||
const textWidth = this.doc.getTextWidth(text)
|
||||
const strikeY = y - fontSize * 0.3
|
||||
this.doc.setDrawColor(color.r, color.g, color.b)
|
||||
this.doc.setLineWidth(0.5)
|
||||
|
||||
if (style.textAlign === 'center') {
|
||||
this.doc.line(x - textWidth / 2, strikeY, x + textWidth / 2, strikeY)
|
||||
} else if (style.textAlign === 'right') {
|
||||
this.doc.line(x - textWidth, strikeY, x, strikeY)
|
||||
} else {
|
||||
this.doc.line(x, strikeY, x + textWidth, strikeY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawRect(box: LayoutBox, style: RectStyle): void {
|
||||
if (!this.doc) return
|
||||
|
||||
// Draw background
|
||||
if (style.backgroundColor) {
|
||||
const bgColor = parseColor(style.backgroundColor)
|
||||
if (bgColor) {
|
||||
this.doc.setFillColor(bgColor.r, bgColor.g, bgColor.b)
|
||||
this.doc.rect(box.x, box.y, box.width, box.height, 'F')
|
||||
}
|
||||
}
|
||||
|
||||
// Draw border
|
||||
if (style.borderColor && style.borderWidth) {
|
||||
const borderColor = parseColor(style.borderColor)
|
||||
if (borderColor) {
|
||||
this.doc.setDrawColor(borderColor.r, borderColor.g, borderColor.b)
|
||||
this.doc.setLineWidth(style.borderWidth)
|
||||
|
||||
// Handle border radius (simplified - jsPDF doesn't support rounded rects natively)
|
||||
if (style.borderRadius && style.borderRadius > 0) {
|
||||
// For now, draw a regular rect. Rounded rect would need custom implementation
|
||||
this.doc.roundedRect(box.x, box.y, box.width, box.height, style.borderRadius, style.borderRadius, 'S')
|
||||
} else {
|
||||
this.doc.rect(box.x, box.y, box.width, box.height, 'S')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async drawImage(src: string, box: LayoutBox): Promise<void> {
|
||||
if (!this.doc) return
|
||||
|
||||
try {
|
||||
// Handle different image sources
|
||||
if (src.startsWith('data:')) {
|
||||
// Base64 data URL
|
||||
const imageType = this.getImageTypeFromDataUrl(src)
|
||||
this.doc.addImage(src, imageType, box.x, box.y, box.width, box.height)
|
||||
} else if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
// Remote URL - need to fetch and convert to base64
|
||||
const response = await fetch(src)
|
||||
const blob = await response.blob()
|
||||
const base64 = await this.blobToBase64(blob)
|
||||
const imageType = this.getImageTypeFromMimeType(blob.type)
|
||||
this.doc.addImage(base64, imageType, box.x, box.y, box.width, box.height)
|
||||
} else {
|
||||
// Assume it's a base64 string or local path
|
||||
this.doc.addImage(src, 'UNKNOWN', box.x, box.y, box.width, box.height)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load image:', error)
|
||||
}
|
||||
}
|
||||
|
||||
drawLine(from: Point, to: Point, style: LineStyle): void {
|
||||
if (!this.doc) return
|
||||
|
||||
const color = parseColor(style.color) ?? { r: 0, g: 0, b: 0 }
|
||||
const thickness = style.thickness ?? 1
|
||||
|
||||
this.doc.setDrawColor(color.r, color.g, color.b)
|
||||
this.doc.setLineWidth(thickness)
|
||||
|
||||
// Handle line style
|
||||
if (style.style === 'dashed') {
|
||||
this.doc.setLineDashPattern([3, 3], 0)
|
||||
} else if (style.style === 'dotted') {
|
||||
this.doc.setLineDashPattern([1, 2], 0)
|
||||
} else {
|
||||
this.doc.setLineDashPattern([], 0)
|
||||
}
|
||||
|
||||
this.doc.line(from.x, from.y, to.x, to.y)
|
||||
}
|
||||
|
||||
getOutput(): jsPDF {
|
||||
if (!this.doc) {
|
||||
throw new Error('Document not initialized. Call beginDocument first.')
|
||||
}
|
||||
return this.doc
|
||||
}
|
||||
|
||||
async toBlob(): Promise<Blob> {
|
||||
const doc = this.getOutput()
|
||||
return doc.output('blob')
|
||||
}
|
||||
|
||||
toDataURL(): string {
|
||||
const doc = this.getOutput()
|
||||
return doc.output('datauristring')
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private mapFontFamily(family: string): string {
|
||||
const fontMap: Record<string, string> = {
|
||||
'helvetica': 'helvetica',
|
||||
'arial': 'helvetica',
|
||||
'sans-serif': 'helvetica',
|
||||
'times': 'times',
|
||||
'times new roman': 'times',
|
||||
'serif': 'times',
|
||||
'courier': 'courier',
|
||||
'courier new': 'courier',
|
||||
'monospace': 'courier',
|
||||
}
|
||||
|
||||
return fontMap[family.toLowerCase()] ?? 'helvetica'
|
||||
}
|
||||
|
||||
private mapFontStyle(style: TextStyle): string {
|
||||
const isBold = style.fontWeight === 'bold' || style.fontWeight === 700 || style.fontWeight === '700'
|
||||
const isItalic = style.fontStyle === 'italic'
|
||||
|
||||
if (isBold && isItalic) return 'bolditalic'
|
||||
if (isBold) return 'bold'
|
||||
if (isItalic) return 'italic'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
private getImageTypeFromDataUrl(dataUrl: string): string {
|
||||
const match = dataUrl.match(/^data:image\/(\w+);/)
|
||||
if (match) {
|
||||
return match[1].toUpperCase()
|
||||
}
|
||||
return 'JPEG'
|
||||
}
|
||||
|
||||
private getImageTypeFromMimeType(mimeType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'image/jpeg': 'JPEG',
|
||||
'image/jpg': 'JPEG',
|
||||
'image/png': 'PNG',
|
||||
'image/gif': 'GIF',
|
||||
'image/webp': 'WEBP',
|
||||
}
|
||||
return typeMap[mimeType.toLowerCase()] ?? 'JPEG'
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new jsPDF renderer
|
||||
*/
|
||||
export function createJsPdfRenderer(): JsPdfRenderer {
|
||||
return new JsPdfRenderer()
|
||||
}
|
||||
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Renderer Types
|
||||
*
|
||||
* Abstract interfaces for PDF renderers. This allows the engine to be
|
||||
* decoupled from any specific PDF library.
|
||||
*/
|
||||
|
||||
import type { NodeStyle, LayoutBox } from '../core/model/types'
|
||||
|
||||
// ============================================================================
|
||||
// Point and Size Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Style Types for Rendering
|
||||
// ============================================================================
|
||||
|
||||
export interface TextStyle {
|
||||
fontFamily?: string
|
||||
fontSize?: number
|
||||
fontWeight?: string | number
|
||||
fontStyle?: 'normal' | 'italic'
|
||||
color?: string
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justify'
|
||||
lineHeight?: number
|
||||
textDecoration?: 'none' | 'underline' | 'line-through'
|
||||
}
|
||||
|
||||
export interface RectStyle {
|
||||
backgroundColor?: string
|
||||
borderColor?: string
|
||||
borderWidth?: number
|
||||
borderRadius?: number
|
||||
}
|
||||
|
||||
export interface LineStyle {
|
||||
color?: string
|
||||
thickness?: number
|
||||
style?: 'solid' | 'dashed' | 'dotted'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Renderer Interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Abstract renderer interface
|
||||
*
|
||||
* Implementations can target different backends (jsPDF, PDFKit, Canvas, etc.)
|
||||
*/
|
||||
export interface Renderer {
|
||||
/** Renderer name for identification */
|
||||
readonly name: string
|
||||
|
||||
// Document lifecycle
|
||||
beginDocument(context: RenderContext): void
|
||||
endDocument(): void
|
||||
|
||||
// Page lifecycle
|
||||
beginPage(pageNumber: number): void
|
||||
endPage(): void
|
||||
|
||||
// Drawing primitives
|
||||
drawText(text: string, box: LayoutBox, style: TextStyle): void
|
||||
drawRect(box: LayoutBox, style: RectStyle): void
|
||||
drawImage(src: string, box: LayoutBox): Promise<void>
|
||||
drawLine(from: Point, to: Point, style: LineStyle): void
|
||||
|
||||
// Output
|
||||
getOutput(): unknown
|
||||
toBlob(): Promise<Blob>
|
||||
toDataURL(): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to the renderer at document start
|
||||
*/
|
||||
export interface RenderContext {
|
||||
/** Page width in points */
|
||||
pageWidth: number
|
||||
|
||||
/** Page height in points */
|
||||
pageHeight: number
|
||||
|
||||
/** Document metadata */
|
||||
metadata?: {
|
||||
title?: string
|
||||
author?: string
|
||||
subject?: string
|
||||
keywords?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Renderer Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert node style to text style for rendering
|
||||
*/
|
||||
export function nodeStyleToTextStyle(style: NodeStyle | undefined): TextStyle {
|
||||
if (!style) return {}
|
||||
|
||||
return {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: style.fontWeight,
|
||||
fontStyle: style.fontStyle,
|
||||
color: style.color,
|
||||
textAlign: style.textAlign,
|
||||
lineHeight: style.lineHeight,
|
||||
textDecoration: style.textDecoration,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert node style to rect style for rendering
|
||||
*/
|
||||
export function nodeStyleToRectStyle(style: NodeStyle | undefined): RectStyle {
|
||||
if (!style) return {}
|
||||
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderColor: style.borderColor,
|
||||
borderWidth: style.borderWidth,
|
||||
borderRadius: style.borderRadius,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse color string to RGB values
|
||||
*/
|
||||
export function parseColor(color: string | undefined): { r: number; g: number; b: number } | null {
|
||||
if (!color) return null
|
||||
|
||||
// Hex color
|
||||
if (color.startsWith('#')) {
|
||||
const hex = color.slice(1)
|
||||
if (hex.length === 3) {
|
||||
return {
|
||||
r: parseInt(hex[0] + hex[0], 16),
|
||||
g: parseInt(hex[1] + hex[1], 16),
|
||||
b: parseInt(hex[2] + hex[2], 16),
|
||||
}
|
||||
}
|
||||
if (hex.length === 6) {
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RGB color
|
||||
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
|
||||
if (rgbMatch) {
|
||||
return {
|
||||
r: parseInt(rgbMatch[1], 10),
|
||||
g: parseInt(rgbMatch[2], 10),
|
||||
b: parseInt(rgbMatch[3], 10),
|
||||
}
|
||||
}
|
||||
|
||||
// Named colors (basic support)
|
||||
const namedColors: Record<string, { r: number; g: number; b: number }> = {
|
||||
black: { r: 0, g: 0, b: 0 },
|
||||
white: { r: 255, g: 255, b: 255 },
|
||||
red: { r: 255, g: 0, b: 0 },
|
||||
green: { r: 0, g: 128, b: 0 },
|
||||
blue: { r: 0, g: 0, b: 255 },
|
||||
yellow: { r: 255, g: 255, b: 0 },
|
||||
cyan: { r: 0, g: 255, b: 255 },
|
||||
magenta: { r: 255, g: 0, b: 255 },
|
||||
gray: { r: 128, g: 128, b: 128 },
|
||||
grey: { r: 128, g: 128, b: 128 },
|
||||
}
|
||||
|
||||
return namedColors[color.toLowerCase()] ?? null
|
||||
}
|
||||
@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Document Store
|
||||
*
|
||||
* Svelte store for managing document state with reactive updates.
|
||||
*/
|
||||
|
||||
import { writable, derived, get } from 'svelte/store'
|
||||
import type { Node, DocumentNode, PageSettings } from '../core/model/types'
|
||||
import { isDocumentNode } from '../core/model/types'
|
||||
import {
|
||||
createDocument,
|
||||
createSection,
|
||||
createRow,
|
||||
createColumn,
|
||||
createText,
|
||||
} from '../core/model/node'
|
||||
import {
|
||||
findNodeById,
|
||||
findParent,
|
||||
insertNode as insertNodeToTree,
|
||||
removeNode as removeNodeFromTree,
|
||||
updateNode as updateNodeInTree,
|
||||
updateNodeStyle as updateNodeStyleInTree,
|
||||
deepCloneTree,
|
||||
getNodeIndex,
|
||||
} from '../core/model/tree'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface DocumentState {
|
||||
/** The document tree root */
|
||||
root: DocumentNode
|
||||
|
||||
/** Whether there are unsaved changes */
|
||||
dirty: boolean
|
||||
|
||||
/** Document file name (for display) */
|
||||
fileName: string | null
|
||||
}
|
||||
|
||||
export interface DocumentActions {
|
||||
/** Set a new document root */
|
||||
setRoot: (root: DocumentNode) => void
|
||||
|
||||
/** Update page settings */
|
||||
setPageSettings: (settings: Partial<PageSettings>) => void
|
||||
|
||||
/** Insert a node into the tree */
|
||||
insertNode: (parentId: string, node: Node, index?: number) => void
|
||||
|
||||
/** Remove a node from the tree */
|
||||
removeNode: (nodeId: string) => void
|
||||
|
||||
/** Move a node to a new parent */
|
||||
moveNode: (nodeId: string, newParentId: string, newIndex?: number) => void
|
||||
|
||||
/** Update node properties */
|
||||
updateNode: (nodeId: string, updates: Partial<Node>) => void
|
||||
|
||||
/** Update node style */
|
||||
updateNodeStyle: (nodeId: string, style: Record<string, unknown>) => void
|
||||
|
||||
/** Update node props */
|
||||
updateNodeProps: (nodeId: string, props: Record<string, unknown>) => void
|
||||
|
||||
/** Mark document as saved */
|
||||
markSaved: () => void
|
||||
|
||||
/** Reset to a new empty document */
|
||||
newDocument: () => void
|
||||
|
||||
/** Load a document from JSON */
|
||||
loadDocument: (json: string) => void
|
||||
|
||||
/** Export document to JSON */
|
||||
exportDocument: () => string
|
||||
|
||||
/** Get a node by ID */
|
||||
getNode: (nodeId: string) => Node | null
|
||||
|
||||
/** Get parent of a node */
|
||||
getParent: (nodeId: string) => Node | null
|
||||
|
||||
/** Get index of a node in its parent */
|
||||
getNodeIndex: (nodeId: string) => number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Create Empty Document
|
||||
// ============================================================================
|
||||
|
||||
function createEmptyDocument(): DocumentNode {
|
||||
return createDocument([
|
||||
createSection([
|
||||
createRow([
|
||||
createColumn([
|
||||
createText('Start building your document...', {
|
||||
style: {
|
||||
fontSize: 16,
|
||||
color: '#999999',
|
||||
textAlign: 'center',
|
||||
margin: [100, 0],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
], {
|
||||
pageSettings: {
|
||||
size: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: [40, 40, 40, 40],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document Store Factory
|
||||
// ============================================================================
|
||||
|
||||
export function createDocumentStore(initialDoc?: DocumentNode) {
|
||||
const initialState: DocumentState = {
|
||||
root: initialDoc ?? createEmptyDocument(),
|
||||
dirty: false,
|
||||
fileName: null,
|
||||
}
|
||||
|
||||
const { subscribe, set, update } = writable<DocumentState>(initialState)
|
||||
|
||||
const actions: DocumentActions = {
|
||||
setRoot: (root: DocumentNode) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
root,
|
||||
dirty: true,
|
||||
}))
|
||||
},
|
||||
|
||||
setPageSettings: (settings: Partial<PageSettings>) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
root: {
|
||||
...state.root,
|
||||
props: {
|
||||
...state.root.props,
|
||||
pageSettings: {
|
||||
...state.root.props.pageSettings,
|
||||
...settings,
|
||||
},
|
||||
},
|
||||
},
|
||||
dirty: true,
|
||||
}))
|
||||
},
|
||||
|
||||
insertNode: (parentId: string, node: Node, index?: number) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
root: insertNodeToTree(state.root, parentId, node, index) as DocumentNode,
|
||||
dirty: true,
|
||||
}))
|
||||
},
|
||||
|
||||
removeNode: (nodeId: string) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
root: removeNodeFromTree(state.root, nodeId) as DocumentNode,
|
||||
dirty: true,
|
||||
}))
|
||||
},
|
||||
|
||||
moveNode: (nodeId: string, newParentId: string, newIndex?: number) => {
|
||||
update((state) => {
|
||||
// Get the node to move
|
||||
const nodeToMove = findNodeById(state.root, nodeId)
|
||||
if (!nodeToMove) return state
|
||||
|
||||
// Remove from current location
|
||||
let newTree = removeNodeFromTree(state.root, nodeId)
|
||||
|
||||
// Insert at new location
|
||||
newTree = insertNodeToTree(newTree, newParentId, nodeToMove, newIndex)
|
||||
|
||||
return {
|
||||
...state,
|
||||
root: newTree as DocumentNode,
|
||||
dirty: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
updateNode: (nodeId: string, updates: Partial<Node>) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
root: updateNodeInTree(state.root, nodeId, updates) as DocumentNode,
|
||||
dirty: true,
|
||||
}))
|
||||
},
|
||||
|
||||
updateNodeStyle: (nodeId: string, style: Record<string, unknown>) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
root: updateNodeStyleInTree(state.root, nodeId, style) as DocumentNode,
|
||||
dirty: true,
|
||||
}))
|
||||
},
|
||||
|
||||
updateNodeProps: (nodeId: string, props: Record<string, unknown>) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
root: updateNodeInTree(state.root, nodeId, {
|
||||
props: { ...findNodeById(state.root, nodeId)?.props, ...props },
|
||||
}) as DocumentNode,
|
||||
dirty: true,
|
||||
}))
|
||||
},
|
||||
|
||||
markSaved: () => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
dirty: false,
|
||||
}))
|
||||
},
|
||||
|
||||
newDocument: () => {
|
||||
set({
|
||||
root: createEmptyDocument(),
|
||||
dirty: false,
|
||||
fileName: null,
|
||||
})
|
||||
},
|
||||
|
||||
loadDocument: (json: string) => {
|
||||
try {
|
||||
const root = JSON.parse(json) as DocumentNode
|
||||
if (!isDocumentNode(root)) {
|
||||
throw new Error('Invalid document format')
|
||||
}
|
||||
set({
|
||||
root,
|
||||
dirty: false,
|
||||
fileName: null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load document:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
exportDocument: () => {
|
||||
const state = get({ subscribe })
|
||||
return JSON.stringify(state.root, null, 2)
|
||||
},
|
||||
|
||||
getNode: (nodeId: string) => {
|
||||
const state = get({ subscribe })
|
||||
return findNodeById(state.root, nodeId)
|
||||
},
|
||||
|
||||
getParent: (nodeId: string) => {
|
||||
const state = get({ subscribe })
|
||||
return findParent(state.root, nodeId)
|
||||
},
|
||||
|
||||
getNodeIndex: (nodeId: string) => {
|
||||
const state = get({ subscribe })
|
||||
const parent = findParent(state.root, nodeId)
|
||||
if (!parent || !('children' in parent)) return -1
|
||||
return getNodeIndex(parent, nodeId)
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Derived Stores
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create derived store for page settings
|
||||
*/
|
||||
export function createPageSettingsStore(documentStore: ReturnType<typeof createDocumentStore>) {
|
||||
return derived(documentStore, ($doc) => $doc.root.props.pageSettings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create derived store for document dirty state
|
||||
*/
|
||||
export function createDirtyStore(documentStore: ReturnType<typeof createDocumentStore>) {
|
||||
return derived(documentStore, ($doc) => $doc.dirty)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Store Instance
|
||||
// ============================================================================
|
||||
|
||||
export const documentStore = createDocumentStore()
|
||||
export const pageSettings = createPageSettingsStore(documentStore)
|
||||
export const isDirty = createDirtyStore(documentStore)
|
||||
@ -0,0 +1,299 @@
|
||||
/**
|
||||
* History Store
|
||||
*
|
||||
* Svelte store for managing undo/redo history with command pattern.
|
||||
*/
|
||||
|
||||
import { writable, derived, get } from 'svelte/store'
|
||||
import type { DocumentNode } from '../core/model/types'
|
||||
import { deepCloneTree } from '../core/model/tree'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A snapshot of document state for history
|
||||
*/
|
||||
export interface HistorySnapshot {
|
||||
/** Unique identifier for this snapshot */
|
||||
id: string
|
||||
|
||||
/** The document root at this point in history */
|
||||
root: DocumentNode
|
||||
|
||||
/** Human-readable description of the change */
|
||||
description: string
|
||||
|
||||
/** Timestamp when this snapshot was created */
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
/** Stack of past states (for undo) */
|
||||
past: HistorySnapshot[]
|
||||
|
||||
/** Stack of future states (for redo) */
|
||||
future: HistorySnapshot[]
|
||||
|
||||
/** Maximum number of history entries to keep */
|
||||
maxHistory: number
|
||||
|
||||
/** Whether history is currently being updated (to prevent loops) */
|
||||
isUpdating: boolean
|
||||
}
|
||||
|
||||
export interface HistoryActions {
|
||||
/** Push a new state onto the history stack */
|
||||
push: (root: DocumentNode, description: string) => void
|
||||
|
||||
/** Undo the last change */
|
||||
undo: () => DocumentNode | null
|
||||
|
||||
/** Redo a previously undone change */
|
||||
redo: () => DocumentNode | null
|
||||
|
||||
/** Clear all history */
|
||||
clear: () => void
|
||||
|
||||
/** Check if undo is available */
|
||||
canUndo: () => boolean
|
||||
|
||||
/** Check if redo is available */
|
||||
canRedo: () => boolean
|
||||
|
||||
/** Get the description of the next undo action */
|
||||
getUndoDescription: () => string | null
|
||||
|
||||
/** Get the description of the next redo action */
|
||||
getRedoDescription: () => string | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
let snapshotIdCounter = 0
|
||||
|
||||
function createSnapshot(root: DocumentNode, description: string): HistorySnapshot {
|
||||
snapshotIdCounter++
|
||||
return {
|
||||
id: `snapshot_${snapshotIdCounter}`,
|
||||
root: deepCloneTree(root) as DocumentNode,
|
||||
description,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// History Store Factory
|
||||
// ============================================================================
|
||||
|
||||
export function createHistoryStore(maxHistory: number = 50) {
|
||||
const initialState: HistoryState = {
|
||||
past: [],
|
||||
future: [],
|
||||
maxHistory,
|
||||
isUpdating: false,
|
||||
}
|
||||
|
||||
const { subscribe, set, update } = writable<HistoryState>(initialState)
|
||||
|
||||
const actions: HistoryActions = {
|
||||
push: (root: DocumentNode, description: string) => {
|
||||
update((state) => {
|
||||
// Don't push if we're in the middle of undo/redo
|
||||
if (state.isUpdating) return state
|
||||
|
||||
const snapshot = createSnapshot(root, description)
|
||||
const newPast = [...state.past, snapshot]
|
||||
|
||||
// Trim history if it exceeds max
|
||||
if (newPast.length > state.maxHistory) {
|
||||
newPast.shift()
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
past: newPast,
|
||||
future: [], // Clear future when new action is taken
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
let result: DocumentNode | null = null
|
||||
|
||||
update((state) => {
|
||||
if (state.past.length === 0) return state
|
||||
|
||||
const currentPast = [...state.past]
|
||||
const previous = currentPast.pop()!
|
||||
|
||||
result = previous.root
|
||||
|
||||
return {
|
||||
...state,
|
||||
past: currentPast,
|
||||
future: [{ ...previous, root: deepCloneTree(previous.root) as DocumentNode }, ...state.future],
|
||||
isUpdating: true,
|
||||
}
|
||||
})
|
||||
|
||||
// Reset updating flag after a tick
|
||||
setTimeout(() => {
|
||||
update((state) => ({ ...state, isUpdating: false }))
|
||||
}, 0)
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
let result: DocumentNode | null = null
|
||||
|
||||
update((state) => {
|
||||
if (state.future.length === 0) return state
|
||||
|
||||
const newFuture = [...state.future]
|
||||
const next = newFuture.shift()!
|
||||
|
||||
result = next.root
|
||||
|
||||
return {
|
||||
...state,
|
||||
past: [...state.past, { ...next, root: deepCloneTree(next.root) as DocumentNode }],
|
||||
future: newFuture,
|
||||
isUpdating: true,
|
||||
}
|
||||
})
|
||||
|
||||
// Reset updating flag after a tick
|
||||
setTimeout(() => {
|
||||
update((state) => ({ ...state, isUpdating: false }))
|
||||
}, 0)
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
set({
|
||||
past: [],
|
||||
future: [],
|
||||
maxHistory,
|
||||
isUpdating: false,
|
||||
})
|
||||
},
|
||||
|
||||
canUndo: () => {
|
||||
const state = get({ subscribe })
|
||||
return state.past.length > 0
|
||||
},
|
||||
|
||||
canRedo: () => {
|
||||
const state = get({ subscribe })
|
||||
return state.future.length > 0
|
||||
},
|
||||
|
||||
getUndoDescription: () => {
|
||||
const state = get({ subscribe })
|
||||
if (state.past.length === 0) return null
|
||||
return state.past[state.past.length - 1].description
|
||||
},
|
||||
|
||||
getRedoDescription: () => {
|
||||
const state = get({ subscribe })
|
||||
if (state.future.length === 0) return null
|
||||
return state.future[0].description
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Derived Stores
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create derived store for undo availability
|
||||
*/
|
||||
export function createCanUndoStore(historyStore: ReturnType<typeof createHistoryStore>) {
|
||||
return derived(historyStore, ($history) => $history.past.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create derived store for redo availability
|
||||
*/
|
||||
export function createCanRedoStore(historyStore: ReturnType<typeof createHistoryStore>) {
|
||||
return derived(historyStore, ($history) => $history.future.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create derived store for history count
|
||||
*/
|
||||
export function createHistoryCountStore(historyStore: ReturnType<typeof createHistoryStore>) {
|
||||
return derived(historyStore, ($history) => ({
|
||||
past: $history.past.length,
|
||||
future: $history.future.length,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Store Instance
|
||||
// ============================================================================
|
||||
|
||||
export const historyStore = createHistoryStore()
|
||||
export const canUndo = createCanUndoStore(historyStore)
|
||||
export const canRedo = createCanRedoStore(historyStore)
|
||||
export const historyCount = createHistoryCountStore(historyStore)
|
||||
|
||||
// ============================================================================
|
||||
// History-Aware Document Store
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a document store with history support
|
||||
* This wraps the document store to automatically track changes
|
||||
*/
|
||||
export function createHistoryAwareDocumentStore(
|
||||
documentStore: ReturnType<typeof import('./document').createDocumentStore>,
|
||||
historyStoreInstance: ReturnType<typeof createHistoryStore>
|
||||
) {
|
||||
// Track previous state for history
|
||||
let previousRoot: DocumentNode | null = null
|
||||
|
||||
// Subscribe to document changes
|
||||
documentStore.subscribe((state) => {
|
||||
const historyState = get(historyStoreInstance)
|
||||
if (previousRoot !== null && !historyState.isUpdating) {
|
||||
// Check if root actually changed
|
||||
if (JSON.stringify(state.root) !== JSON.stringify(previousRoot)) {
|
||||
// Push to history with generic description
|
||||
// In a real app, you'd want more descriptive labels
|
||||
historyStoreInstance.push(state.root, 'Document changed')
|
||||
}
|
||||
}
|
||||
previousRoot = deepCloneTree(state.root) as DocumentNode
|
||||
})
|
||||
|
||||
return {
|
||||
undo: () => {
|
||||
const previousState = historyStoreInstance.undo()
|
||||
if (previousState) {
|
||||
documentStore.setRoot(previousState)
|
||||
}
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const nextState = historyStoreInstance.redo()
|
||||
if (nextState) {
|
||||
documentStore.setRoot(nextState)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Stores Module
|
||||
*
|
||||
* Export all Svelte stores for state management.
|
||||
*/
|
||||
|
||||
// Document Store
|
||||
export type { DocumentState, DocumentActions } from './document'
|
||||
export {
|
||||
createDocumentStore,
|
||||
createPageSettingsStore,
|
||||
createDirtyStore,
|
||||
documentStore,
|
||||
pageSettings,
|
||||
isDirty,
|
||||
} from './document'
|
||||
|
||||
// Selection Store
|
||||
export type { SelectionState, SelectionActions } from './selection'
|
||||
export {
|
||||
createSelectionStore,
|
||||
createHasSelectionStore,
|
||||
createSelectionCountStore,
|
||||
selectionStore,
|
||||
hasSelection,
|
||||
selectionCount,
|
||||
} from './selection'
|
||||
|
||||
// History Store
|
||||
export type { HistorySnapshot, HistoryState, HistoryActions } from './history'
|
||||
export {
|
||||
createHistoryStore,
|
||||
createCanUndoStore,
|
||||
createCanRedoStore,
|
||||
createHistoryCountStore,
|
||||
createHistoryAwareDocumentStore,
|
||||
historyStore,
|
||||
canUndo,
|
||||
canRedo,
|
||||
historyCount,
|
||||
} from './history'
|
||||
@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Selection Store
|
||||
*
|
||||
* Svelte store for managing node selection state in the editor.
|
||||
*/
|
||||
|
||||
import { writable, derived, get } from 'svelte/store'
|
||||
import type { Node } from '../core/model/types'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SelectionState {
|
||||
/** Currently selected node ID */
|
||||
selectedId: string | null
|
||||
|
||||
/** Currently hovered node ID */
|
||||
hoveredId: string | null
|
||||
|
||||
/** Whether multi-select mode is active */
|
||||
multiSelect: boolean
|
||||
|
||||
/** IDs of all selected nodes (for multi-select) */
|
||||
selectedIds: Set<string>
|
||||
}
|
||||
|
||||
export interface SelectionActions {
|
||||
/** Select a node */
|
||||
select: (nodeId: string | null) => void
|
||||
|
||||
/** Add to selection (multi-select) */
|
||||
addToSelection: (nodeId: string) => void
|
||||
|
||||
/** Remove from selection (multi-select) */
|
||||
removeFromSelection: (nodeId: string) => void
|
||||
|
||||
/** Toggle selection state */
|
||||
toggleSelection: (nodeId: string) => void
|
||||
|
||||
/** Clear all selections */
|
||||
clearSelection: () => void
|
||||
|
||||
/** Set hovered node */
|
||||
setHovered: (nodeId: string | null) => void
|
||||
|
||||
/** Enable/disable multi-select mode */
|
||||
setMultiSelect: (enabled: boolean) => void
|
||||
|
||||
/** Check if a node is selected */
|
||||
isSelected: (nodeId: string) => boolean
|
||||
|
||||
/** Check if a node is hovered */
|
||||
isHovered: (nodeId: string) => boolean
|
||||
|
||||
/** Get all selected IDs as array */
|
||||
getSelectedIds: () => string[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Selection Store Factory
|
||||
// ============================================================================
|
||||
|
||||
export function createSelectionStore() {
|
||||
const initialState: SelectionState = {
|
||||
selectedId: null,
|
||||
hoveredId: null,
|
||||
multiSelect: false,
|
||||
selectedIds: new Set(),
|
||||
}
|
||||
|
||||
const { subscribe, set, update } = writable<SelectionState>(initialState)
|
||||
|
||||
const actions: SelectionActions = {
|
||||
select: (nodeId: string | null) => {
|
||||
update((state) => {
|
||||
if (nodeId === null) {
|
||||
return {
|
||||
...state,
|
||||
selectedId: null,
|
||||
selectedIds: new Set(),
|
||||
}
|
||||
}
|
||||
|
||||
// In multi-select mode, add to selection
|
||||
if (state.multiSelect && state.selectedId !== null) {
|
||||
const newSelectedIds = new Set(state.selectedIds)
|
||||
newSelectedIds.add(state.selectedId)
|
||||
newSelectedIds.add(nodeId)
|
||||
return {
|
||||
...state,
|
||||
selectedId: nodeId,
|
||||
selectedIds: newSelectedIds,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedId: nodeId,
|
||||
selectedIds: new Set([nodeId]),
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
addToSelection: (nodeId: string) => {
|
||||
update((state) => {
|
||||
const newSelectedIds = new Set(state.selectedIds)
|
||||
newSelectedIds.add(nodeId)
|
||||
return {
|
||||
...state,
|
||||
selectedId: nodeId,
|
||||
selectedIds: newSelectedIds,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
removeFromSelection: (nodeId: string) => {
|
||||
update((state) => {
|
||||
const newSelectedIds = new Set(state.selectedIds)
|
||||
newSelectedIds.delete(nodeId)
|
||||
const remaining = Array.from(newSelectedIds)
|
||||
return {
|
||||
...state,
|
||||
selectedId: remaining.length > 0 ? remaining[remaining.length - 1] : null,
|
||||
selectedIds: newSelectedIds,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
toggleSelection: (nodeId: string) => {
|
||||
update((state) => {
|
||||
const newSelectedIds = new Set(state.selectedIds)
|
||||
|
||||
if (newSelectedIds.has(nodeId)) {
|
||||
newSelectedIds.delete(nodeId)
|
||||
} else {
|
||||
newSelectedIds.add(nodeId)
|
||||
}
|
||||
|
||||
const remaining = Array.from(newSelectedIds)
|
||||
return {
|
||||
...state,
|
||||
selectedId: remaining.length > 0 ? remaining[remaining.length - 1] : null,
|
||||
selectedIds: newSelectedIds,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
selectedId: null,
|
||||
selectedIds: new Set(),
|
||||
}))
|
||||
},
|
||||
|
||||
setHovered: (nodeId: string | null) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
hoveredId: nodeId,
|
||||
}))
|
||||
},
|
||||
|
||||
setMultiSelect: (enabled: boolean) => {
|
||||
update((state) => ({
|
||||
...state,
|
||||
multiSelect: enabled,
|
||||
}))
|
||||
},
|
||||
|
||||
isSelected: (nodeId: string) => {
|
||||
const state = get({ subscribe })
|
||||
return state.selectedIds.has(nodeId)
|
||||
},
|
||||
|
||||
isHovered: (nodeId: string) => {
|
||||
const state = get({ subscribe })
|
||||
return state.hoveredId === nodeId
|
||||
},
|
||||
|
||||
getSelectedIds: () => {
|
||||
const state = get({ subscribe })
|
||||
return Array.from(state.selectedIds)
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Derived Stores
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create derived store for whether anything is selected
|
||||
*/
|
||||
export function createHasSelectionStore(selectionStore: ReturnType<typeof createSelectionStore>) {
|
||||
return derived(selectionStore, ($selection) => $selection.selectedId !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create derived store for selection count
|
||||
*/
|
||||
export function createSelectionCountStore(selectionStore: ReturnType<typeof createSelectionStore>) {
|
||||
return derived(selectionStore, ($selection) => $selection.selectedIds.size)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Store Instance
|
||||
// ============================================================================
|
||||
|
||||
export const selectionStore = createSelectionStore()
|
||||
export const hasSelection = createHasSelectionStore(selectionStore)
|
||||
export const selectionCount = createSelectionCountStore(selectionStore)
|
||||
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
import { greet } from './greet';
|
||||
|
||||
let { host = 'SvelteKit', guest = 'Vitest' } = $props();
|
||||
</script>
|
||||
|
||||
<h1>{greet(host)}</h1>
|
||||
<p>{greet(guest)}</p>
|
||||
@ -0,0 +1,15 @@
|
||||
import { page } from 'vitest/browser';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import Welcome from './Welcome.svelte';
|
||||
|
||||
describe('Welcome.svelte', () => {
|
||||
it('renders greetings for host and guest', async () => {
|
||||
render(Welcome, { host: 'SvelteKit', guest: 'Vitest' });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 1 }))
|
||||
.toHaveTextContent('Hello, SvelteKit!');
|
||||
await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { greet } from './greet';
|
||||
|
||||
describe('greet', () => {
|
||||
it('returns a greeting', () => {
|
||||
expect(greet('Svelte')).toBe('Hello, Svelte!');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
export function greet(name: string): string {
|
||||
return 'Hello, ' + name + '!';
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
{@render children()}
|
||||
@ -0,0 +1,457 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Main Editor Page
|
||||
*
|
||||
* This is the main SPA entry point with the visual PDF editor.
|
||||
* Shows page settings dialog first, then the editor.
|
||||
*/
|
||||
import { Editor, PageSettingsDialog, JsonDialog } from '$lib/editor'
|
||||
import { createSampleInvoice } from '$lib/examples/sample-document'
|
||||
import { createJsPdfRenderer, renderDocument } from '$lib/renderer'
|
||||
import { createDocument } from '$lib/core/model/node'
|
||||
import type { DocumentNode, PageSettings } from '$lib/core/model/types'
|
||||
|
||||
// Deep clone helper that handles non-cloneable objects
|
||||
function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
// Default page settings
|
||||
const defaultPageSettings: PageSettings = {
|
||||
size: 'A4',
|
||||
orientation: 'portrait',
|
||||
margins: [40, 40, 40, 40],
|
||||
}
|
||||
|
||||
// App state using Svelte5 runes
|
||||
let showSettingsDialog = $state(true)
|
||||
let showJsonDialog = $state(false)
|
||||
let editorReady = $state(false)
|
||||
|
||||
// Document state
|
||||
let currentSettings = $state<PageSettings>(deepClone(defaultPageSettings))
|
||||
let initialDoc = $state<DocumentNode | null>(null)
|
||||
let documentJson = $state('')
|
||||
|
||||
// Reference to editor component
|
||||
let editorRef: Editor
|
||||
|
||||
// Preview state
|
||||
let showPreview = $state(false)
|
||||
let pdfUrl = $state<string | null>(null)
|
||||
let loading = $state(false)
|
||||
|
||||
// Handle page settings save
|
||||
function handleSettingsSave(settings: PageSettings) {
|
||||
currentSettings = settings
|
||||
// Create a new document with the specified settings
|
||||
initialDoc = createDocument([], {
|
||||
pageSettings: settings,
|
||||
metadata: {
|
||||
title: 'Nuevo Documento',
|
||||
author: 'PDF Engine Lab',
|
||||
},
|
||||
})
|
||||
showSettingsDialog = false
|
||||
editorReady = true
|
||||
}
|
||||
|
||||
// Handle start with sample
|
||||
function handleStartWithSample() {
|
||||
const sample = createSampleInvoice()
|
||||
currentSettings = sample.props.pageSettings
|
||||
initialDoc = sample
|
||||
showSettingsDialog = false
|
||||
editorReady = true
|
||||
}
|
||||
|
||||
// Generate PDF preview
|
||||
async function generatePreview() {
|
||||
if (!editorRef) return
|
||||
|
||||
loading = true
|
||||
try {
|
||||
const doc = editorRef.getDocument()
|
||||
const renderer = createJsPdfRenderer()
|
||||
await renderDocument(doc, { renderer })
|
||||
pdfUrl = renderer.toDataURL()
|
||||
showPreview = true
|
||||
} catch (error) {
|
||||
console.error('Failed to generate PDF:', error)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Download PDF
|
||||
async function downloadPdf() {
|
||||
if (!editorRef) return
|
||||
|
||||
try {
|
||||
const doc = editorRef.getDocument()
|
||||
const renderer = createJsPdfRenderer()
|
||||
await renderDocument(doc, { renderer })
|
||||
const pdf = renderer.getOutput()
|
||||
pdf.save('document.pdf')
|
||||
} catch (error) {
|
||||
console.error('Failed to generate PDF:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Close preview
|
||||
function closePreview() {
|
||||
showPreview = false
|
||||
pdfUrl = null
|
||||
}
|
||||
|
||||
// Open JSON dialog
|
||||
function openJsonDialog() {
|
||||
if (editorRef) {
|
||||
documentJson = editorRef.exportToJSON()
|
||||
}
|
||||
showJsonDialog = true
|
||||
}
|
||||
|
||||
// Handle JSON import
|
||||
function handleJsonImport(json: string) {
|
||||
try {
|
||||
const doc = JSON.parse(json) as DocumentNode
|
||||
initialDoc = doc
|
||||
currentSettings = doc.props.pageSettings
|
||||
showJsonDialog = false
|
||||
// Reset editor by toggling ready state
|
||||
editorReady = false
|
||||
setTimeout(() => {
|
||||
editorReady = true
|
||||
}, 0)
|
||||
} catch (err) {
|
||||
console.error('Failed to import JSON:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// New document
|
||||
function handleNewDocument() {
|
||||
initialDoc = null
|
||||
editorReady = false
|
||||
showSettingsDialog = true
|
||||
}
|
||||
|
||||
// Handle keyboard events for accessibility
|
||||
function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closePreview()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>PDF Engine Lab - Visual Editor</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Page Settings Dialog -->
|
||||
<PageSettingsDialog
|
||||
open={showSettingsDialog}
|
||||
settings={currentSettings}
|
||||
onsave={handleSettingsSave}
|
||||
/>
|
||||
|
||||
<!-- JSON Export/Import Dialog -->
|
||||
<JsonDialog
|
||||
open={showJsonDialog}
|
||||
{documentJson}
|
||||
onclose={() => showJsonDialog = false}
|
||||
onimport={handleJsonImport}
|
||||
/>
|
||||
|
||||
<!-- PDF Preview Modal -->
|
||||
{#if showPreview}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="preview-overlay"
|
||||
onclick={closePreview}
|
||||
onkeydown={handleOverlayKeydown}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="preview-modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="preview-header">
|
||||
<h2>PDF Preview</h2>
|
||||
<div class="preview-actions">
|
||||
<button onclick={downloadPdf}>Download PDF</button>
|
||||
<button onclick={closePreview}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
{#if pdfUrl}
|
||||
<iframe src={pdfUrl} title="PDF Preview"></iframe>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main App -->
|
||||
<div class="app">
|
||||
{#if editorReady && initialDoc}
|
||||
<Editor bind:this={editorRef} initialDocument={initialDoc} />
|
||||
|
||||
<div class="floating-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={handleNewDocument}
|
||||
title="Nuevo Documento"
|
||||
>
|
||||
📄 Nuevo
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={openJsonDialog}
|
||||
title="Exportar/Importar JSON"
|
||||
>
|
||||
💾 JSON
|
||||
</button>
|
||||
<button
|
||||
class="preview-btn"
|
||||
onclick={generatePreview}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Generando...' : '👁️ Preview'}
|
||||
</button>
|
||||
<button
|
||||
class="download-btn"
|
||||
onclick={downloadPdf}
|
||||
>
|
||||
📥 PDF
|
||||
</button>
|
||||
</div>
|
||||
{:else if !showSettingsDialog}
|
||||
<div class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
<p>Cargando editor...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Settings dialog is open -->
|
||||
<div class="start-screen">
|
||||
<div class="start-content">
|
||||
<h1>PDF Engine Lab</h1>
|
||||
<p>Editor visual de documentos PDF</p>
|
||||
<button class="sample-btn" onclick={handleStartWithSample}>
|
||||
Cargar documento de ejemplo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.start-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||||
}
|
||||
|
||||
.start-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.start-content h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.start-content p {
|
||||
font-size: 18px;
|
||||
color: #9ca3af;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
.sample-btn {
|
||||
padding: 12px 24px;
|
||||
border: 2px solid #4b5563;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sample-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: #1f2937;
|
||||
color: #9ca3af;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #374151;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.preview-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.preview-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-actions button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.preview-actions button:first-child {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.preview-actions button:first-child:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.preview-actions button:last-child {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.preview-actions button:last-child:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-content iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.floating-actions {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.floating-actions button {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: white;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
background: white;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.preview-btn:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.floating-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@ -0,0 +1,19 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { relative, sep } from 'node:path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// defaults to rune mode for the project, execept for `node_modules`. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => {
|
||||
const relativePath = relative(import.meta.dirname, filename);
|
||||
const pathSegments = relativePath.toLowerCase().split(sep);
|
||||
const isExternalLibrary = pathSegments.includes('node_modules');
|
||||
|
||||
return isExternalLibrary ? undefined : true;
|
||||
}
|
||||
},
|
||||
kit: { adapter: adapter() }
|
||||
};
|
||||
|
||||
export default config;
|
||||
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
projects: [
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'client',
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'chromium', headless: true }]
|
||||
},
|
||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/lib/server/**']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
Loading…
Reference in new issue