diff --git a/.gitignore b/.gitignore index 45f3c1f18..00d911614 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ package-lock.json /node_modules /tmp +/vendor/bundle +/.bundle diff --git a/color-demo.html b/color-demo.html new file mode 100644 index 000000000..7e34adca1 --- /dev/null +++ b/color-demo.html @@ -0,0 +1,265 @@ + + + + + + Trix colour-token attribute — demo + + + + + + + + + +
+

Trix colour-token attribute — demo

+

+ Runtime-registered color text attribute that emits + data-color="primary|secondary|accent" into the HTML. + The theme (see the :root CSS at the top of this file) + maps each token to an actual colour — change those three lines and + every document re-themes. +

+ +
+ Colour token: + + + + + no selection +
+ +
+ + +
+ +
+
+

Active attributes at cursor

+
(nothing yet)
+
+
+

Serialized HTML (hidden input value)

+
(nothing yet)
+
+
+ +
+ + + +
+
+ + + + diff --git a/docs/01-overview.md b/docs/01-overview.md new file mode 100644 index 000000000..d84d28e0a --- /dev/null +++ b/docs/01-overview.md @@ -0,0 +1,97 @@ +# Overview + +## What Trix is + +Trix is a WYSIWYG rich-text editor originally written by Basecamp. Its central +design decision is to **avoid `document.execCommand` entirely** and treat the +browser's `contenteditable` behaviour as an I/O device: + +1. Keystrokes, paste, and drop events are observed. +2. Events are translated into operations on an in-memory **document model**. +3. The model is re-rendered to the DOM; the DOM is never the source of truth. + +This makes behaviour reproducible across browsers — every browser quirk lives on +the input side; the document model is a pure data structure. + +## Fork history + +This repository is a fork of `basecamp/trix`, published as `@jimdo/trix` on the +private Jimdo npm registry (`package.json:2`, `.npmrc`). Per the root +`README.md`, the fork was created because Trix was, at the time, the only +WYSIWYG editor with acceptable behaviour on Android GBoard. The fork adds +Jimdo-specific tweaks on top of upstream. + +Recent Jimdo changes visible in `git log` include: +- Alignment block attributes (`alignRight`, `alignCenter`) added via + `inheritFromPreviousBlock` + `role: "alignment"` + (`src/trix/config/block_attributes.coffee:67-79`). +- List-delete semantics (`11750a9a fix(lists): change behavior of delete into lists`). +- Expanded `createContainerElement` so an `` tag captures both `href` and + `target` attributes simultaneously (`src/trix/views/piece_view.coffee:78-92`). +- Publish workflow to the Jimdo npm registry (README, `.npmrc`). + +The last upstream-style release bump is `0.12.0` (`package.json:3`). Upstream +Trix has since moved to v2.x with a different toolchain — those changes have +**not** been ported here. + +## Tech stack + +| Area | Choice | +|---|---| +| Language | CoffeeScript 1.9.1 (`Gemfile:6`) | +| Styling | Sass (`Gemfile:9`) | +| Asset pipeline | Sprockets 3.6.0 (`Gemfile:4`; pinned because 3.7+ strips banners) | +| Test runner | Blade 0.7.x (`Gemfile:13`), with SauceLabs for cross-browser (`Gemfile:14`) | +| Minifier | Uglifier (`Gemfile:10`) | +| SVG optimization | svgo via `sprockets-svgo` (`Gemfile:5`) and the npm `svgo` devDep (`package.json:24`) | +| Runner | Rack + Blade Rack adapter for dev (`config.ru`) | +| CI | Travis (`.travis.yml`) | +| Custom elements | Native `` / `` with a v0 polyfill fallback (`polyfills/custom-elements-v0-fallback.js.erb`) | + +The toolchain is Ruby-centric. There is no webpack, no rollup, no tsc, no +babel, no jest. The only node dependency is `svgo`. Everything happens through +Sprockets compiling CoffeeScript/Sass/ERB sources into a single JS bundle. + +## Output artifacts + +`bin/blade build` produces three files in `dist/`: + +| File | Contents | +|---|---| +| `dist/trix.js` | Full bundle: Trix + polyfills (custom elements v0, Set, etc.). This is what most consumers use. | +| `dist/trix-core.js` | Trix without polyfills. Use if you already ship polyfills yourself. | +| `dist/trix.css` | Default toolbar styles and `.trix-content` stored-content styles. | + +`dist/` is checked into the repo and updated at release time by +`bin/release` (`bin/release:31, 131`). + +## Entry points + +- `assets/trix.coffee` → includes polyfills, then `src/trix/index.coffee.erb`. +- `assets/trix-core.coffee` → same, minus polyfills. +- `src/trix/index.coffee.erb` → the real source root: imports `core`, + `config`, and the two custom elements. +- `src/trix/elements/trix_editor_element.coffee:8` → registers the + `` custom element. +- `src/trix/elements/trix_toolbar_element.coffee` → registers the + `` custom element. + +## Target browsers + +Per `.blade.yml`, the test matrix includes Chrome, Firefox, Safari, Edge 16, +IE 11, iPhone (iOS 9.3–11.2), Android 4.4–6.0. A real port today would drop +IE 11 and old iOS/Android, but the current code still has polyfills and +branches for them — do not remove casually. + +## Age / risk note + +The repo has been untouched for years. Before making any change, expect: + +- Ruby 2.x era dependencies. The pinned Sprockets (3.6.0) and coffee-script + (1.9.1) need rbenv + an old Ruby. +- Gemfile.lock may need regeneration against a modern bundler, but the + **versions must not float** — the Sprockets pin is load-bearing. +- `bin/release` assumes `github.user` and `github.token` in global git + config and an old `github_api` gem; for the Jimdo publish flow, only + steps 1–4 and the `npm publish` tail matter (see + [02-build-and-test.md](02-build-and-test.md)). diff --git a/docs/02-build-and-test.md b/docs/02-build-and-test.md new file mode 100644 index 000000000..d3484f1eb --- /dev/null +++ b/docs/02-build-and-test.md @@ -0,0 +1,119 @@ +# Build and test + +## Prerequisites + +- Ruby. `.ruby-version` pins to `2.3.4`. That matches the Gemfile's 2.x-era + gems. Use rbenv, rvm, or asdf; `bin/setup` calls `rbenv install + --skip-existing` if rbenv is on `PATH` (`bin/setup:39-41`). +- Bundler. +- Node + npm (only needed because the `svgo` binary is a dev dependency). +- Homebrew on macOS makes `bin/setup` smoother (it will brew-install missing + ruby/npm). + +## First-time setup + +``` +bin/setup +``` + +This script (`bin/setup:1-72`): + +1. Ensures Ruby is installed (uses rbenv if present). +2. Installs bundler if missing. +3. Runs `bundle install`. +4. Runs `npm install`. +5. If Pow is installed, symlinks the app at `http://trix.dev`. + +Pass `-v` to see the full output (`bin/setup:11`). + +## Building `dist/` + +``` +bin/blade build +``` + +This invokes Blade's build task which reads `.blade.yml` and compiles: + +- `assets/trix.coffee` → `dist/trix.js` +- `assets/trix-core.coffee` → `dist/trix-core.js` +- `assets/trix.scss` → `dist/trix.css` + +The logical paths and output directory are declared in `.blade.yml:11-16`: + +```yaml +build: + logical_paths: + - trix.js + - trix-core.js + - trix.css + path: dist + js_compressor: uglifier +``` + +The pinned Sprockets version (3.6.0, `Gemfile:4`) matters — 3.7+ strips the +copyright banner comments that are prepended to the bundle. + +## Running locally + +``` +bin/rackup +``` + +The Rack app is defined in `config.ru`: + +- `/` serves the live-compiled Sprockets environment. +- `/test` serves the Blade test harness. +- `/attachments` is a tiny content-addressed blob store that accepts POSTs + (writes to `tmp/attachments/{md5}`) and serves back on GET (`config.ru:10-29`). + +Visit `http://localhost:9292/` to see `assets/index.html` with a working +``. + +## Tests + +Tests are browser-driven via Blade. Two trees: + +- `test/src/unit/` — pure model tests (document, text, block, piece, + html_parser, serialization, composition, mutation_observer, location_mapper). +- `test/src/system/` — black-box tests that drive the `` custom + element (text_formatting, block_formatting, list_formatting, undo, pasting, + basic_input, composition_input, cursor_movement, attachment, etc.). + +Run locally: + +``` +bin/blade +``` + +The CI entrypoint is `bin/ci`, invoked by `.travis.yml:3` via `travis_wait` +(`.travis.yml:3`). `bin/ci` routes between Basecamp's SauceLabs credentials and +a dev account depending on the repo slug (`bin/ci:4-20`). + +## Publishing + +Publishing from this fork is to the Jimdo private npm registry, not to the +public one. From `README.md`: + +1. Add an npm token with publish rights to `.npmrc`. +2. `npm publish --dry-run` to verify. +3. `npm publish`. + +`package.json:6` restricts the published payload to `dist/*.css` and +`dist/*.js` — that means `dist/` **must be up to date before publishing**, and +should be a committed build artifact. + +`bin/release` is the upstream Basecamp release script and does more than +Jimdo needs (it creates GitHub releases, uploads assets, updates copyright +years). For Jimdo-only publishing, the README's three steps are what the team +actually runs. + +## Known friction points + +- **Old gems**: `github_api ~> 0.13.1` and `aws-sdk` may not install cleanly on + current Ruby versions. For the Jimdo npm publish path they are not needed. +- **SauceLabs credentials** are embedded base64-encoded in `bin/ci:5-7`. These + are Basecamp's, not Jimdo's; they will not work for forks. +- **v0 Custom Elements polyfill** (`polyfills/vendor/`) is shipped and needed + for older Safari / older Chromium. Removing it breaks IE 11 and Safari 9. +- **No TypeScript, no ESLint, no Prettier.** There is no linter configured; + rely on test coverage. diff --git a/docs/03-architecture.md b/docs/03-architecture.md new file mode 100644 index 000000000..cd7d86979 --- /dev/null +++ b/docs/03-architecture.md @@ -0,0 +1,540 @@ +# Architecture + +## Guiding principle + +Trix treats `contenteditable` as an I/O device. The DOM is **not** the source +of truth; a pure, immutable document model is. Every user action is translated +into a model mutation; the DOM is then re-rendered from the new model. + +This inverts how naïve WYSIWYG editors work (mutate the DOM via `execCommand`, +then try to make sense of the result). The cost is a larger codebase; the +benefit is that behaviour is deterministic and testable. + +## Data flow, at a glance + +User interaction flows through four layers: + +``` + DOM event + | + v + +-----------------+ + | InputController | (src/trix/controllers/input_controller.coffee) + +-----------------+ + | + v (responder protocol) + +-----------------+ + | Composition | (src/trix/models/composition.coffee) + +-----------------+ + | + | mutates (immutable copies of) ... + v + +-----------------+ + | Document | (src/trix/models/document.coffee) + +-----------------+ + | + | rendered by ... + v + +-----------------+ + | DocumentView | (src/trix/views/document_view.coffee) + +-----------------+ + | + v + DOM +``` + +`EditorController` (`src/trix/controllers/editor_controller.coffee`) is the +conductor: it owns every sub-controller and every model, acts as the +delegate for all of them, and re-fires user-facing custom events on the +`` element. + +## Layer 1 — Custom elements (entry points) + +### `` + +Defined in `src/trix/elements/trix_editor_element.coffee:8` via +`Trix.registerElement "trix-editor"`. + +Lifecycle (`src/trix/elements/trix_editor_element.coffee:163-177`): + +- `initialize`: sets `contenteditable=""` and `role="textbox"`. +- `connect`: creates `new Trix.EditorController(editorElement: this, html: @value)` + and registers the selection manager. Fires `trix-initialize` via + `requestAnimationFrame`. +- `disconnect`: unregisters selection manager and form-reset listener. + +Properties exposed on the element (`src/trix/elements/trix_editor_element.coffee:108-150`): + +- `element.trixId` — unique id, auto-assigned. +- `element.toolbarElement` — the ``; auto-created if not in DOM. +- `element.inputElement` — the hidden `` mirror for form submission; + auto-created. +- `element.editor` — the `Trix.Editor` instance; this is the public API. +- `element.value` — getter reads from the hidden input; setter calls + `editor.loadHTML()`. + +### `` + +Defined in `src/trix/elements/trix_toolbar_element.coffee`. Its only job is to +render the default toolbar HTML from `Trix.config.toolbar.getDefaultHTML()` if +the element is empty when connected. + +## Layer 2 — Core primitives (`src/trix/core/`) + +- `basic_object.coffee` — `Trix.BasicObject`. Has `@proxyMethod(expression)` + for declarative method proxying (e.g. `@proxyMethod "getDialog().open"`). +- `object.coffee` — `Trix.Object extends BasicObject`. Adds `@id`, + `hasSameConstructorAs`, `isEqualTo`, `inspect`, `toJSONString`, + `getCacheKey`. +- `collections/hash.coffee` — `Trix.Hash`, a persistent immutable hash used to + store `Piece.attributes`. `merge`, `remove`, `slice`, `getKeysCommonToHash` + all return new hashes. `Hash.box(obj)` wraps a plain object. +- `collections/object_group.coffee` — `Trix.ObjectGroup`, groups adjacent + objects that agree on a "canBeGrouped" predicate. Used to render adjacent + `href` pieces under a single `` tag and to render nested lists/quotes as + trees. +- `collections/object_map.coffee`, `element_store.coffee` — caches used by + views. +- `helpers/dom.coffee` — DOM helpers: `makeElement`, `findClosestElementFromNode`, + `findInnerElement`, `triggerEvent`, `handleEvent`, `walkTree`, `tagName`. +- `helpers/config.coffee` — cached accessors over `Trix.config`: + `getTextConfig(name)`, `getBlockConfig(name)`, `getAllAttributeNames()`, + `getBlockAttributeNames()`, `getTextAttributeNames()`, + `getListAttributeNames()`. +- `utilities/operation.coffee` — `Trix.Operation`, base for async work. Has a + `getPromise()` that auto-wraps the imperative `perform(callback)` pattern. + +## Layer 3 — Models (`src/trix/models/`) + +The model layer is **immutable**. Every mutating method returns a new instance. +This is how undo works: snapshots are just saved model references. + +### Document hierarchy + +``` +Document + blockList: SplittableList + +Block + text: Text + attributes: string[] # block-level (e.g. ["quote", "bullet"]) + +Text + pieceList: SplittableList + +Piece (abstract) + attributes: Trix.Hash # text-level (e.g. {bold: true, href: "..."}) + +-- StringPiece + | string: string + | length: number + +-- AttachmentPiece + attachment: Attachment + length: 1 +``` + +Key files: + +- `document.coffee` — `Trix.Document`. Range mutations like + `insertTextAtRange`, `removeTextAtRange`, `addAttributeAtRange`, + `applyBlockAttributeAtRange`. Coordinate translation via + `locationRangeFromRange` / `rangeFromLocationRange`. +- `block.coffee` — `Trix.Block`. Block attributes stored as an ordered array + (e.g. `["quote", "bullet"]`); `getLastAttribute`, `getAttributeLevel`, + `getNestingLevel` drive list/quote nesting logic. +- `text.coffee` — `Trix.Text`. Piece-level attribute operations + (`addAttributesAtRange`, `removeAttributeAtRange`, + `getCommonAttributesAtRange`). +- `piece.coffee` — `Trix.Piece`. Abstract; holds `@attributes` as a + `Trix.Hash`. Registers subclass types via `@registerType` + (`src/trix/models/piece.coffee:4-6`). +- `string_piece.coffee` — `Trix.StringPiece`. Implements `splitAtOffset`, + `canBeConsolidatedWith` (true when adjacent pieces share attributes). +- `attachment_piece.coffee` — `Trix.AttachmentPiece`. Length is always 1. +- `splittable_list.coffee` — `Trix.SplittableList`. The collection that holds + pieces or blocks. Provides `splitObjectAtPosition`, + `transformObjectsInRange`, `consolidate`. + +### Composition — the mutator facade + +`src/trix/models/composition.coffee` is the thing controllers talk to. It +holds `@document`, a `@currentAttributes` cache (the attribute set active at +the cursor), a `@revision` counter, and exposes high-level operations: + +- Text insertion: `insertText`, `insertString`, `insertDocument`, + `insertHTML`, `insertBlockBreak`, `insertLineBreak`, `insertFile`, + `insertAttachment`. +- Deletion: `deleteInDirection` (`composition.coffee:128-181`), with + special cases for deleting into block-attribute boundaries and list + decrements. +- Attributes: `setCurrentAttribute`, `toggleCurrentAttribute`, + `removeCurrentAttribute`, `hasCurrentAttribute`, + `canSetCurrentAttribute`, `updateCurrentAttributes`. +- Nesting: `increaseNestingLevel`, `decreaseNestingLevel`, + `canDecreaseBlockAttributeLevel`. +- Attachments: `insertAttachment`, `removeAttachment`, `editAttachment`, + `updateAttributesForAttachment`. + +Every mutation flows through `setDocument(document)` +(`composition.coffee:13-18`), which: + +1. Short-circuits if the new document equals the current one. +2. Replaces `@document`. +3. Calls `refreshAttachments` (diffs attachment lists, notifies delegate). +4. Increments `@revision`. +5. Notifies `@delegate.compositionDidChangeDocument`. + +### Editor — the public API + +`src/trix/models/editor.coffee` is a thin façade over `Composition`, +`SelectionManager`, and `UndoManager`. `element.editor` **is** an instance of +this. It is the external contract. + +Full method list in [03-architecture.md § Public API](#public-api) below. + +### Parsing and serialization + +- `html_parser.coffee` — parses HTML → `Document` by walking a hidden sandbox + DOM, classifying elements as block-level or inline, and recovering + attributes per `Trix.config.textAttributes` / + `Trix.config.blockAttributes` (`html_parser.coffee:181-220`). +- `html_sanitizer.coffee` — scrubs disallowed attributes; the default allow-list + is `"style href src width height class target"` plus anything starting with + `data-trix-` (`html_sanitizer.coffee:4, 45-49`). +- `src/trix/config/serialization.coffee` — defines `Trix.serializers` and + `Trix.deserializers` for `application/json` and `text/html`. The HTML + serializer renders the document to a DOM fragment via + `Trix.DocumentView.render`, strips internal attributes, applies + `data-trix-serialized-attributes` overrides, and returns `innerHTML` + minus `` markers. + +### Selection and undo + +- `selection_manager.coffee` — bridges `window.getSelection()` and Trix's + location ranges, with lock/unlock ref-counting so renders don't clobber + in-progress selection updates. +- `location_mapper.coffee`, `point_mapper.coffee` — convert between screen + points, DOM ranges, and Trix `LocationRange` / character `Range` arrays. +- `undo_manager.coffee` — two stacks of `{description, context, snapshot}`. + Consecutive entries with the same description and context are consolidated + (`undo_manager.coffee:6-12`) — that's how "type one word" is a single undo + step even though each keystroke records an entry. + +### Attachments + +- `attachment.coffee` — model with `attributes` (filename, url, contentType, + width, height, caption, …). +- `attachment_manager.coffee` — wraps attachments on add/remove; delegate + surface for upload progress. +- `managed_attachment.coffee` — the attachment object handed to user code in + `trix-attachment-add` events; proxies most methods to the underlying + `Attachment`. + +## Layer 4 — Controllers (`src/trix/controllers/`) + +Controllers wire the DOM event layer to the model layer. They do no rendering +themselves. + +### EditorController + +`src/trix/controllers/editor_controller.coffee`. The conductor. + +Constructor (`editor_controller.coffee:13-37`) wires: + +- `SelectionManager`, `Composition`, `AttachmentManager`, `InputController`, + `CompositionController`, `ToolbarController`, and a public `Editor`. +- Itself as the delegate for every sub-controller and model. +- `InputController.responder = Composition` — `InputController` mutates + `Composition` directly via its responder interface for performance. + +The delegate protocol is the real API surface between layers; every +`compositionDidX`, `inputControllerDidX`, `selectionManagerDidX`, and +`toolbarDidX` is implemented here. + +`notifyEditorElement(message, data)` (`editor_controller.coffee:345-356`) is +the funnel for all `trix-*` custom events; it also updates the hidden +`` on change/attachment events (`editor_controller.coffee:353-354`). + +**Actions** (`editor_controller.coffee:282-302`) — `undo`, `redo`, `link`, +`increaseNestingLevel`, `decreaseNestingLevel`. Each has a `test` (can it fire +now?) and `perform` (do it). External actions use the `x-` prefix convention +and fire `trix-action-invoke` instead of running locally +(`editor_controller.coffee:310-317`). + +### InputController + +`src/trix/controllers/input_controller.coffee`. Listens for keyboard, +paste, drop, focus/blur, composition events. Uses a `MutationObserver` +(`src/trix/observers/mutation_observer.coffee`) to reconcile unexpected DOM +mutations (IME, browser autocorrect, extensions) with Trix's model +expectations. + +There are two flavours: + +- `controllers/input/level_0_input_controller.coffee` — fallback for + browsers without `beforeinput`. +- `controllers/input/level_2_input_controller.coffee` — modern path using + `beforeinput` events. + +Both report to `EditorController` via the +`inputControllerWill*` / `inputControllerDid*` delegate methods +(`editor_controller.coffee:162-215`), which record typing/cut/paste undo +entries and coordinate render lifecycle. + +### CompositionController + +`src/trix/controllers/composition_controller.coffee`. Owns the +`DocumentView` instance. Calls `render` / `sync` on document change; manages +view caches and attachment editor installation. + +### ToolbarController + +`src/trix/controllers/toolbar_controller.coffee`. Wires DOM events on the +toolbar element to delegate callbacks: + +- Clicks on `[data-trix-attribute]` → `toolbarDidToggleAttribute(name)` or + open a dialog if one exists with that name + (`toolbar_controller.coffee:36-46`). +- Clicks on `[data-trix-action]` → `toolbarDidInvokeAction(name)` or open a + dialog (`toolbar_controller.coffee:26-34`). +- Clicks on `[data-trix-method]` inside a dialog → call the named method on + the controller itself (`toolbar_controller.coffee:48-51`) — this is how + the link dialog's "Link" button invokes `setAttribute(dialogElement)`. +- Keydown on dialog inputs → Enter submits, Escape closes + (`toolbar_controller.coffee:53-61`). + +Button state is synced via `updateAttributes(dict)` and `updateActions(dict)` +(`toolbar_controller.coffee:65-89`); buttons get `data-trix-active` when the +attribute is truthy, or disabled (`element.disabled = true`) when the +attribute value is explicitly `false`. + +### AttachmentEditorController + +`src/trix/controllers/attachment_editor_controller.coffee`. Installed while +an attachment is being edited; handles the caption textarea and remove +button. + +## Layer 5 — Views (`src/trix/views/`) + +Views are stateless renderers. Each view caches its produced DOM nodes; on +document mutation the view tree is invalidated and re-rendered to a shadow +DOM, then `sync()` clones the shadow into the real editor element. + +- `object_view.coffee` — base class. `getNodes()` memoises `createNodes()`. +- `document_view.coffee` — top-level. Groups blocks into an `ObjectGroup` + tree (respecting block nesting) and creates `BlockView` children. +- `block_view.coffee` — creates block-level containers (`

