Skip to content

Commit 11c7fd8

Browse files
hackerwinsclaude
andauthored
Add editable header/footer with page number support (Phase 4.1) (#110)
Add editable header and footer regions to the paginated document editor. Users double-click the page margin to enter header/footer editing mode, type and format text using the same Block/Inline model as the body, and exit via Escape or clicking the body. A special pageNumber inline token is substituted with the actual page number during rendering. Key changes: - Data model: HeaderFooter type with blocks + marginFromEdge on Document - EditContext ('body'|'header'|'footer') in Doc and TextEditor for routing all editing operations to the correct block array - Canvas rendering: header/footer per page with separator line, body dimming, cursor and selection rendering on the clicked page - Store: MemDocStore and YorkieDocStore updated with findBlockInAnyArray and bodyTreeOffset for correct Yorkie Tree path computation - Yorkie serialization: header/footer container nodes in Tree structure - Full mouse interaction: click, drag selection, double-click word selection, arrow key navigation in header/footer regions Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 8d58764 commit 11c7fd8

20 files changed

Lines changed: 3292 additions & 121 deletions
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
---
2+
title: docs-header-footer
3+
target-version: 0.4.0
4+
---
5+
6+
# Document Header & Footer
7+
8+
## Summary
9+
10+
Add editable header and footer regions to the paginated document editor.
11+
Headers and footers are rendered in the page margin area (above/below the
12+
content area) and share the same Block/Inline data model as the document body.
13+
A special `pageNumber` inline token is replaced with the actual page number
14+
during rendering. Users enter header/footer editing via double-click on the
15+
margin area; the editor maintains an `EditContext` that routes all input to the
16+
active block array.
17+
18+
### Goals
19+
20+
- Editable header and footer using the existing Block/Inline model.
21+
- Page number insertion via a special inline token.
22+
- Header/footer rendered in the margin area on every page.
23+
- Double-click to enter header/footer editing, click body or Escape to exit.
24+
- Yorkie serialization with backward compatibility.
25+
26+
### Non-Goals
27+
28+
- "First page different" or "odd/even page different" headers — future extension.
29+
- Section breaks with per-section headers — depends on Phase 4.3.
30+
- Header/footer UI in a modal or side panel — editing is inline on the page.
31+
- Page count token (`{PAGES}`) — only current page number for now.
32+
33+
## Data Model
34+
35+
### Document Extension
36+
37+
```typescript
38+
interface Document {
39+
blocks: Block[];
40+
pageSetup?: PageSetup;
41+
header?: HeaderFooter; // undefined = no header
42+
footer?: HeaderFooter; // undefined = no footer
43+
}
44+
45+
interface HeaderFooter {
46+
blocks: Block[]; // reuses existing Block/Inline model
47+
marginFromEdge: number; // distance from page edge in px (default 48 = 0.5 inch)
48+
}
49+
```
50+
51+
When `header` or `footer` is `undefined`, the margin area is empty and
52+
double-clicking it creates a new `HeaderFooter` with a single empty paragraph.
53+
54+
### Default Values
55+
56+
- `marginFromEdge`: 48px (0.5 inch at 96 DPI) — matches Google Docs.
57+
- Initial blocks: one empty paragraph block.
58+
59+
### Page Number Inline
60+
61+
Extend `InlineStyle` with a `pageNumber` flag:
62+
63+
```typescript
64+
interface InlineStyle {
65+
// ... existing fields
66+
pageNumber?: boolean;
67+
}
68+
```
69+
70+
An inline with `pageNumber: true` stores placeholder text `"#"` in the model.
71+
During rendering, the text is replaced with the current page number string
72+
(`"1"`, `"2"`, ...). All other style properties (bold, italic, fontSize, color,
73+
alignment) apply normally.
74+
75+
### Blocked Block Types
76+
77+
Header/footer blocks must not contain: `table`, `page-break`,
78+
`horizontal-rule`. The editor prevents creation of these types when
79+
`editContext` is `'header'` or `'footer'`. `heading`, `list-item`, and
80+
`paragraph` are allowed.
81+
82+
## Layout
83+
84+
### Header/Footer Layout
85+
86+
`computeLayout(blocks, ctx, contentWidth)` is reused for header and footer
87+
blocks. The `contentWidth` is the same as the body content width
88+
(`pageWidth - margins.left - margins.right`).
89+
90+
Header and footer layouts are computed once and reused for all pages (single
91+
header/footer model).
92+
93+
### Pipeline
94+
95+
```
96+
headerLayout = header ? computeLayout(header.blocks, ctx, contentWidth) : null
97+
footerLayout = footer ? computeLayout(footer.blocks, ctx, contentWidth) : null
98+
bodyLayout = computeLayout(doc.blocks, ctx, contentWidth)
99+
paginated = paginateLayout(bodyLayout, pageSetup)
100+
```
101+
102+
The body pagination is unchanged — `contentHeight` remains
103+
`pageHeight - margins.top - margins.bottom`. Header/footer content lives in
104+
the margin area and does not affect body pagination.
105+
106+
### Vertical Positioning
107+
108+
Per page, absolute Y coordinates:
109+
110+
- **Header start**: `pageY + header.marginFromEdge`
111+
- **Footer start**: `pageY + pageHeight - footer.marginFromEdge - footerLayout.totalHeight`
112+
113+
Horizontal offset: `pageX + margins.left` (same as body content).
114+
115+
### Page Number Text Measurement
116+
117+
During layout, `pageNumber` inlines use the placeholder `"#"` for width
118+
measurement. The width difference between `"#"` and actual numbers (e.g. `"12"`)
119+
is negligible for typical documents. No re-layout per page is needed.
120+
121+
## Rendering
122+
123+
### Render Order (doc-canvas.ts)
124+
125+
Extended page render loop:
126+
127+
```
128+
for each page:
129+
1. Viewport culling
130+
2. Draw shadow + page background
131+
3. [NEW] Clip header area → draw header runs
132+
4. [NEW] Clip footer area → draw footer runs
133+
5. Clip content area → draw selections, text, cursor (existing)
134+
6. Draw peer cursors (existing)
135+
```
136+
137+
### Header/Footer Clipping
138+
139+
- **Header clip rect**: `(pageX + margins.left, pageY + marginFromEdge, contentWidth, margins.top - marginFromEdge)`
140+
- **Footer clip rect**: `(pageX + margins.left, pageY + pageHeight - margins.bottom, contentWidth, margins.bottom - marginFromEdge)`
141+
142+
### Page Number Rendering
143+
144+
`renderRun()` is reused. When processing a run with `pageNumber: true`, the
145+
renderer substitutes `run.text` with the current page's number string before
146+
calling `fillText`. Style properties (font, color, size) apply normally.
147+
148+
### Edit Mode Visual Feedback
149+
150+
When `editContext` is `'header'` or `'footer'`:
151+
152+
- Active region: dashed border outline (`#ccc`, 1px).
153+
- Body content: reduced opacity (e.g. `globalAlpha = 0.4`) to visually
154+
distinguish the editing context.
155+
- Inactive header/footer: rendered normally (no dimming).
156+
157+
## Editing Model
158+
159+
### Edit Context
160+
161+
```typescript
162+
type EditContext = 'body' | 'header' | 'footer';
163+
```
164+
165+
TextEditor gains an `editContext` field (default `'body'`).
166+
167+
### Context Switching
168+
169+
- **Double-click on header margin area** → set `editContext = 'header'`, create
170+
`HeaderFooter` if undefined, place cursor.
171+
- **Double-click on footer margin area** → set `editContext = 'footer'`, create
172+
`HeaderFooter` if undefined, place cursor.
173+
- **Single-click on body area** or **Escape** → set `editContext = 'body'`.
174+
- **Single-click on header/footer area** while already in that context → move
175+
cursor within the region.
176+
177+
### Active Block Array
178+
179+
All editing operations (input, delete, format, split, merge) operate on the
180+
active block array:
181+
182+
```typescript
183+
getActiveBlocks(): Block[] {
184+
if (editContext === 'header') return doc.header.blocks;
185+
if (editContext === 'footer') return doc.footer.blocks;
186+
return doc.blocks;
187+
}
188+
```
189+
190+
The `Doc` (document model) methods accept block arrays or the editor routes
191+
calls to the correct block array.
192+
193+
### Disabled Operations in Header/Footer
194+
195+
- `Ctrl+Enter` (page break): ignored.
196+
- Block type changes to `table`, `page-break`, `horizontal-rule`: blocked.
197+
- Find & Replace: searches body only (header/footer excluded for simplicity).
198+
199+
## Coordinate Mapping
200+
201+
### Click Target Resolution
202+
203+
Extend pixel-to-position mapping to return a `ClickTarget`:
204+
205+
```typescript
206+
interface ClickTarget {
207+
context: EditContext;
208+
position: DocPosition;
209+
}
210+
```
211+
212+
Per-page hit testing (using click Y relative to page top):
213+
214+
| Y range | Target |
215+
|---------|--------|
216+
| `marginFromEdge` to `margins.top` | header |
217+
| `margins.top` to `pageHeight - margins.bottom` | body |
218+
| `pageHeight - margins.bottom` to `pageHeight - marginFromEdge` | footer |
219+
220+
Clicks in the outer edge zone (0 to `marginFromEdge`, or
221+
`pageHeight - marginFromEdge` to `pageHeight`) map to the nearest region.
222+
223+
### Cursor Position (positionToPixel)
224+
225+
When `editContext` is `'header'` or `'footer'`, cursor pixel position is
226+
computed from the header/footer layout:
227+
228+
- **Header**: `pageY + marginFromEdge + lineY`
229+
- **Footer**: `pageY + pageHeight - marginFromEdge - footerHeight + lineY`
230+
231+
The cursor is shown on the page closest to the current viewport center (since
232+
all pages share the same header/footer content).
233+
234+
## Store
235+
236+
### DocStore Extension
237+
238+
```typescript
239+
interface DocStore {
240+
// ... existing
241+
getHeader(): HeaderFooter | undefined;
242+
getFooter(): HeaderFooter | undefined;
243+
setHeader(header: HeaderFooter | undefined): void;
244+
setFooter(footer: HeaderFooter | undefined): void;
245+
}
246+
```
247+
248+
`setHeader`/`setFooter` push to the undo stack. Setting `undefined` removes
249+
the header/footer.
250+
251+
### MemDocStore
252+
253+
Implements the new methods by reading/writing `document.header` and
254+
`document.footer`.
255+
256+
## Yorkie Serialization
257+
258+
### Tree Structure
259+
260+
Header and footer are stored as optional container nodes under the Tree root:
261+
262+
```
263+
root
264+
├── header (optional)
265+
│ └── block[type=paragraph]
266+
│ └── inline[style=...]
267+
│ └── text
268+
├── footer (optional)
269+
│ └── block[type=paragraph]
270+
│ └── inline[style=...]
271+
│ └── text
272+
└── body
273+
└── block[type=paragraph]
274+
└── ...
275+
```
276+
277+
### Header/Footer Attributes
278+
279+
Container node attributes:
280+
281+
```typescript
282+
{ type: 'header', marginFromEdge: '48' }
283+
{ type: 'footer', marginFromEdge: '48' }
284+
```
285+
286+
### Page Number Serialization
287+
288+
Inline style attribute: `pageNumber: 'true'`. Text content: `#`.
289+
290+
### Backward Compatibility
291+
292+
Existing documents without `header`/`footer` nodes deserialize to
293+
`undefined` — no migration needed. The absence of these nodes is the default
294+
state (no header/footer).
295+
296+
## File Change Summary
297+
298+
| File | Change |
299+
|------|--------|
300+
| `model/types.ts` | `HeaderFooter` interface, `Document.header/footer`, `InlineStyle.pageNumber` |
301+
| `model/document.ts` | Block operations routed by context, page-number inline creation |
302+
| `view/layout.ts` | No change (reused as-is for header/footer blocks) |
303+
| `view/pagination.ts` | Export header/footer Y offset helpers |
304+
| `view/doc-canvas.ts` | Render header/footer per page, page-number substitution, edit-mode visuals |
305+
| `view/text-editor.ts` | `EditContext`, context switching, double-click handling, operation routing |
306+
| `view/theme.ts` | Header/footer edit-mode visual constants (dashed border, dimming alpha) |
307+
| `store/store.ts` | `getHeader/Footer()`, `setHeader/Footer()` |
308+
| `store/memory.ts` | MemDocStore implementation |
309+
| `frontend/.../yorkie-doc-store.ts` | Yorkie serialization/deserialization for header/footer nodes |
310+
311+
## Risks and Mitigation
312+
313+
| Risk | Mitigation |
314+
|------|-----------|
315+
| EditContext routing complexity in TextEditor | TextEditor already handles table cell editing context; header/footer follows the same pattern |
316+
| Page number width mismatch between layout and render | Placeholder `#` width is close to digit width; acceptable for v1 |
317+
| Undo/redo across context switches | Each context's mutations are separate undo entries; context switch itself is not undoable |
318+
| Yorkie Tree structure change | Additive change (new optional nodes); no migration needed |
319+
| Header/footer overflow beyond margin area | Clip rect prevents visual overflow; content simply gets cut off |

docs/tasks/active/20260325-docs-wordprocessor-todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Design doc: [docs-wordprocessor-roadmap.md](../../design/docs-wordprocessor-road
3131

3232
## Phase 4: Page Features
3333

34-
- [ ] 4.1 Header / Footer — fixed regions, page numbers
34+
- [x] 4.1 Header / Footer — fixed regions, page numbers
3535
- [x] 4.2 Page Break — Ctrl+Enter, forced split
3636
- [ ] 4.3 Section Break — per-section PageSetup
3737
- [ ] 4.4 Table of Contents — heading-based auto-generation, outline sidebar

0 commit comments

Comments
 (0)