feat: initialize PDF Engine Lab visual editor project

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 mode
main
dev 7 months ago
commit 0ab4e82cab

23
.gitignore vendored

@ -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 @@
engine-strict=true

@ -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.

3077
package-lock.json generated

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

13
src/app.d.ts vendored

@ -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>

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

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,653 @@
<script lang="ts">
/**
* Editor.svelte
*
* Main visual editor component that integrates all editor parts.
*/
import { onMount } from 'svelte'
import type { Node, NodeType, DocumentNode } from '../core/model/types'
import { hasChildren } from '../core/model/types'
import {
createSection,
createRow,
createColumn,
createText,
createImage,
createSpacer,
createDivider,
createBox,
generateId,
} from '../core/model/node'
import { componentRegistry } from '../components/registry'
import { createDocumentStore, createSelectionStore, createHistoryStore, createHistoryAwareDocumentStore } from '../stores'
import Canvas from './canvas/Canvas.svelte'
import Toolbar from './toolbar/Toolbar.svelte'
import PropertiesPanel from './panels/PropertiesPanel.svelte'
import InsertionPopup from './canvas/InsertionPopup.svelte'
// Props using Svelte 5 runes
let { initialDocument }: { initialDocument?: DocumentNode } = $props()
// Create stores
const documentStore = createDocumentStore(initialDocument)
const selectionStore = createSelectionStore()
const historyStore = createHistoryStore()
// Connect history to document
const history = createHistoryAwareDocumentStore(documentStore, historyStore)
// State from stores - renamed to avoid shadowing global `document`
let docTree = $derived($documentStore.root)
let selectedId = $derived($selectionStore.selectedId)
let hoveredId = $derived($selectionStore.hoveredId)
// Get selected node
let selectedNode = $derived(selectedId ? documentStore.getNode(selectedId) : null)
// Zoom state
let zoom = $state(0.8)
// Notification state for hierarchy enforcement messages
let notification = $state<{ message: string; type: 'warning' | 'error' | 'info' } | null>(null)
// Insertion popup state
let insertionPopup = $state<{
isOpen: boolean
parentId: string
index: number
allowedTypes: NodeType[]
position: { x: number; y: number }
}>({
isOpen: false,
parentId: '',
index: 0,
allowedTypes: [],
position: { x: 0, y: 0 }
})
// Auto-clear notification after 3 seconds
function showNotification(message: string, type: 'warning' | 'error' | 'info' = 'warning') {
notification = { message, type }
setTimeout(() => {
notification = null
}, 3000)
}
// Create node factory based on type
function createNodeByType(type: NodeType): Node {
const definition = componentRegistry.get(type)
const defaultProps = definition?.defaultProps ?? {}
const defaultStyle = definition?.defaultStyle ?? {}
switch (type) {
case 'section':
return createSection([], { style: defaultStyle })
case 'row':
return createRow([], { style: defaultStyle })
case 'column':
return createColumn([], { style: defaultStyle })
case 'text':
return createText((defaultProps.value as string) ?? 'New text', { style: defaultStyle })
case 'image':
return createImage((defaultProps.src as string) ?? '', {
alt: defaultProps.alt as string,
style: defaultStyle,
})
case 'spacer':
return createSpacer({ size: defaultProps.size as number, style: defaultStyle })
case 'divider':
return createDivider({
thickness: defaultProps.thickness as number,
style: defaultProps.style as 'solid' | 'dashed' | 'dotted',
nodeStyle: defaultStyle,
})
case 'box':
return createBox([], { style: defaultStyle })
default:
return createText('New element', { style: defaultStyle })
}
}
// Get node type hierarchy level
// 0 = document, 1 = section, 2 = row, 3 = column, 4 = primitives
function getNodeLevel(type: NodeType): number {
switch (type) {
case 'document': return 0
case 'section': return 1
case 'row': return 2
case 'column': return 3
default: return 4 // primitives
}
}
// Get parent type for a given node type
function getParentType(type: NodeType): NodeType | null {
switch (type) {
case 'section': return 'document'
case 'row': return 'section'
case 'column': return 'row'
default: return 'column' // primitives go in columns
}
}
// Find appropriate parent for a new node type
function findAppropriateParent(type: NodeType): { parentId: string; index?: number } | null {
const targetLevel = getNodeLevel(type)
if (!selectedId) {
// No selection - find or create appropriate container from document root
if (targetLevel === 1) {
// Section goes directly in document
return { parentId: docTree.id }
}
// Need to find/create the chain: document -> section -> row -> column
let currentContainer = docTree
// Find or create section
if (!currentContainer.children?.length) {
return null // Will trigger auto-creation
}
// Navigate down the tree finding the deepest valid container
for (let level = 1; level < targetLevel; level++) {
const targetType = level === 1 ? 'section' : level === 2 ? 'row' : 'column'
const existingChild = currentContainer.children?.find((c: Node) => c.type === targetType)
if (existingChild && hasChildren(existingChild)) {
currentContainer = existingChild as any
} else {
return null // Need to create intermediate containers
}
}
return { parentId: currentContainer.id }
}
const selected = documentStore.getNode(selectedId)
if (!selected) return null
const selectedLevel = getNodeLevel(selected.type)
// If inserting at same level or into a container
if (targetLevel === selectedLevel + 1 && hasChildren(selected)) {
// Selected node is the right parent type
return { parentId: selectedId }
}
if (targetLevel <= selectedLevel) {
// Insert sibling at same level - find parent
const parent = documentStore.getParent(selectedId)
if (parent && hasChildren(parent)) {
const index = documentStore.getNodeIndex(selectedId)
return { parentId: parent.id, index: index + 1 }
}
}
// Need to find appropriate ancestor
let current = selected
while (current) {
const currentLevel = getNodeLevel(current.type)
if (currentLevel === targetLevel - 1 && hasChildren(current)) {
return { parentId: current.id }
}
current = documentStore.getParent(current.id) as any
}
return null
}
// Check if a valid parent exists for a given node type
function canInsertType(type: NodeType): boolean {
const targetLevel = getNodeLevel(type)
// Sections can always be inserted into document
if (type === 'section') return true
// Rows need a section
if (type === 'row') {
if (!selectedId) {
// Check if any section exists
return docTree.children?.some((c: Node) => c.type === 'section') ?? false
}
const selected = documentStore.getNode(selectedId)
if (!selected) return docTree.children?.some((c: Node) => c.type === 'section') ?? false
// Can insert row if selected is a section or if we can find a section ancestor
if (selected.type === 'section') return true
if (selected.type === 'document') return docTree.children?.some((c: Node) => c.type === 'section') ?? false
// Find section ancestor
let current = selected
while (current) {
if (current.type === 'section') return true
current = documentStore.getParent(current.id) as any
}
return false
}
// Columns need a row
if (type === 'column') {
if (!selectedId) {
// Check if any row exists in the document
return findNodeOfType('row') !== null
}
const selected = documentStore.getNode(selectedId)
if (!selected) return findNodeOfType('row') !== null
// Can insert column if selected is a row or inside a row
if (selected.type === 'row') return true
// Find row ancestor
let current = selected
while (current) {
if (current.type === 'row') return true
current = documentStore.getParent(current.id) as any
}
return false
}
// Primitives (level 4) need a column
if (targetLevel === 4) {
if (!selectedId) {
return findNodeOfType('column') !== null
}
const selected = documentStore.getNode(selectedId)
if (!selected) return findNodeOfType('column') !== null
// Can insert primitive if selected is a column or inside a column
if (selected.type === 'column') return true
// Find column ancestor
let current = selected
while (current) {
if (current.type === 'column') return true
current = documentStore.getParent(current.id) as any
}
return false
}
return true
}
// Helper to find a node of a specific type in the document tree
function findNodeOfType(type: NodeType): Node | null {
function search(node: Node): Node | null {
if (node.type === type) return node
if (node.children) {
for (const child of node.children) {
const found = search(child)
if (found) return found
}
}
return null
}
return search(docTree)
}
// Handle block insertion with strict hierarchy enforcement
function handleInsert(type: NodeType) {
const targetLevel = getNodeLevel(type)
// Special handling for rows - auto-create column inside
if (type === 'row') {
const parentInfo = findAppropriateParent('row')
if (!parentInfo) {
showNotification('Please create or select a section first. Rows must be inside a section.', 'warning')
return
}
const row = createRow([], { style: componentRegistry.get('row')?.defaultStyle ?? {} })
const column = createColumn([], { style: componentRegistry.get('column')?.defaultStyle ?? {} })
// Add column to row
row.children.push(column)
// Always append rows at the end of the section (don't use index)
documentStore.insertNode(parentInfo.parentId, row)
selectionStore.select(column.id)
return
}
// For primitives - require a column
if (targetLevel === 4) {
const parentInfo = findAppropriateParent(type)
if (!parentInfo) {
showNotification('Please select a column first. Content elements must be inside a column.', 'warning')
return
}
const node = createNodeByType(type)
// Always append primitives at the end of the column (don't use index)
documentStore.insertNode(parentInfo.parentId, node)
selectionStore.select(node.id)
return
}
// For sections - insert directly into document
if (type === 'section') {
const section = createSection([], { style: componentRegistry.get('section')?.defaultStyle ?? {} })
// Always append sections at the end of the document
documentStore.insertNode(docTree.id, section)
selectionStore.select(section.id)
return
}
// For columns - require a row
if (type === 'column') {
const parentInfo = findAppropriateParent('column')
if (!parentInfo) {
showNotification('Please select a row first. Columns must be inside a row.', 'warning')
return
}
const column = createColumn([], { style: componentRegistry.get('column')?.defaultStyle ?? {} })
// Always append columns at the end of the row (don't use index)
documentStore.insertNode(parentInfo.parentId, column)
selectionStore.select(column.id)
return
}
}
// Handle inline insert button click
function handleInsertButtonClick(parentId: string, index: number) {
// Get the parent node to determine allowed child types
const parent = documentStore.getNode(parentId)
if (!parent) return
let allowedTypes: NodeType[] = []
if (parent.type === 'section') {
allowedTypes = ['row']
} else if (parent.type === 'row') {
allowedTypes = ['column']
} else if (parent.type === 'column') {
allowedTypes = ['text', 'image', 'spacer', 'divider', 'box']
} else if (parent.type === 'box') {
allowedTypes = ['text', 'image', 'spacer', 'divider']
}
// Position popup in center of screen
insertionPopup = {
isOpen: true,
parentId,
index,
allowedTypes,
position: {
x: globalThis.innerWidth / 2 - 150,
y: globalThis.innerHeight / 2 - 150
}
}
}
// Handle insertion from popup
function handlePopupInsert(type: NodeType, parentId: string, index: number) {
const node = createNodeByType(type)
// Special handling for rows - auto-create column inside
if (type === 'row') {
const column = createColumn([], { style: componentRegistry.get('column')?.defaultStyle ?? {} })
node.children.push(column)
}
documentStore.insertNode(parentId, node, index)
selectionStore.select(type === 'row' && node.children?.[0]?.id ? node.children[0].id : node.id)
insertionPopup = { ...insertionPopup, isOpen: false }
}
// Close insertion popup
function closeInsertionPopup() {
insertionPopup = { ...insertionPopup, isOpen: false }
}
// Handle selection
function handleSelect(nodeId: string | null, _event: MouseEvent) {
if (nodeId === null) {
selectionStore.clearSelection()
} else {
selectionStore.select(nodeId)
}
}
// Handle hover
function handleHover(nodeId: string | null, _event: MouseEvent) {
selectionStore.setHovered(nodeId)
}
// Handle style update
function handleStyleUpdate(style: Record<string, unknown>) {
if (selectedId) {
documentStore.updateNodeStyle(selectedId, style)
}
}
// Handle props update
function handlePropsUpdate(props: Record<string, unknown>) {
if (selectedId) {
documentStore.updateNodeProps(selectedId, props)
}
}
// Handle delete
function handleDelete() {
if (selectedId) {
documentStore.removeNode(selectedId)
selectionStore.clearSelection()
}
}
// Handle keyboard shortcuts
function handleKeyDown(event: KeyboardEvent) {
// Delete
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedId) {
// Only if not in an input - use globalThis.document to avoid any confusion
if (globalThis.document.activeElement?.tagName !== 'INPUT' && globalThis.document.activeElement?.tagName !== 'TEXTAREA') {
event.preventDefault()
handleDelete()
}
}
// Undo: Ctrl+Z
if (event.ctrlKey && event.key === 'z' && !event.shiftKey) {
event.preventDefault()
history.undo()
}
// Redo: Ctrl+Y or Ctrl+Shift+Z
if ((event.ctrlKey && event.key === 'y') || (event.ctrlKey && event.shiftKey && event.key === 'z')) {
event.preventDefault()
history.redo()
}
// Escape to deselect
if (event.key === 'Escape') {
selectionStore.clearSelection()
}
}
onMount(() => {
globalThis.document.addEventListener('keydown', handleKeyDown)
return () => globalThis.document.removeEventListener('keydown', handleKeyDown)
})
// Export functions for parent components
export function getDocument(): DocumentNode {
return $documentStore.root
}
export function exportToJSON(): string {
return documentStore.exportDocument()
}
export function loadFromJSON(json: string) {
documentStore.loadDocument(json)
}
export function newDocument() {
documentStore.newDocument()
selectionStore.clearSelection()
historyStore.clear()
}
</script>
<div class="editor">
<div class="editor-toolbar">
<div class="toolbar-group">
<button onclick={history.undo} disabled={!$historyStore.past.length} title="Undo (Ctrl+Z)">
↶
</button>
<button onclick={history.redo} disabled={!$historyStore.future.length} title="Redo (Ctrl+Y)">
↷
</button>
</div>
<div class="toolbar-group">
<button onclick={() => (zoom = Math.max(0.25, zoom - 0.1))} title="Zoom Out">
−
</button>
<span class="zoom-level">{Math.round(zoom * 100)}%</span>
<button onclick={() => (zoom = Math.min(2, zoom + 0.1))} title="Zoom In">
+
</button>
</div>
<div class="toolbar-group">
<button onclick={newDocument} title="New Document">New</button>
<button onclick={() => console.log(exportToJSON())} title="Export JSON">Export</button>
</div>
</div>
<div class="editor-main">
<Toolbar oninsert={handleInsert} {canInsertType} />
{#if notification}
<div class="notification {notification.type}" role="alert">
{notification.message}
</div>
{/if}
<Canvas
document={docTree}
{selectedId}
{hoveredId}
{zoom}
onselect={handleSelect}
onhover={handleHover}
oninsertbutton={handleInsertButtonClick}
/>
<PropertiesPanel
node={selectedNode}
onupdatestyle={handleStyleUpdate}
onupdateprops={handlePropsUpdate}
ondelete={handleDelete}
/>
</div>
<InsertionPopup
isOpen={insertionPopup.isOpen}
parentId={insertionPopup.parentId}
index={insertionPopup.index}
allowedTypes={insertionPopup.allowedTypes}
position={insertionPopup.position}
oninsert={handlePopupInsert}
onclose={closeInsertionPopup}
/>
</div>
<style>
.editor {
display: flex;
flex-direction: column;
height: 100vh;
background: #1f2937;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
background: #374151;
border-bottom: 1px solid #4b5563;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.editor-toolbar button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: #4b5563;
color: white;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.editor-toolbar button:hover:not(:disabled) {
background: #6b7280;
}
.editor-toolbar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.zoom-level {
color: #9ca3af;
font-size: 12px;
min-width: 45px;
text-align: center;
}
.editor-main {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.notification {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 1000;
animation: fadeInOut 3s ease-in-out;
max-width: 400px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.notification.warning {
background: #f59e0b;
color: #1f2937;
}
.notification.error {
background: #ef4444;
color: white;
}
.notification.info {
background: #3b82f6;
color: white;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
10% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
90% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
}
</style>

@ -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,216 @@
<script lang="ts">
/**
* InsertionPopup.svelte
*
* Popup menu for selecting element type to insert.
* Appears when clicking an InsertButton.
*/
import { componentRegistry } from '../../components/registry'
import type { NodeType } from '../../core/model/types'
// Props using Svelte5 runes
let {
isOpen = false,
parentId,
index,
allowedTypes = [],
position = { x: 0, y: 0 },
oninsert = undefined,
onclose = undefined,
}: {
isOpen?: boolean
parentId: string
index: number
allowedTypes?: NodeType[]
position?: { x: number; y: number }
oninsert?: (type: NodeType, parentId: string, index: number) => void
onclose?: () => void
} = $props()
// Get component definitions for allowed types
let availableComponents = $derived(
allowedTypes
.map(type => componentRegistry.get(type))
.filter(Boolean)
)
function handleSelect(type: NodeType) {
oninsert?.(type, parentId, index)
onclose?.()
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onclose?.()
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onclose?.()
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
{#if isOpen}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="popup-backdrop" onclick={handleBackdropClick}>
<div
class="insertion-popup"
style="left: {position.x}px; top: {position.y}px;"
role="dialog"
aria-label="Insert element"
>
<div class="popup-header">
<span>Add Element</span>
<button class="close-btn" onclick={() => onclose?.()} aria-label="Close">×</button>
</div>
<div class="popup-content">
{#if availableComponents.length === 0}
<div class="empty-message">No elements available</div>
{:else}
<div class="element-grid">
{#each availableComponents as component (component.type)}
<button
class="element-option"
onclick={() => handleSelect(component.type)}
title={component.description}
>
<span class="element-icon">{component.icon}</span>
<span class="element-label">{component.label}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.popup-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.insertion-popup {
position: fixed;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
min-width: 280px;
max-width: 400px;
max-height: 80vh;
overflow: hidden;
animation: popup-appear 0.15s ease;
}
@keyframes popup-appear {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
font-weight: 600;
font-size: 14px;
color: #374151;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
color: #6b7280;
transition: all 0.15s;
}
.close-btn:hover {
background: #e5e7eb;
color: #374151;
}
.popup-content {
padding: 12px;
overflow-y: auto;
max-height: 400px;
}
.empty-message {
text-align: center;
color: #9ca3af;
padding: 20px;
font-size: 14px;
}
.element-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.element-option {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 8px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.15s ease;
}
.element-option:hover {
border-color: #3b82f6;
background: #eff6ff;
transform: translateY(-2px);
}
.element-option:active {
transform: translateY(0);
}
.element-icon {
font-size: 20px;
line-height: 1;
}
.element-label {
font-size: 11px;
color: #374151;
text-align: center;
font-weight: 500;
}
</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,502 @@
<script lang="ts">
/**
* PageSettingsDialog.svelte
*
* Dialog for configuring page settings before starting the editor.
* Allows setting page size, orientation, margins, and background color.
*/
import type { PageSettings, PageSizePreset, PageOrientation } from '../../core/model/types'
// Props using Svelte5 runes
let {
open = false,
settings,
onsave,
oncancel,
}: {
open?: boolean
settings: PageSettings
onsave?: (settings: PageSettings) => void
oncancel?: () => void
} = $props()
// Deep clone helper that handles non-cloneable objects
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
// Local state for form fields
let localSettings = $state<PageSettings>(deepClone(settings))
// Reset local settings when dialog opens
$effect(() => {
if (open) {
localSettings = structuredClone(settings)
}
})
// Page size options
const pageSizeOptions: { value: PageSizePreset; label: string; dimensions: string }[] = [
{ value: 'A4', label: 'A4', dimensions: '210 × 297 mm' },
{ value: 'A3', label: 'A3', dimensions: '297 × 420 mm' },
{ value: 'A5', label: 'A5', dimensions: '148 × 210 mm' },
{ value: 'Letter', label: 'Letter', dimensions: '8.5 × 11 in' },
{ value: 'Legal', label: 'Legal', dimensions: '8.5 × 14 in' },
{ value: 'Tabloid', label: 'Tabloid', dimensions: '11 × 17 in' },
]
// Orientation options
const orientationOptions: { value: PageOrientation; label: string; icon: string }[] = [
{ value: 'portrait', label: 'Vertical', icon: '📱' },
{ value: 'landscape', label: 'Horizontal', icon: '🖥️' },
]
// Check if size is preset
let isPresetSize = $derived(typeof localSettings.size === 'string')
// Get selected preset label
let selectedPresetLabel = $derived(
isPresetSize
? pageSizeOptions.find(o => o.value === localSettings.size)?.label ?? 'A4'
: 'Custom'
)
// Handle preset size change
function handlePresetSizeChange(value: PageSizePreset) {
localSettings.size = value
}
// Handle orientation change
function handleOrientationChange(value: PageOrientation) {
localSettings.orientation = value
}
// Handle margin change
function handleMarginChange(side: 'top' | 'right' | 'bottom' | 'left', value: number) {
if (!localSettings.margins) {
localSettings.margins = [40, 40, 40, 40]
}
const index = ['top', 'right', 'bottom', 'left'].indexOf(side)
localSettings.margins[index] = value
}
// Handle save
function handleSave() {
onsave?.(localSettings)
}
// Handle cancel
function handleCancel() {
oncancel?.()
}
// Handle backdrop click
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
handleCancel()
}
}
// Handle keyboard
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleCancel()
}
}
</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>Configuración del Documento</h2>
<button class="close-btn" onclick={handleCancel}>✕</button>
</div>
<div class="dialog-body">
<!-- Page Size -->
<div class="form-section">
<h3>Tamaño de Página</h3>
<div class="size-grid">
{#each pageSizeOptions as option}
<button
class="size-option"
class:selected={isPresetSize && localSettings.size === option.value}
onclick={() => handlePresetSizeChange(option.value)}
>
<span class="size-label">{option.label}</span>
<span class="size-dimensions">{option.dimensions}</span>
</button>
{/each}
</div>
</div>
<!-- Orientation -->
<div class="form-section">
<h3>Orientación</h3>
<div class="orientation-grid">
{#each orientationOptions as option}
<button
class="orientation-option"
class:selected={localSettings.orientation === option.value}
onclick={() => handleOrientationChange(option.value)}
>
<span class="orientation-icon">{option.icon}</span>
<span class="orientation-label">{option.label}</span>
</button>
{/each}
</div>
</div>
<!-- Margins -->
<div class="form-section">
<h3>Márgenes (pt)</h3>
<div class="margin-grid">
<label>
<span>Arriba</span>
<input
type="number"
value={localSettings.margins?.[0] ?? 40}
onchange={(e) => handleMarginChange('top', parseFloat(e.currentTarget.value) || 0)}
min="0"
step="5"
/>
</label>
<label>
<span>Derecha</span>
<input
type="number"
value={localSettings.margins?.[1] ?? 40}
onchange={(e) => handleMarginChange('right', parseFloat(e.currentTarget.value) || 0)}
min="0"
step="5"
/>
</label>
<label>
<span>Abajo</span>
<input
type="number"
value={localSettings.margins?.[2] ?? 40}
onchange={(e) => handleMarginChange('bottom', parseFloat(e.currentTarget.value) || 0)}
min="0"
step="5"
/>
</label>
<label>
<span>Izquierda</span>
<input
type="number"
value={localSettings.margins?.[3] ?? 40}
onchange={(e) => handleMarginChange('left', parseFloat(e.currentTarget.value) || 0)}
min="0"
step="5"
/>
</label>
</div>
</div>
<!-- Preview -->
<div class="form-section">
<h3>Vista Previa</h3>
<div class="preview-container">
<div
class="page-preview"
class:landscape={localSettings.orientation === 'landscape'}
>
<div
class="page-content-preview"
style="margin: {(localSettings.margins?.[0] ?? 40) / 4}px {(localSettings.margins?.[1] ?? 40) / 4}px {(localSettings.margins?.[2] ?? 40) / 4}px {(localSettings.margins?.[3] ?? 40) / 4}px"
>
<span class="preview-label">{selectedPresetLabel}</span>
</div>
</div>
</div>
</div>
</div>
<div class="dialog-footer">
<button class="btn-secondary" onclick={handleCancel}>Cancelar</button>
<button class="btn-primary" onclick={handleSave}>Crear Documento</button>
</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: 600px;
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;
}
.form-section {
margin-bottom: 24px;
}
.form-section:last-child {
margin-bottom: 0;
}
.form-section h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.size-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.size-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.15s;
}
.size-option:hover {
border-color: #93c5fd;
background: #f0f9ff;
}
.size-option.selected {
border-color: #3b82f6;
background: #eff6ff;
}
.size-label {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.size-dimensions {
font-size: 11px;
color: #6b7280;
margin-top: 2px;
}
.orientation-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.orientation-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.15s;
}
.orientation-option:hover {
border-color: #93c5fd;
background: #f0f9ff;
}
.orientation-option.selected {
border-color: #3b82f6;
background: #eff6ff;
}
.orientation-icon {
font-size: 32px;
}
.orientation-label {
font-size: 14px;
font-weight: 500;
color: #374151;
}
.margin-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.margin-grid label {
display: flex;
flex-direction: column;
gap: 4px;
}
.margin-grid span {
font-size: 12px;
color: #6b7280;
}
.margin-grid input {
padding: 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.margin-grid input:focus {
outline: none;
border-color: #3b82f6;
}
.preview-container {
display: flex;
justify-content: center;
padding: 20px;
background: #f3f4f6;
border-radius: 8px;
}
.page-preview {
width: 80px;
height: 113px;
background: white;
border: 1px solid #d1d5db;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.page-preview.landscape {
width: 113px;
height: 80px;
}
.page-content-preview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
}
.preview-label {
font-size: 10px;
color: #9ca3af;
font-weight: 500;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.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;
}
@media (max-width: 640px) {
.size-grid {
grid-template-columns: repeat(2, 1fr);
}
.margin-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</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…
Cancel
Save

Powered by TurnKey Linux.