# Writer (Document Editor) Links: [Home](../index.md) | [Workflow Document](../../Workflow_Writer.md) An **SGApps.IO product**: a rich text and document editor built from scratch, in-house, as an ApplicationPrototype module. Two layouts: paginated document pages, or a fluid inline layout that replaces a textarea. No third-party editor, parser or document library of any kind — ~25,000 lines of our own code (19,400 JavaScript + 5,800 CSS); the only sibling it uses is the in-house [Packer](../archiver/packer.md) to package DOCX exports. Proprietary, source-available — see `modules/editors/writer/LICENSE`. ## Features | Category | Highlights | |---|---| | **Layout Modes** | Document (paginated A4/Letter pages with inline auto-breaks) and Inline (fluid textarea replacement). Toolbar **Layout** group switches between them at runtime | | **Rich Text** | Bold, italic, underline, strikethrough, fonts, colors, alignment, lists, tables, images, code blocks, callouts, hyperlinks | | **Editing Modes** | WYSIWYG, Markdown (split-pane), HTML Source (syntax-highlighted) | | **Page Setup** | Paper size, orientation, margins, headers/footers (first-page-different), section breaks — column and odd/even settings are stored in the page setup but not yet applied to the layout | | **Headers & Footers** | Configurable per-zone (left/center/right) with dynamic field tokens (`{pageNumber}`, `{totalPages}`, `{date}`, `{time}`, `{title}`) | | **Rulers** | Interactive horizontal + vertical rulers with indent markers and tab stops, zoom-aware | | **Import/Export** | Import DOCX (with images), ODT, HTML, Markdown, plain text; export DOCX (with images), HTML, Markdown, plain text, JSON, and PDF through the browser's print dialog — built on a native ZIP parser and the in-house `archiver/packer` (no JSZip dependency) | | **Images** | Insert from file, URL or paste; drag-resize; embedded as binary `word/media` parts in DOCX export | | **Styles** | Named paragraph/character styles (Normal, Heading 1-6, Quote, Code) + custom | | **Themes** | Light, Dark, Google Docs (classic), Word — runtime switchable | | **Presets** | `classic`, `wysiwyg`, `minimal` — one-line configuration | | **History** | Undo/redo with grouped operations (200 steps) | | **Zoom** | 25–300% from the toolbar (`setZoom()` takes any factor); ruler and pagination scale with the zoom | --- ## Loading the Module ```js Application.require("editors/writer").then(function (Writer) { var editor = Writer({ container: document.getElementById('editor'), layoutMode: 'document', theme: 'light' }); editor.whenLoaded().then(function () { console.log('Writer ready'); }); }); ``` --- ## Constructor Options | Option | Type | Default | Description | |---|---|---|---| | `container` | `HTMLElement` | *(required)* | DOM element to mount into | | `mode` | `string` | `'wysiwyg'` | Editing mode: `'wysiwyg'`, `'markdown'`, `'source'` | | `layoutMode` | `string` | `'document'` | `'document'` (paginated) or `'inline'` (fluid) | | `theme` | `string` | `'light'` | `'light'`, `'dark'`, `'classic'`, `'word'` | | `preset` | `string` | `null` | `'classic'`, `'wysiwyg'`, `'minimal'`, or `null` | | `showToolbar` | `boolean` | `true` | Show the formatting toolbar | | `showRuler` | `boolean` | `true` | Show rulers (Document Mode only) | | `showStatusBar` | `boolean` | `true` | Show status bar (page, word count, zoom) | | `showFormatBar` | `boolean` | `true` | Show bubble toolbar on text selection | | `readOnly` | `boolean` | `false` | Prevent editing | | `placeholder` | `string` | `'Start typing...'` | Placeholder text | | `content` | `string` | `''` | Initial HTML content | | `pageSetup` | `Object` | A4, portrait, 25.4mm | Page setup (Document Mode) | | `headerFooter` | `Object` | footer = `{pageNumber}` | Header/footer text per zone (see [Headers & Footers](#headers--footers-document-mode)) | | `toolbar` | `Object` | all `true` | Per-group toolbar toggles | | `autosave` | `Object\|false` | `false` | `{ interval: 5000, onSave: fn }` | | `smartQuotes` | `boolean` | `true` | Auto-convert straight quotes to typographic quotes | ### Toolbar Configuration ```js toolbar: { history: true, // Undo, Redo clipboard: true, // Cut, Copy, Paste textStyle: true, // Bold, Italic, Underline, Strikethrough, Super/Sub, Code, Clear heading: true, // Heading dropdown (Normal, H1-H6, Quote, Code) font: true, // Font family, size, color, highlight alignment: true, // Left, Center, Right, Justify lists: true, // Bullet, Ordered, Task indent: true, // Increase/Decrease indent insert: true, // Image, Table, Link, HR, Page Break, Embed, Callout, Emoji code: true, // Code block with language mode: true, // WYSIWYG / Markdown / Source switcher layout: true, // Document / Inline layout switcher tools: true // Find, Print, Export, Import, Zoom } ``` --- ## Presets | Preset | Layout | Rulers | Toolbar | Theme | Best For | |---|---|---|---|---|---| | `'classic'` | Document | shown | full | classic | Document authoring, collaboration | | `'wysiwyg'` | Inline | hidden | full | light | CMS, blog editors, forms | | `'minimal'` | Inline | hidden | hidden | light | Textarea replacement, comments | ```js // paginated document editor Writer({ container: el, preset: 'classic' }); // TinyMCE-style inline editor Writer({ container: el, preset: 'wysiwyg' }); // Minimal textarea replacement Writer({ container: el, preset: 'minimal' }); ``` --- ## Layout Modes ### Document Mode Renders content as paginated pages (A4/Letter) with visible margins, page shadows, headers/footers, and page numbers — like Google Docs or Microsoft Word. ```js var editor = Writer({ container: el, layoutMode: 'document', pageSetup: { size: 'A4', // A3, A4, A5, Letter, Legal, Tabloid orientation: 'portrait', // or 'landscape' margins: { top: 25.4, bottom: 25.4, left: 30, right: 20 }, // mm columns: 1, headerFooter: { firstPageDifferent: true, oddEvenDifferent: false } } }); ``` ### Inline Mode A fluid contentEditable area that grows with content — like TinyMCE or CKEditor. No page boundaries or margins. ```js var editor = Writer({ container: el, layoutMode: 'inline' }); ``` ### Switching at Runtime ```js editor.setLayoutMode('document'); // or 'inline' ``` `setLayoutMode` is a real layout switch, not just a class swap. When called it: 1. Strips auto-break footers/headers from `editorContainer.innerHTML` so they don't leak into the new mode. 2. Updates the `wr-layout-*` class on the root element. 3. **For document mode:** lazily creates the page wrapper if missing, reparents the editable container into it, lazily creates the vertical ruler (and attaches its drag handlers) if `showRuler` is true, restores the cleaned HTML, then runs the page-margin → pagination → ruler-refresh chain. 4. **For inline mode:** detaches the page wrapper from the DOM (but keeps it in memory for reuse), reparents the editable container directly into the editor area, hides both rulers. 5. Refreshes the toolbar state, the status bar, and emits `event:layoutChange`. A no-op fast-path skips the work when `mode` is already the current layout. #### Toolbar Layout Group When `toolbar.layout` is enabled (default), the toolbar shows a **Layout** group with two buttons: | Button | Action | Active when | |---|---|---| | 📄 Document | `app.setLayoutMode('document')` | `config.layoutMode === 'document'` | | ☰ Inline | `app.setLayoutMode('inline')` | `config.layoutMode === 'inline'` | The active button gets the `wr-active` class, mirroring the WYSIWYG/MD/Source mode buttons. State stays in sync regardless of the current editing mode. --- ## Headers & Footers (Document Mode) In Document Mode, every paginated page gets its own footer and the next page gets its own header — both rendered as inline blocks (not overlays). They are configured through the top-level `headerFooter` option. ### Configuration ```js Writer({ container: el, layoutMode: 'document', pageSetup: { size: 'A4', headerFooter: { firstPageDifferent: true, // First page uses firstPageHeader/firstPageFooter oddEvenDifferent: false // (reserved) different header/footer on odd vs even pages } }, headerFooter: { header: { left: '{title}', center: '', right: '{date}' }, footer: { left: '', center: 'Page {pageNumber} of {totalPages}', right: '' }, firstPageDifferent: true, firstPageHeader: { left: '', center: '', right: '' }, firstPageFooter: { left: '', center: '', right: '' } } }); ``` Each header/footer has three zones (`left`, `center`, `right`) and accepts plain text or HTML with dynamic field tokens. ### Field Tokens | Token | Resolves to | |---|---| | `{pageNumber}` | Current page number (1-based) | | `{totalPages}` | Total number of pages | | `{date}` | Today's date in locale format | | `{time}` | Current time in locale format | | `{title}` | Document title (from `` or first heading) | Field tokens are re-resolved on every re-pagination, so `{pageNumber}`/`{totalPages}` always reflect the current state. ### Header & Footer Dialog The toolbar **Insert → Header & Footer** button (or `_showHeaderFooterDialog()` programmatically) opens an interactive dialog with three input rows for the header (left/center/right) and three for the footer, plus a "different first page" toggle. Changes are applied immediately and trigger a re-pagination. ### How Pagination Renders Them In document mode, content flows naturally and an "auto-break" element is inserted whenever a page fills up. Each auto-break consists of six contiguous DOM children: 1. `wr-page-margin-zone wr-page-margin-bottom` — bottom margin of the previous page 2. `wr-page-footer-block` — page footer (3-column layout) 3. `wr-page-footer-margin` — visual margin under the footer 4. `wr-page-gap` — grey gap between pages 5. `wr-page-header-margin` — visual margin above the header 6. `wr-page-header-block` — page header (3-column layout) All auto-break children are `contenteditable="false"` so the user cannot type inside them; the cursor naturally jumps over the gap. Re-pagination is debounced by 200 ms after edits and 300 ms on window resize. --- ## Editing Modes ### WYSIWYG (default) Full rich text editing with toolbar, bubble toolbar on selection, slash commands, block drag handles. ### Markdown Split-pane or inline Markdown editing with live preview. Supports GFM: tables, task lists, strikethrough, footnotes, code blocks, LaTeX math. ### HTML Source Syntax-highlighted HTML editor with real-time two-way sync to WYSIWYG. ```js editor.setMode('wysiwyg'); // or 'markdown' or 'source' ``` --- ## API Reference ### Content | Method | Returns | Description | |---|---|---| | `getHTML()` | `string` | Clean semantic HTML | | `getMarkdown()` | `string` | GFM Markdown | | `getJSON()` | `Object` | Document model JSON | | `getText()` | `string` | Plain text | | `setContent(html)` | `app` | Set from HTML | | `setContentMarkdown(md)` | `app` | Set from Markdown | | `setContentJSON(json)` | `app` | Set from JSON | | `clearContent()` | `app` | Clear all content | ### Editor State | Method | Returns | Description | |---|---|---| | `node()` | `HTMLElement` | Root DOM element | | `whenLoaded()` | `Promise` | Resolves when ready | | `focus()` / `blur()` | `app` | Focus/blur the editor | | `setReadonly(bool)` | `app` | Toggle read-only | | `isReadonly()` | `boolean` | Check read-only | | `setMode(mode)` | `app` | Switch editing mode | | `getMode()` | `string` | Current mode | | `setLayoutMode(mode)` | `app` | Switch layout (`'document'` or `'inline'`) — full re-layout, see [Switching at Runtime](#switching-at-runtime) | | `getLayoutMode()` | `string` | Current layout | | `setTheme(name)` | `app` | Switch theme | | `setZoom(percent)` | `app` | 50–200% | | `getZoom()` | `number` | Current zoom | | `usePreset(name)` | `app` | Apply preset | | `destroy()` | -- | Cleanup | ### Page Setup (Document Mode) | Method | Returns | Description | |---|---|---| | `setPageSetup(opts)` | `app` | Update page size, margins, etc. | | `getPageSetup()` | `Object` | Current page setup | | `setRulerVisible(bool)` | `app` | Show/hide rulers | | `setRulerUnit(unit)` | `app` | `'cm'`, `'in'`, `'px'` | | `insertBreak(type)` | `app` | `'page'`, `'column'`, `'section'` | ### Formatting | Method | Returns | Description | |---|---|---| | `getSelection()` | `Object` | Current selection | | `setSelection(from, to)` | `app` | Set selection | | `getWordCount()` | `Object` | `{ words, chars, sentences, readingTime }` | | `undo()` / `redo()` | `app` | Undo/redo | | `find(query, opts)` | `Array` | Find matches | | `replace(query, repl, opts)` | `number` | Replace all | | `insertBlock(type, attrs)` | `app` | Insert table, image, code, etc. | ### Import / Export | Method | Returns | Description | |---|---|---| | `export(format)` | `Promise<Blob\|string>` | `'docx'`, `'pdf'`, `'html'`, `'md'`, `'txt'`, `'json'` | | `importFile(file)` | `Promise` | Import DOCX, ODT, HTML, MD, TXT | | `download(format)` | -- | Export + trigger browser download | | `print()` | -- | Print in isolated iframe | #### DOCX Import Importing a `.docx` file uses a built-in native ZIP parser (no JSZip dependency) that: 1. Reads the central directory and decompresses each entry through the browser's `DecompressionStream('deflate-raw')`. 2. Parses `word/document.xml`, `word/_rels/document.xml.rels`, and `word/numbering.xml`. 3. Extracts every embedded image from `word/media/*` as a `Blob` and substitutes a real object URL for each `r:embed` reference. 4. Resolves `<w:hyperlink r:id="...">` against the relationships file so links survive the round-trip. 5. Preserves nested ordered/unordered lists, tables, code blocks (mono spans + shading), and inline runs (bold/italic/underline/strike/super/sub/color/highlight). The same native ZIP parser is used for ODT (`content.xml`). #### DOCX Export `export('docx')` produces an OpenXML package with: - Paragraphs, headings, blockquotes, code blocks, horizontal rules - Nested ordered/unordered lists with proper `numbering.xml` definitions - Tables with row/column spans, borders, shading - Inline runs (bold/italic/underline/strike/super/sub/color/highlight/font/size) - Hyperlinks as proper `<w:hyperlink>` elements with relationships - Images embedded as base64 in `word/media/` (PNG, JPEG, GIF, SVG) - Page setup (size, orientation, margins) - Headers/footers from the configured `headerFooter` zones #### Native Promise Handling ApplicationPrototype overrides `window.Promise` with its own implementation. To keep async chains working with `DecompressionStream`, the module saves a reference to the native `Promise` constructor at module load (`_NativePromise = window.Promise`) and uses it for the import pipeline. End-user code that calls `importFile().then(...)` continues to work transparently. --- ## Events | Event | Data | Description | |---|---|---| | `event:ready` | -- | Editor initialized | | `event:change` | `{html}` | Content changed | | `event:selection-change` | `{from, to, marks}` | Selection changed | | `event:mode-change` | `{mode}` | Editing mode switched | | `event:layout-change` | `{layoutMode}` | Layout mode switched | | `event:focus` / `event:blur` | -- | Focus events | | `event:save` | `{html, json}` | Ctrl+S or autosave | | `event:word-count` | `{words, chars}` | Count updated | | `event:page-change` | `{page, totalPages}` | Page changed (Document Mode) | | `event:import` / `event:export` | `{format}` | File I/O events | | `event:print` | -- | Print triggered | --- ## Keyboard Shortcuts | Shortcut | Action | |---|---| | `Ctrl+B` / `Ctrl+I` / `Ctrl+U` | Bold / Italic / Underline | | `Ctrl+Shift+X` | Strikethrough | | `Ctrl+Shift+7` / `8` / `9` | Ordered / Bullet / Task list | | `Ctrl+E` / `L` / `R` / `J` | Center / Left / Right / Justify | | `Ctrl+]` / `[` | Increase / Decrease indent | | `Ctrl+Z` / `Ctrl+Y` | Undo / Redo | | `Ctrl+K` | Insert link | | `Ctrl+Shift+E` | Code block | | `Ctrl+Enter` | Page break (Document Mode) | | `Ctrl+P` | Print | | `Ctrl+S` | Save (fires event) | | `Ctrl+F` / `Ctrl+H` | Find / Find & Replace | | `/` | Slash command menu | | `# ` through `###### ` | Auto-heading 1–6 | | `- ` / `* ` / `1. ` | Auto-start list | | `> ` | Auto-blockquote | | `---` | Horizontal rule | | `Shift+Enter` | Soft line break | --- ## Use Cases ### paginated document editor ```js Application.require("editors/writer").then(function (Writer) { var editor = Writer({ container: document.getElementById('editor'), preset: 'classic', content: '<h1>My Document</h1><p>Start writing...</p>' }); editor.on('event:save', function (data) { fetch('/api/save', { method: 'POST', body: data.html }); }); }); ``` ### CMS blog post editor (inline mode) ```js Application.require("editors/writer").then(function (Writer) { Writer({ container: document.getElementById('post-editor'), preset: 'wysiwyg', content: existingPostHTML, autosave: { interval: 10000, onSave: function (html) { localStorage.setItem('draft', html); } } }); }); ``` ### Minimal textarea replacement ```js Application.require("editors/writer").then(function (Writer) { Writer({ container: document.getElementById('comment-box'), preset: 'minimal', placeholder: 'Write a comment...' }); }); ``` ### Read-only document viewer ```js Application.require("editors/writer").then(function (Writer) { Writer({ container: document.getElementById('viewer'), readOnly: true, showToolbar: false, layoutMode: 'document', content: documentHTML }); }); ``` ### Export to DOCX ```js Application.require("editors/writer").then(function (Writer) { var editor = Writer({ container: el, preset: 'classic' }); document.getElementById('exportBtn').onclick = function () { editor.download('docx'); // triggers browser download }; // Or get the Blob programmatically editor.export('docx').then(function (blob) { // upload blob to server }); }); ``` ### Import DOCX and edit ```js Application.require("editors/writer").then(function (Writer) { var editor = Writer({ container: el, preset: 'classic' }); document.getElementById('fileInput').addEventListener('change', function (e) { editor.importFile(e.target.files[0]).then(function () { console.log('Document imported'); }); }); }); ``` ### Markdown editor ```js Application.require("editors/writer").then(function (Writer) { Writer({ container: el, mode: 'markdown', layoutMode: 'inline', showRuler: false, content: '# Hello World\n\nThis is **Markdown**.' }); }); ``` ### Custom toolbar ```js Application.require("editors/writer").then(function (Writer) { Writer({ container: el, layoutMode: 'inline', toolbar: { history: true, textStyle: true, heading: true, lists: true, insert: true, tools: true, // Disable everything else: clipboard: false, font: false, alignment: false, indent: false, code: false, mode: false } }); }); ``` --- ## Architecture ``` modules/editors/writer/ index.js -- ApplicationPrototype module: toolbar, dialogs, keyboard, modes, presets, headers/footers, inline pagination, layout switcher, rulers, format bar, status bar, slash menu, DOCX/ODT I/O engine.js -- Document model, commands, undo/redo, styles, tables, lists, find/replace renderer.js -- Non-DOM compatibility shim (state-only setters used by index.js) format-parser.js -- HTML/Markdown/DOCX parsers, serializers, paste cleanup, native ZIP parser css/ writer.css -- Complete styling, 4 themes, print, page-break visuals ``` | File | Lines | Purpose | |---|---|---| | `index.js` | 9,075 | Module shell, toolbar (incl. Layout switcher), dialogs, shortcuts, modes, presets, headers/footers, inline pagination, runtime layout switching, DOCX/ODT import-export, native ZIP parser | | `engine.js` | 6,572 | Document model, commands, undo, styles, tables, input rules | | `renderer.js` | 93 | Non-DOM compat shim — `setLayoutMode`/`setTheme`/`setZoom`/`destroy`/etc. as state-only setters. **Index.js owns the entire visible UI** (see [Renderer note](#about-rendererjs)) | | `format-parser.js` | 3,667 | HTML/MD/DOCX parsers, serializers, paste cleanup, image handling | | `writer.css` | 5,827 | Styling, 4 themes, print, page-break visuals, dialogs, image picker | | **Total** | **~25,234** | | **Dependencies:** `uri-load` (CSS loading), `extensions/prototype`. Import/export uses a native ZIP parser built on the browser's `DecompressionStream` — no JSZip or other external library is required. ### About `renderer.js` Historically `renderer.js` owned the entire writer UI: it built its own `.wr-root` shell with toolbar, rulers, status bar, bubble toolbar, slash menu, page container, and document rendering. When `index.js` was rewritten to be the canonical module shell — building all of those itself and using `editorContainer` directly as the document surface — the renderer was reduced to a thin compatibility shim. It still exposes the same methods `index.js` historically called (`setLayoutMode`, `setTheme`, `setZoom`, `setRulerVisible`, `setRulerUnit`, `render`, `resize`, `destroy`, plus the matching getters), but they are state-only setters or no-ops. **Nothing in `renderer.js` touches the DOM.** This eliminated a "writer-inside-a-writer" rendering bug where the renderer's legacy DOM was being built inside the contenteditable. --- ## Notes - **Document Mode** renders content as a single scrollable column with **inline page breaks** — auto-break elements (footer + gap + header) are inserted between pages, so the cursor flows naturally and the v-ruler stays aligned at any zoom level. There are no overlay separators. - **Inline Mode** provides a TinyMCE-like fluid editing surface ideal for CMS and forms. - **Layout switching at runtime** is a real re-layout, not just a class swap. `setLayoutMode('document')` lazily creates the page wrapper and vertical ruler if they're missing, restores the cleaned HTML, and re-runs pagination + ruler refresh. `setLayoutMode('inline')` strips auto-break elements, detaches the page wrapper, and hides the rulers. The `_pageWrapperEl`/`_verticalRulerEl` are kept in memory after detaching so flipping back is cheap. - **Empty editor** initialises with `<p><br></p>` so the caret always has somewhere to land. Without it, an empty contenteditable whose only child is the auto-break footer (`contenteditable="false"`) would refuse all `execCommand` calls — typing, headings, formatting, lists, etc. would all silently no-op. - **Insert Table dialog** quick-pick grid commits on click — clicking a cell sets the row/col counts AND inserts the table immediately, mirroring Google Docs / Word / Notion. Hover still updates the highlight and the row/col input fields without committing, so users who want to fine-tune via the inputs or change header/border options can still do that and click the Insert button. - **Headers and footers** are configurable per zone (left/center/right) with field tokens (`{pageNumber}`, `{totalPages}`, `{date}`, `{time}`, `{title}`) — see [Headers & Footers](#headers--footers-document-mode). - The **slash command menu** (`/`) offers Notion-style block insertion: headings, lists, tables, images, code blocks, callouts, embeds. - The **bubble toolbar** appears on text selection with inline formatting options. - **Block drag handles** allow reordering any block by dragging. - **Import/export** uses a built-in native ZIP parser based on `DecompressionStream('deflate-raw')` — no external dependency for DOCX/ODT round-trips. Images are extracted from `word/media/*` and substituted as object URLs. - **Paste cleanup** automatically strips Word/Google Docs proprietary markup while preserving structure (lists, tables, headings, links, images). - **Zoom** (50–200%) is applied via CSS `transform: scale()` on the page wrapper. Rulers and pagination math are zoom-aware so margin handles and v-ruler ticks remain pixel-perfect. - **Four themes available**: Light, Dark, Google Docs (classic), Word — switchable at runtime via `setTheme()`. - **`renderer.js` is a thin compatibility shim** — it does not touch the DOM. All UI is owned by `index.js`. See [About `renderer.js`](#about-rendererjs). - The module has zero external hard dependencies. All functionality (parsing, ZIP, rendering, formulas-free document model) is implemented natively.