diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..b608f998 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,162 @@ +# Handoff — cropping, gradients and text docs + +Working notes so this can be picked up on another machine. **Delete this file +before merging** — it is scratch, not site content. + +## Where things stand + +Two branches, both pushed to origin. + +### `cropping-demos` — pushed, ready for a PR + +Open at https://github.com/fabricjs/fabricjs.github.io/pull/new/cropping-demos + +| Commit | | +| --- | --- | +| `ae50fb4` | feat(demos): add cropping controls demo | +| `c2df674` | docs(cropping): trim the cropping images guide | +| `86b7068` | progress *(yours)* | +| `f73866b` | docs(cropping): explain how enterCropMode is built | +| `b44affd` | chore: bump fabric.js submodule to v7.4.0 | +| `ac74579` | chore(api): regenerate reference for v7.4.0 | +| `4fc3ecd` | feat(gradients): add gradient controls demo and using gradients guide | + +Contains: the cropping controls demo, the gradient controls demo, the rewritten +`cropping-images` guide, the new `using-gradients` guide, the fabric bump to +7.4.0 (package + submodule), and the 454-file API reference regeneration. + +### `using-text` — pushed, in progress + +| Commit | | +| --- | --- | +| `b3f33be` | docs(text): add the Text guide group | +| `0047eba` / `82f6b63` | *(yours — added then removed `.claude/launch.json`)* | + +Nine pages under `src/content/docs/docs/Text/`, orders 310–390, rendering as a +collapsible sidebar group between `Getting started` and `Upgrading`. + +## Resuming on another machine + +```bash +git fetch origin +git checkout using-text +git submodule update --init --recursive # must land on tag v740 +npm install +npm run dev +``` + +Two environment facts that will otherwise waste your time: + +- **`westures` is a devDependency now, and it is required.** `fabric/extensions` + re-exports its westures gesture integration unconditionally, but fabric + declares `westures` nowhere. Without it, importing `fabric/extensions` dies + with `ERR_MODULE_NOT_FOUND` and takes the whole dev server down. +- **The submodule must be at `v740`.** Tags are named `v740`, not `v7.4.0`. The + API reference is generated from it, so a mismatch produces a large spurious + diff under `src/content/docs/api/`. + +## Text docs — the nine pages + +`src/content/docs/docs/Text/` + +| Order | File | Notes | +| --- | --- | --- | +| 310 | `choosing-a-class.mdx` | The three classes as a chain; only `set()` re-lays-out; the three index spaces | +| 320 | `layout-and-appearance.mdx` | interactive canvas | +| 330 | `fonts.mdx` | The measurement cache and the late-webfont failure. The most load-bearing page | +| 340 | `character-styles.mdx` | interactive canvas | +| 350 | `editing.mdx` | interactive canvas | +| 360 | `textbox-wrapping.mdx` | interactive canvas | +| 370 | `editing-ui.mdx` | The integration cookbook — toolbar focus, undo/redo, mobile, a11y | +| 380 | `text-on-a-path.mdx` | Beta limits | +| 390 | `serializing-text.mdx` | Style compression lossiness, SVG asymmetry | + +Interactive examples live in a sibling directory per page, e.g. +`Text/editing/code.js`, imported as `./editing/code.js?raw`. From a page inside +the group, `CodeEditor` is four levels up: `../../../../components/CodeEditor`. + +## Library findings worth taking upstream + +These came out of verifying doc claims against a running build. All reproduced. + +1. **`fabric/extensions` has an undeclared dependency on `westures`.** Not in + `dependencies`, `peerDependencies` or `optionalDependencies`. A downstream + user importing only the cropping helpers still hits it. + +2. **`toObject()` throws on a style declaration holding only unknown keys.** + + ```js + new fabric.FabricText('abcd', { styles: { 0: { 0: { mine: 'x' } } } }).toObject(); + // TypeError: Cannot read properties of undefined (reading 'end') + ``` + + `stylesToArray` tries to extend a range that was never opened, because + `hasStyleChanged` only compares the 14 known properties. Relevant to anyone + adding a custom per-character style property. + +3. **CRLF shifts styles by one character per line on a JSON round trip.** + `stylesToArray` splits on `'\n'`, `stylesFromArray` on `/\r?\n/`. + + ```js + const t = new fabric.FabricText('ab\r\ncd', { styles: { 1: { 0: { fill: 'red' } } } }); + (await fabric.FabricText.fromObject(t.toObject())).styles; + // { 1: { 1: { fill: 'red' } } } ← moved to character 1 + ``` + +4. **`ctrlKeysMapUp[88]` points at a `cut` method that does not exist.** Works in + practice only because `_copyDone` short-circuits `onKeyUp` first. + +5. **`renderGhostImage` and `cropPanMoveHandler` are not exported** from + `fabric/extensions`, so the crop ghost cannot be used with a control set you + assigned yourself — only via `enterCropMode`. Exporting them would make the + "recompose the pieces yourself" story in the docs actually true. + +6. **A gradient colour stop at offset 0 or 1 sits exactly under an axis endpoint + handle** and shadows it in hit-testing, so that endpoint becomes hard to grab. + +7. **`Textbox`'s class JSDoc claims Y scaling is locked.** It is not — `mt`, `mb` + and the corner handles all scale. The doc comment is stale. + +## Corrections already applied to the docs + +Three claims that source-reading got wrong and testing caught. Mentioning them +because the same reasoning traps are easy to fall back into: + +- An assigned `Textbox` **height is not rejected** — it survives until the next + layout pass, then is overwritten. +- `setSelectionStyles({ prop: undefined })` **deletes the override** and reverts + to the object value. It is not a no-op, and it differs from passing `false`. +- Custom style properties are dropped when merged into a run, but **crash** when + they are the only key (see finding 2). + +## Open follow-ups + +- **Open the `cropping-demos` PR.** Nothing blocking it. +- **Supersede the Old docs text pages** now that the Text group exists: + `Old docs/fabric-text.md`, the `### Text` section of `fabric-intro-part-2.md`, + and the Node fonts section of `fabric-intro-part-4.md`. The precedent is the + `'/docs/old-docs/fabric-object-caching'` redirect already in + `astro.config.mjs`. +- **Fold the `loading-custom-fonts` demo prose into `Text/fonts.mdx`** and leave + the demo as the showcase, per the docs-vs-demos split in `AGENTS.md`. +- **A rich-text toolbar demo** would be the natural showcase for `editing-ui.mdx`. +- **Standardise demo tags** — currently `text`, `text styles`, `fonts`, + `typography` are used inconsistently. +- **Collapsible / expandable `CodeEditor`** — your idea from the cropping work: + some canvases want the code minimised by default, with the interaction + foregrounded. +- Dependabot reports 64 vulnerabilities on the default branch (28 high). Pre-existing. + +## Verifying interactive pages + +The canvases register themselves on `window.canvasesId[canvasId]`, which makes +them scriptable from the browser console — much more reliable than eyeballing: + +```js +const c = window.canvasesId['text-editing']; +c.getObjects()[0].enterEditing(); +``` + +One trap when simulating drags: dispatch `mouseup` on **`document`**, not +`window`. Fabric does not see a window-level mouseup, so `_currentTransform` +stays active and every later drag silently continues the first handle. diff --git a/src/content/docs/docs/Text/character-styles.mdx b/src/content/docs/docs/Text/character-styles.mdx new file mode 100644 index 00000000..70552184 --- /dev/null +++ b/src/content/docs/docs/Text/character-styles.mdx @@ -0,0 +1,237 @@ +--- +date: '2026-07-30' +title: 'Styling individual characters' +description: 'The styles object, styling a range, and how styles survive editing' +sidebar: + order: 340 +--- + +import { CodeEditor } from '../../../../components/CodeEditor'; +import code from './character-styles/code.js?raw'; + +What makes Fabric.js text *rich* text is that any of a small set of properties can +be overridden per character. This page is the data model behind that. + + + + + +## The styles object + +`styles` is a sparse, two-level map: **logical line index**, then **grapheme index +within that line**. + +```js +text.styles = { + 0: { + 0: { fontWeight: 'bold' }, + 1: { fontWeight: 'bold' }, + }, + 2: { + 5: { fill: 'red' }, + }, +}; +``` + +A missing line means no styles on that line. A missing character means it inherits +from the object. So `styles` records only the differences, never a complete +picture — which is why reading a character's *effective* value needs a helper +rather than a lookup. + +Two things about the indices: + +- Character indices count **graphemes**, not UTF-16 units. An emoji or a flag + occupies one style slot even though `text.length` counts several. +- Line indices are **logical** lines — the ones separated by `\n` in your text. + For a `Textbox`, the lines you *see* are wrapped lines, and those are a + different index space. See [wrapping text with Textbox](/docs/text/textbox-wrapping). + +## What can be styled + +Exactly fourteen properties: + +`fontSize`, `fontWeight`, `fontFamily`, `fontStyle`, `underline`, `overline`, +`linethrough`, `stroke`, `strokeWidth`, `fill`, `deltaY`, `textBackgroundColor`, +`textDecorationThickness`, `textDecorationColor`. + +Anything not in that list is object-wide only. In particular **`textAlign`, +`lineHeight`, `charSpacing`, `direction`, `shadow` and `opacity` cannot vary per +character** — so there is no per-paragraph alignment within a single text object. +If your editor needs mixed alignment, use one object per paragraph. + +`deltaY` shifts a character's baseline in pixels, and is how superscript works. + +## Reading and writing a range + +```js +// read +const styles = text.getSelectionStyles(startIndex, endIndex); +const complete = text.getSelectionStyles(startIndex, endIndex, true); + +// write (merges into whatever is already there) +text.setSelectionStyles({ fontWeight: 'bold' }, startIndex, endIndex); +``` + +Indices are graphemes, `start` is inclusive and `end` is **exclusive**. On an +`IText` both arguments default to the current selection, which is what makes a +toolbar button a one-liner: + +```js +itext.setSelectionStyles({ underline: true }); +``` + +The `complete` flag fills in the object-level value for every property that isn't +overridden, so you get all fourteen keys back. Use it when you need to know what a +character actually looks like; omit it when you want to know what was explicitly +set. + +### Three traps in this API + +**Omitting `endIndex` silently does nothing.** The documentation suggests it +defaults to `startIndex + 1`; it does not — the internal loop runs from `start` to +`end` and with `end` missing it never executes. `getSelectionStyles(3)` returns an +empty array and `setSelectionStyles({...}, 3)` sets nothing. Always pass an +explicit end. For the same reason `endIndex: 0` is falsy and behaves like omitting +it. + +**`undefined` and `false` do different things, and neither is a no-op.** Passing +`undefined` *removes the override*, so the character falls back to the object-level +value. Passing `false` writes an override of `false`. When the object's own value is +`true`, the two give opposite results: + +```js +const text = new fabric.FabricText('abc', { + underline: true, // object-level + styles: { 0: { 0: { underline: false } } }, // character 0 overrides it +}); + +text.setSelectionStyles({ underline: undefined }, 0, 1); +// override deleted → character 0 is now underlined, like the rest + +text.setSelectionStyles({ underline: false }, 0, 1); +// override kept → character 0 stays not underlined +``` + +So `undefined` means "inherit from the object" and `false` means "force off". Other +properties in the same declaration are untouched either way. To strip a property +from every character at once, use `text.removeStyle('underline')`. + +**`setSelectionStyles` does not re-measure immediately.** It marks the object for +re-measurement at the next render instead. Between your call and that render, +`width`, `height` and `getBoundingRect()` are stale — which bites when you +reposition a toolbar or an overlay right after applying a style. If you need +correct dimensions now: + +```js +text.setSelectionStyles({ fontSize: 60 }, 0, 4); +text.initDimensions(); +text.setCoords(); +``` + +## Reading an effective value + +To find out what a character actually renders as — style override if present, +object value otherwise — use `getValueOfPropertyAt`: + +```js +const size = text.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize'); +``` + +Reading `text.fontSize` ignores styles, and reading `text.styles[l][c].fontSize` +ignores the fallback. This helper is the only correct route, and it takes *line +and character* coordinates rather than a flat index — convert with +`text.get2DCursorLocation(index, true)`. + +Do not mutate what the lower-level style getters hand back. They return a live +reference when a style exists and a throwaway empty object when it does not, and +you cannot tell which from the outside — so treat results as read-only. + +## Housekeeping + +Styles accumulate redundancy fast, especially through copy and paste, which +records the *complete* style of every copied character. Two helpers clean up: + +- **`text.cleanStyle(property)`** removes per-character values equal to the + object's value, prunes the empties, and — if every character agrees — hoists the + value onto the object and drops the per-character entries. Note that it can + therefore change the object-level property. +- **`text.removeStyle(property)`** deletes that property everywhere. + +`text.styleHas(property)` tells you whether a property appears anywhere, and +`isEmptyStyles(lineIndex)` whether a line has any styling at all. Both are used +internally to pick faster rendering paths. + +## Superscript and subscript + +```js +text.setSuperscript(startIndex, endIndex); +text.setSubscript(startIndex, endIndex); +``` + +Both are conveniences over `fontSize` and `deltaY`, controlled by the +`superscript` and `subscript` objects (`{ size: 0.6, baseline: -0.35 }` and +`{ size: 0.6, baseline: 0.11 }`). + +Two consequences of being computed rather than semantic: applying it twice +compounds — the size multiplies by 0.6 each time — and because `deltaY` is stored +in pixels, changing `fontSize` afterwards does not rescale the shift. There is no +`unsetSuperscript`; you reset `fontSize` and `deltaY` yourself. + +Also note that the default `superscript`, `subscript` and `offsets` objects are +shared between instances, so mutating one in place affects every text object +created afterwards. Replace the whole object instead: + +```js +text.superscript = { size: 0.7, baseline: -0.4 }; // not text.superscript.size = 0.7 +``` + +## How styles survive edits + +When text is inserted or deleted, style keys have to shift with it. Fabric.js +handles this for you as long as you go through the right API: + +- **`text.insertChars(chars, styleArray, start, end)`** — inserts, optionally with + one style object per inserted grapheme. +- **`text.removeChars(start, end)`** — deletes and reflows the remaining styles. + +Both re-measure and re-position afterwards. Prefer them over `set('text', …)`, +which replaces the string without reflowing `styles` at all. + +Two behaviours worth knowing because they are visible to users: typing inherits +the style of the character to the **left** of the caret, and pressing Enter at the +end of a line deliberately does not drag the previous character's style onto the +new line. Clearing the text entirely discards all styles. + +Neither `insertChars` nor `removeChars` syncs the hidden textarea or fires +`changed`, so calling them mid-edit needs care — see +[building a text editing UI](/docs/text/editing-ui). + +## Adding your own style property + +You can extend the styleable set on a subclass: + +```js +class MyText extends fabric.IText { + static _styleProperties = [...fabric.IText._styleProperties, 'myProp']; +} +``` + +That is enough for `getSelectionStyles(…, true)` and `getValueOfPropertyAt` to see +it. You then override `_renderChar` to draw it, add it to `cacheProperties` if an +object-level change should repaint, and to `textLayoutProperties` if it affects +measurement. + +**There is a real limit here, so plan for it.** The internal comparison that +decides "did the style change between these two characters" has a hard-coded list +of the fourteen properties and no override hook. Two consequences: + +1. Adjacent characters differing *only* in your custom property are drawn as one + run, using the first character's value. +2. Serialisation compresses runs the same way, so your property's variation is + **dropped by `toObject()`** — and a declaration containing *only* unknown + properties makes `toObject()` throw. Never let your property be the sole + occupant of a declaration. + +The workaround is to serialise `styles` yourself — the loader already accepts the +uncompressed object form — or to ensure your property never varies independently +of a known one. See [serializing and exporting text](/docs/text/serializing-text). diff --git a/src/content/docs/docs/Text/character-styles/code.js b/src/content/docs/docs/Text/character-styles/code.js new file mode 100644 index 00000000..e37658f9 --- /dev/null +++ b/src/content/docs/docs/Text/character-styles/code.js @@ -0,0 +1,39 @@ +const canvas = new fabric.Canvas(canvasEl); + +const text = new fabric.IText('Rich text on canvas', { + fontSize: 34, + fill: '#334155', +}); + +// styles is keyed by logical line, then by grapheme index within that line. +text.styles = { + 0: { + 0: { fontWeight: 'bold' }, + 1: { fontWeight: 'bold' }, + 2: { fontWeight: 'bold' }, + 3: { fontWeight: 'bold' }, + 5: { fill: '#7c3aed', fontStyle: 'italic' }, + 6: { fill: '#7c3aed', fontStyle: 'italic' }, + 7: { fill: '#7c3aed', fontStyle: 'italic' }, + 8: { fill: '#7c3aed', fontStyle: 'italic' }, + 13: { underline: true, textBackgroundColor: '#fef08a' }, + 14: { underline: true, textBackgroundColor: '#fef08a' }, + 15: { underline: true, textBackgroundColor: '#fef08a' }, + 16: { underline: true, textBackgroundColor: '#fef08a' }, + 17: { underline: true, textBackgroundColor: '#fef08a' }, + 18: { underline: true, textBackgroundColor: '#fef08a' }, + }, +}; + +// Assigning styles in place does not re-measure, so do it explicitly. +text.initDimensions(); +text.setCoords(); + +canvas.add(text); +canvas.centerObject(text); + +// The supported way to style a range: indices are graphemes, end is exclusive. +text.setSelectionStyles({ fontSize: 44 }, 0, 4); +text.initDimensions(); +text.setCoords(); +canvas.requestRenderAll(); diff --git a/src/content/docs/docs/Text/choosing-a-class.mdx b/src/content/docs/docs/Text/choosing-a-class.mdx new file mode 100644 index 00000000..7ba09527 --- /dev/null +++ b/src/content/docs/docs/Text/choosing-a-class.mdx @@ -0,0 +1,150 @@ +--- +date: '2026-07-30' +title: 'Text objects: choosing a class' +description: 'FabricText, IText and Textbox — what each one adds and which to use' +sidebar: + order: 310 +--- + +Fabric.js has three text classes, and they form a chain rather than a menu: + +``` +FabricText → IText → Textbox +``` + +Each one inherits everything from the previous, so the question is never "which +features do I get" but "how far along the chain do I need to go". + +## FabricText + +The base class. It renders text and nothing else: no cursor, no keyboard, no +mouse handling. Everything about how text *looks* lives here — fonts, spacing, +alignment, decorations, per-character styles — and the other two classes inherit +all of it. + +```js +const label = new fabric.FabricText('Read only', { + fontFamily: 'Helvetica', + fontSize: 24, +}); +``` + +Reach for it when the text is content rather than input: labels, watermarks, +captions, generated output. It is the cheapest of the three and cannot be typed +into even if the canvas is interactive. + +## IText + +`IText` — the *I* is for interactive — adds the entire editing experience: + +- a caret with a blink animation, and a rendered selection +- keyboard input through a hidden `