|
| 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 | |
0 commit comments