`, `

`, + `

`, `
  • `, `
    `, etc.) based on block attributes, then renders
    +  inner `TextView`.
    +- `text_view.coffee` — groups pieces that `canBeGrouped` together (the
    +  default is pieces sharing an `href` value — see `piece.coffee:82-87`) and
    +  creates `PieceView` children.
    +- `piece_view.coffee` — the attribute-to-DOM bridge. See
    +  [04-attribute-system.md § Rendering](04-attribute-system.md#5-rendering-model--dom).
    +- `attachment_view.coffee`, `previewable_attachment_view.coffee` — render
    +  `
    ` with cursor targets and data attributes. +- `object_group_view.coffee` — renders grouped pieces under a single + container (e.g. `` wrapping adjacent linked pieces). + +Rendering is **not** reconciliation-based (no virtual-DOM diff). The view +tree is always rebuilt from the new document; a content-addressed cache keeps +unchanged sub-trees fast (`object_view.coffee` cache key machinery). + +## Layer 6 — Observers (`src/trix/observers/`) + +- `mutation_observer.coffee` — wraps a `MutationObserver` on the + contenteditable element; filters out mutations on `[data-trix-mutable]` + nodes; summarises to `{textAdded, textDeleted}` for the input controller. +- `selection_change_observer.coffee` — singleton that polls + `document.getSelection()` (via `selectionchange` event where supported, + otherwise `requestAnimationFrame`) and notifies every registered + `SelectionManager`. + +## Layer 7 — Operations (`src/trix/operations/`) + +- `operation.coffee` (in core) — base class. `perform(callback)` is + subclassed; `getPromise()` wraps the imperative callback in a Promise. +- `file_verification_operation.coffee` — reads a file via `FileReader` to + verify it's accessible before attaching. +- `image_preload_operation.coffee` — loads an image and captures + `naturalWidth` / `naturalHeight` before inserting as an attachment. + +## Layer 8 — Config (`src/trix/config/`) + +Every file in `config/` extends `Trix.config` on import. The index +(`config/index.coffee`) requires them in order. + +| File | Exposes | +|---|---| +| `text_attributes.coffee` | `Trix.config.textAttributes` — bold, italic, href, target, strike, frozen. See [04-attribute-system.md](04-attribute-system.md). | +| `block_attributes.coffee` | `Trix.config.blockAttributes` — default, quote, heading1-3, code, bulletList/bullet, numberList/number, alignRight, alignCenter. | +| `toolbar.coffee` | `Trix.config.toolbar.getDefaultHTML()` — the inline template string for the default toolbar markup. | +| `lang.coffee` | `Trix.config.lang` — UI strings. | +| `css.coffee` | `Trix.config.css` — CSS class name map for attachment parts. | +| `attachments.coffee` | Attachment-related toggles (`enableImages` etc.). | +| `file_size_formatting.coffee` | Byte formatting for attachment captions. | +| `serialization.coffee` | `Trix.serializers`, `Trix.deserializers`, `Trix.serializeToContentType`, `Trix.deserializeFromContentType`. | +| `undo_interval.coffee` | `Trix.config.undoInterval` — how often typing coalesces into a single undo step (default 5000ms). | + +Config files can be mutated at runtime before calling `registerElement` or +before elements enter the DOM — this is how new attributes get registered. + +## Public API + +`element.editor` is a `Trix.Editor` (`src/trix/models/editor.coffee:3-141`). + +### Document loading / snapshots + +``` +loadDocument(document) +loadHTML(html) +loadJSON({document, selectedRange}) +loadSnapshot({document, selectedRange}) +getDocument() // => Trix.Document +getSelectedDocument() // => Trix.Document (selection only) +getSnapshot() // => {document, selectedRange} +toJSON() // snapshot as JSON-safe +``` + +### Mutation + +``` +insertText(text) // text: Trix.Text +insertString(string) // uses currentAttributes +insertHTML(html) +insertDocument(document) +insertAttachment(attachment) +insertFile(file) +insertLineBreak() +deleteInDirection("forward" | "backward") +``` + +### Selection + +``` +getSelectedRange() // => [start, end] +getPosition() // => number (cursor position) +getClientRectAtPosition(pos) // => DOMRect +setSelectedRange(range) +expandSelectionInDirection(dir) +moveCursorInDirection(dir) +``` + +### Attributes + +``` +activateAttribute(name, value = true) +deactivateAttribute(name) +attributeIsActive(name) // => boolean +canActivateAttribute(name) // => boolean +``` + +### Nesting (lists / quotes) + +``` +canIncreaseNestingLevel() +canDecreaseNestingLevel() +increaseNestingLevel() +decreaseNestingLevel() +``` + +### Undo / redo + +``` +canUndo() +canRedo() +recordUndoEntry(description, {context, consolidatable} = {}) +undo() +redo() +``` + +## Custom events + +All are dispatched on the `` element (bubble, cancelable). The +funnel is `editor_controller.coffee:345-356`; events fire via +`element.notify(message, data)` → +`triggerEvent("trix-#{message}", onElement: this, attributes: data)`. + +| Event | `event.*` payload | When | +|---|---|---| +| `trix-initialize` | — | Editor ready (`requestAnimationFrame` after `connect`). | +| `trix-change` | — | Document or attachment changed. Also refreshes the hidden input. | +| `trix-document-change` | — | Document mutated. | +| `trix-selection-change` | — | Cursor or selection moved. | +| `trix-attributes-change` | `attributes` | `currentAttributes` changed. This is how external UIs read active attributes (bold, href, and any custom attribute). | +| `trix-actions-change` | `actions` | Action availability changed. | +| `trix-action-invoke` | `actionName` | An `x-*`-prefixed external action fired. | +| `trix-attachment-add` | `attachment` (ManagedAttachment) | Attachment added. | +| `trix-attachment-edit` | `attachment` | Attachment attributes changed. | +| `trix-attachment-remove` | `attachment` | Attachment removed. | +| `trix-file-accept` | `file` | Called before accepting a dropped/pasted file. `preventDefault()` to reject. | +| `trix-before-paste` | `paste` (`{html, string, range}`) | Before paste is applied. | +| `trix-paste` | `paste` | After paste applied. | +| `trix-toolbar-dialog-show` | `dialogName` | Dialog opened. | +| `trix-toolbar-dialog-hide` | `dialogName` | Dialog closed. | +| `trix-focus` | — | Editor focused. | +| `trix-blur` | — | Editor blurred. | +| `trix-render` | — | Document re-rendered. | +| `trix-sync` | — | Shadow DOM synced into live element. | + +## End-to-end walkthroughs + +### Typing a character + +1. User types "a". The browser fires `beforeinput` / inserts into the DOM. +2. `Level2InputController` intercepts; `MutationObserver` also sees the DOM + change. +3. InputController calls `@responder.insertString("a")` — + `responder = Composition` (`editor_controller.coffee:25`). +4. `Composition.insertString` (`composition.coffee:58-61`) packs the string + with current attributes via `Text.textForStringWithAttributes` and calls + `insertText`. +5. `Composition.insertText` (`composition.coffee:34-42`) replaces `@document` + with `@document.insertTextAtRange(text, range)` and updates selection. +6. `setDocument` fires `compositionDidChangeDocument` to `EditorController`. +7. `EditorController.compositionDidChangeDocument` + (`editor_controller.coffee:47-49`) calls `@render()` unless in the middle + of handling input; `notifyEditorElement("document-change")` fires the + `trix-document-change` event. +8. When input handling ends, render happens. `CompositionController.render` + rebuilds the view tree and syncs; `trix-sync`, then `trix-change` fire. + The hidden input value is updated inside `notifyEditorElement` + (`editor_controller.coffee:353-354`). + +### Clicking Bold on a selection + +1. User selects "abc", clicks the toolbar Bold button. +2. `ToolbarController.didClickAttributeButton` + (`toolbar_controller.coffee:36-46`) calls + `delegate.toolbarDidToggleAttribute("bold")`. +3. `EditorController.toolbarDidToggleAttribute` + (`editor_controller.coffee:234-238`) records a "Formatting" undo entry + and calls `@composition.toggleCurrentAttribute("bold")`. +4. `Composition.toggleCurrentAttribute` (`composition.coffee:220-224`) + flips between `setCurrentAttribute("bold", true)` and + `removeCurrentAttribute("bold")`. +5. `setCurrentAttribute` → `setTextAttribute` → since selection is + expanded, `@setDocument(@document.addAttributeAtRange("bold", true, range))` + (`composition.coffee:260-268`). +6. `Document.addAttributeAtRange` (`document.coffee:344-355`) walks blocks in + the range, calls `block.text.addAttributeAtRange`, which splits pieces at + range boundaries and applies the attribute via + `piece.copyWithAdditionalAttributes` (`text.coffee:58-60`, + `piece.coffee:19-20`). +7. `compositionDidChangeDocument` → render → sync. +8. `compositionDidChangeCurrentAttributes` also fires (because + `@currentAttributes.bold = true`) — `ToolbarController.updateAttributes` + lights up the Bold button; `trix-attributes-change` event fires. + +### Pasting HTML + +1. `InputController` sees a paste, calls + `inputControllerWillPaste({html, string, range})` on delegate. +2. `EditorController.inputControllerWillPaste` + (`editor_controller.coffee:187-190`) records a "Paste" undo entry and + fires `trix-before-paste` (cancelable). +3. If not cancelled, `Composition.insertHTML(html)` (`composition.coffee:91-100`) + parses via `Document.fromHTML` → `Trix.HTMLParser.parse` + (`html_parser.coffee:23-32`), merges into the current document at the + selection range, moves selection past the inserted range. +4. `inputControllerDidPaste` fires `trix-paste` + (`editor_controller.coffee:192-196`). diff --git a/docs/04-attribute-system.md b/docs/04-attribute-system.md new file mode 100644 index 000000000..513638e7e --- /dev/null +++ b/docs/04-attribute-system.md @@ -0,0 +1,385 @@ +# The attribute system + +This is the mechanism you hook into to add new formatting (including the +colour feature in [05-color-feature-proposal.md](05-color-feature-proposal.md)). +Before touching it, read end to end — the seven stages form one feedback loop, +and a change in any one of them must be consistent with the rest. + +## Block attributes vs text attributes + +Two distinct mechanisms, same config shape. + +| | Block attributes | Text attributes | +|---|---|---| +| Defined in | `src/trix/config/block_attributes.coffee` | `src/trix/config/text_attributes.coffee` | +| Stored on | `Block.attributes: string[]` | `Piece.attributes: Trix.Hash` | +| Can carry a value | No (name only) | Yes (e.g. `href: "https://…"`) | +| Scope | Whole block (paragraph / list item / heading …) | Character range within a block | +| Examples | `quote`, `heading1`, `bullet`, `alignRight`, `code` | `bold`, `italic`, `href`, `strike`, `frozen` | +| Tag output | Container element (`
    `, `

    `, …) | Inline wrapper (``, ``, ``, ``) | + +A new colour attribute is a **text** attribute — it applies to a selection, not +to a whole paragraph. + +## Supported config keys + +### Text attribute keys (`src/trix/config/text_attributes.coffee`) + +| Key | Meaning | Example | +|---|---|---| +| `tagName: string` | Wrap each piece in this tag. | `bold: {tagName: "strong"}` | +| `groupTagName: string` | Group adjacent pieces with the same value under one tag. HTML attributes come from the attribute value (`href: "…"` → ``). | `href: {groupTagName: "a"}` | +| `style: {property: value}` | Apply **static** inline styles to a wrapping `` (or the `tagName` element if set). Values are literal, not the piece's attribute value. | `frozen: {style: {backgroundColor: "highlight"}}` | +| `styleProperty: string` | Apply the piece's attribute value as a **dynamic** inline style. | (not currently used upstream — this is exactly what the colour attribute needs) | +| `parser: (element) => value` | Recover the attribute from a DOM element when parsing HTML. Return a truthy value, or undefined. | `bold.parser` checks computed `fontWeight`. | +| `inheritable: boolean` | If true, the attribute is treated as "sticky" at the boundary between adjacent pieces — the toolbar shows it as active even when the cursor sits right after a styled run. See `document.coffee:585-588`. | `bold: {inheritable: true}` | + +### Block attribute keys (`src/trix/config/block_attributes.coffee`) + +| Key | Meaning | +|---|---| +| `tagName: string` | The wrapping block element (`
    `, `

    `, …). | +| `tagNames: string[]` | Multi-tag match; used with `test` for attributes like alignment that can apply to any block tag (`alignRight.tagNames: ["div", "p", "h1", "h2", "h3"]`). | +| `className: string` | Class to apply on the block element. | +| `parse: boolean` | If `false`, the parser never *produces* this attribute; it only exists as output (`default`, `bulletList`, `numberList` — you never ask for a "bulletList", you ask for a "bullet" and the list container is implied). | +| `nestable: boolean` | Block can nest inside itself (quotes, list items). | +| `terminal: boolean` | No further block attribute can nest inside this one. Code blocks and headings are terminal. | +| `breakOnReturn: boolean` | Return in this block exits the block rather than creating a new one of the same kind. Headings. | +| `inheritFromPreviousBlock: boolean` | When Return creates a new block, this attribute is copied. Used for alignments (`block_attributes.coffee:67-79`). Mechanism: `composition.coffee:538-542`. | +| `role: string` | Prevents multiple attributes with the same role co-existing on a block. `alignRight` and `alignCenter` both have `role: "alignment"`. | +| `listAttribute: string` | Names the container attribute a list item belongs to (`bullet.listAttribute: "bulletList"`). | +| `group: boolean` | If `false`, adjacent blocks with this attribute are **not** rendered in the same element. Headings and list items. | +| `test: (element) => boolean` | Additional DOM predicate during parsing. | +| `text: {plaintext: boolean}` | Tells `TextView` to treat this block's text as pre-formatted (no BR conversion). Used by code blocks. | + +## The seven stages of round-trip + +### 1. Declaration + +Declare the attribute in the config. This is the only "new code" place for +most attributes. + +```coffee +# src/trix/config/text_attributes.coffee:2-7 +bold: + tagName: "strong" + inheritable: true + parser: (element) -> + style = window.getComputedStyle(element) + style["fontWeight"] is "bold" or style["fontWeight"] >= 600 +``` + +Once this entry exists, **everything else is automatic** because the rest of +the system iterates `Trix.config.textAttributes` at runtime: + +- `getAllAttributeNames()` in `src/trix/core/helpers/config.coffee` returns + every registered name. +- `HTMLParser.getTextAttributes` iterates the config + (`html_parser.coffee:183-200`). +- `PieceView.createElement` iterates the piece's attributes + (`piece_view.coffee:57-76`). + +Runtime registration is possible too — `Trix.config.textAttributes.color = {...}` +before a `` enters the DOM behaves identically to hard-coding it. + +### 2. Toolbar wiring (optional) + +A toolbar button is just an HTML element with `data-trix-attribute`: + +```html + + +``` + +A value-requiring attribute uses a dialog instead of direct toggle: + +```html + + +``` + +with a dialog (`src/trix/config/toolbar.coffee:30-38`): + +```html +
    + + + +
    +``` + +For the colour feature the toolbar is bypassed entirely — the CMS provides +its own UI. No changes are needed to `config/toolbar.coffee`. + +### 3. Activation + +User flow: button click → toolbar delegate → editor controller → composition → +document. + +``` +ToolbarController.didClickAttributeButton toolbar_controller.coffee:36 + └─> delegate.toolbarDidToggleAttribute(name) +EditorController.toolbarDidToggleAttribute editor_controller.coffee:234-238 + ├─> recordFormattingUndoEntry() editor_controller.coffee:363-366 + └─> composition.toggleCurrentAttribute(name) +Composition.toggleCurrentAttribute composition.coffee:220-224 + └─> setCurrentAttribute(name, true) | removeCurrentAttribute(name) +Composition.setCurrentAttribute composition.coffee:252-258 + ├─> block path? setBlockAttribute + └─> text path: + setTextAttribute(name, value) + currentAttributes[name] = value # always cache, even for collapsed + notifyDelegateOfCurrentAttributesChange() +Composition.setTextAttribute composition.coffee:260-268 + ├─> collapsed selection + name === "href" → insert link text + ├─> collapsed selection + anything else → (no document mutation; the cache above arms the attribute for the next typed character) + └─> expanded selection → document.addAttributeAtRange(name, value, range) +``` + +Programmatic entry point for external callers (the CMS colour picker will use +this): + +``` +element.editor.activateAttribute(name, value = true) # editor.coffee:82-83 + └─> composition.setCurrentAttribute(name, value) +``` + +and + +``` +element.editor.deactivateAttribute(name) # editor.coffee:91-92 + └─> composition.removeCurrentAttribute(name) +``` + +Note: `activateAttribute` does **not** record an undo entry. +`toolbarDidToggleAttribute` does (via `recordFormattingUndoEntry`). External +callers that want undo support must call +`element.editor.recordUndoEntry("…", {context, consolidatable})` first. + +### 4. Storage in the document + +`Document.addAttributeAtRange` (`document.coffee:344-355`) walks every block +in the range; for text attributes it delegates to +`block.text.addAttributeAtRange` (`text.coffee:53-60`), which calls +`pieceList.transformObjectsInRange(range, fn)` +(`splittable_list.coffee:58-65`). + +`transformObjectsInRange` is where the magic happens: + +1. `splitObjectsAtRange(range)` splits pieces at the start and end boundaries + via `string_piece.coffee:36-46`. A piece "hello" split at offset 2 returns + `[<"he">, <"llo">]`, both carrying the original attributes. +2. Each piece within the range is replaced by + `piece.copyWithAdditionalAttributes(attrs)` (`piece.coffee:19-20`), which + merges the new attribute into the immutable `Trix.Hash`. +3. A new `Text` is built from the resulting list; `copyWithPieceList` calls + `pieceList.consolidate()` (`text.coffee:26-27`), which re-merges adjacent + pieces whose attribute hashes are now equal. + +Attributes live on `Piece.attributes`, a `Trix.Hash`: + +```coffee +# src/trix/models/piece.coffee:12-14 +constructor: (value, attributes = {}) -> + super + @attributes = Trix.Hash.box(attributes) +``` + +The hash is persistent: `merge`, `remove`, `slice` return new hashes, so +pieces can share structure across document versions. `isEqualTo` on two +hashes is an array comparison of `toArray()` (`hash.coffee:48-49`). + +### 5. Rendering (model → DOM) + +`PieceView.createNodes` (`piece_view.coffee:18-28`) calls `createStringNodes` +(plain text nodes) and then wraps them in whatever `createElement` produces. + +`PieceView.createElement` (`piece_view.coffee:54-76`) is the attribute-to-DOM +translator: + +```coffee +createElement: -> + styles = {} + + for key, value of @attributes when config = getTextConfig(key) + if config.tagName + pendingElement = makeElement(config.tagName) + # ... nest into the innermost existing wrapper ... + + if config.styleProperty + styles[config.styleProperty] = value + + if config.style + styles[key] = value for key, value of config.style + + if Object.keys(styles).length + element ?= makeElement("span") + element.style[key] = value for key, value of styles + element +``` + +Three routes, which combine: + +- `tagName` → creates a wrapping element. Multiple `tagName` attributes on + one piece produce nested wrappers. +- `styleProperty` → sets `element.style[prop] = pieceValue`. This is how the + colour attribute will render. +- `style` → applies a **static** style object (the attribute value is + ignored; only config-time values are used). + +If **only** style routes fire, the wrapper defaults to ``. If a +`tagName` exists, the style is applied to that element instead. For a piece +with `{bold: true, color: "#f00"}`, this produces ``. + +`PieceView.createContainerElement` (`piece_view.coffee:78-92`) is separate — +it handles `groupTagName`. This is how multiple adjacent `href`-carrying +pieces are wrapped in a single `
    `. Group wrappers carry HTML attributes +(from the attribute value passed to `makeElement`), not styles. + +Grouping at all is decided by `piece.canBeGrouped` / `canBeGroupedWith` +(`piece.coffee:82-87`), which hard-codes `href`. Adjacent colour-only pieces +with the same colour will consolidate (step 4) rather than group — you'll get +a single piece with a single `` wrapper, not multiple pieces grouped +together. + +Block-level rendering is orthogonal: `BlockView` creates the containing +element from block attributes and then inserts a `TextView`, which delegates +each piece to a `PieceView`. + +### 6. Serialization (document → HTML string) + +`Trix.serializers["text/html"]` (`src/trix/config/serialization.coffee:20-44`): + +``` +1. Render document via Trix.DocumentView.render → DOM fragment +2. Remove elements with [data-trix-serialize=false] +3. Remove attributes: contenteditable, data-trix-id, data-trix-store-key, + data-trix-mutable, data-trix-placeholder, tabindex +4. For elements with data-trix-serialized-attributes, parse the JSON and + apply those as real attributes (this is how attachments round-trip + their bag of data). +5. Return innerHTML, stripped of comments +``` + +Note that the serializer **does not** filter `style` attributes — inline +styles survive into the output. `class` also survives (used for alignment +block attributes: `text-align-right`, `text-align-center`). + +Hidden input synchronisation: `EditorController.updateInputElement` +(`editor_controller.coffee:340-343`) is called inside `notifyEditorElement` +on `change`, `attachment-add`, `attachment-edit`, `attachment-remove`; it +serialises the current `SerializableElement` and writes to +`element.inputElement.value`. Any code that reads the hidden input's value +(e.g. a form post, or the CMS reading saved content) gets the serialised HTML +with inline styles intact. + +### 7. Deserialization (HTML → document) + +`Document.fromHTML` → `Trix.HTMLParser.parse` → walks the sanitised HTML DOM. + +First, `HTMLSanitizer` scrubs disallowed attributes. Its allow-list +(`html_sanitizer.coffee:4`) is: + +``` +style href src width height class target +``` + +Anything starting with `data-trix-` is also allowed +(`html_sanitizer.coffee:45-49`). Everything else (e.g. `onclick`, `style-xyz`) +is stripped on load. Elements with `data-trix-serialize="false"` and ` + + + + + + + +
    +

    Trix token attributes — font / colour / underline

    +

    + Three independent text attributes registered at runtime, each + emitting its own data-*. Apply any combination; they + stack on a single element. Theme mapping (font families, colours, + underline patterns) lives in CSS at the top of this file. +

    + +

    Font

    +
    + data-font + + + + +
    + +

    Colour

    +
    + data-color + + + + + +
    + +

    Underline

    +
    + data-underline + + + + + + +
    + +
    no selection
    + +
    + + +
    + +
    +
    +

    Active attributes at cursor

    +
    (nothing yet)
    +
    +
    +

    Serialized HTML (hidden input value)

    +
    (nothing yet)
    +
    +
    + +
    + + + +
    +
    + + + +