- TypeScript 94.7%
- SCSS 2.5%
- MDX 1.6%
- JavaScript 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
* feat(editor): sticky notes * clamp sticky note font size * [dragInput] reject non-finite values * chore: prettier formatting after rebase Co-Authored-By: Claude Fable 5.1 <[email protected]> * chore(editor): sticky notes cleanup (plan §7.1) - hide `transparent` in the sticky stroke picker through master's `excludedColors` (#11872) instead of the branch's own `hiddenPaletteColorNames`; the swatch becomes a grid-preserving placeholder, its hotkey stays dead, and arrow-key navigation steps over it in the arrow's direction (unit + integration tests) - move the text pixel-snap change out to `dwelle/text-pixel-snap` (705e497e2): it affects every text element at every zoom and is not zoom-aware, so it lands separately - rename `STICKY_NOTE_DEFAULT_FONT_SIZE` → `STICKY_NOTE_FALLBACK_FONT_SIZE`: it is only the non-finite fallback; new sticky text seeds its ceiling from `currentItemFontSize` (decision D3) Co-Authored-By: Claude Fable 5.1 <[email protected]> * feat(editor): one sticky note layout core + resize intents (plan §7.2, 1/2) - `getStickyNoteLayout` is the single fit/grow/position calculation (pure; ceiling-anchored size grid, warm-started binary search instead of the linear descent); `updateStickyNoteLayout` applies it to a scene and runs the bound-arrow pass by default (callers forward `simultaneouslyUpdated` or opt out when they own a pass); `relayoutStickyNotes` applies it inside immutable element arrays, identity- and version-preserving - resize intents are absolute values recomputed from the gesture-start snapshot on every pointer-move (`getStickyNoteResizeIntent`): width-only gestures keep the base height, height gestures set it, proportional gestures (Shift on any handle, aspect-locked multi-select, Stats group) also scale the font ceiling, flips preserve everything; the content correction anchors at the held edge (n* → bottom, Alt → center, Shift+E/W → center). The corner pre-fit clamp and the multi-resize sentinel/veto are gone: one fit per pointer-move - arrows bound to a note follow text-driven growth, and multi-resize / Stats group update them after the content correction, not before - `fontSizeMax` is `number | null`; every read/write goes through the container-aware `getUserFontSize` / `getUserFontSizeUpdate` (a detached label with a stale ceiling neither shows nor keeps writing it); unbind clears it and returns an empty note to its base height; restore seeds or clears it after binding repair and normalizes a sticky label's stroke - paste styles lays out a restyled note and its label together in a post-pass (no phantom-container mismatch) and reads the source ceiling from the copied snapshot rather than the fitted size - `redrawTextBoundingBox` / `handleBindTextResize` delegate sticky notes up front (no wasted generic wrap); the `refreshTextDimensions` sticky arm and the wysiwyg duplicate arm are gone; `clampStickyNoteProps` becomes the identity-preserving `normalizeStickyNote` Tests: content-pinned corner drag keeps the requested base, Shift+E scales note/base/ceiling, width-only drag preserves the base of a grown note, Shift released mid-gesture restores gesture-start values, flipping a rotated grown note keeps base/ceiling/label angle, multi-resize syncs an empty note's base. Co-Authored-By: Claude Fable 5.1 <[email protected]> * feat(editor): sticky note invariants in changeProperty + regression tests (plan §7.2, 2/2) - `changeProperty` normalizes sticky notes after any property write (identity-preserving `normalizeStickyNote`), replacing the three per-action wraps in the background, sloppiness and roundness actions - tests (new `tests/stickyNotes.test.tsx` + restore): arrows bound to a note follow text-driven growth while typing; binding free text seeds the ceiling and unbinding clears it (`null`) and returns the note to its base height; pasting styles copies the user's ceiling and lays out the note together with its label; unselected notes keep object identity through property actions; a transparent background write is normalized; restore clears a stale ceiling on unbound text and seeds ceiling + visible stroke on a sticky label Co-Authored-By: Claude Fable 5.1 <[email protected]> * feat(editor): sticky note creation feel + sticky color domain (plan §7.3) Creation - a note starts as a 0×0 draft inserted like every other tool and is previewed at its true dragged size (no jump to 40px on the first move); a gesture under the drag threshold is a click → the default 250×250 square centered on the pointer; a longer drag snaps to the 40×40 minimum only on release, growing away from the origin corner. The preview suppression flag and the pointer-move sticky arm are gone - `normalizeStickyNote` splits into style (constructor + every pass) and geometry (finalize, restore, action passes) so a draft is legal Colors — sticky notes are their own color domain (decision D2) - new `currentItemStickynoteBackgroundColor` default (yellow) next to the stroke one; both persisted/restored non-transparent; new notes and their labels seed from the sticky defaults (a label takes the note's stroke) - own top-pick slots (`colorTopPicks.stickyNoteStroke/Background`) and classic note picks; transparent excluded like bucket fill - one `resolveColorTarget` (regular / sticky / mixed, incl. the text being edited) resolved at execution time in the stroke and background actions' `perform`; the panel only sends `{ color }`, so the memoized picker's stale closure can no longer write the wrong tool's default (A1) - one `getColorUpdate` policy used by the actions, both eyedroppers (which now include bound labels, so a stroke pick colors a note's visible text — 1.12), paste styles and bind-existing-text (a transparent label bound to a note takes the note's text color — A3); the per-surface forks and `isStickyNoteStrokeColorElement` are gone Tests: click under the threshold, 20px drag previews at 20 and snaps to 40 with the origin corner fixed (both directions); closed-popup top pick after a tool switch writes the sticky default; mixed selection writes both domains; notes are created from the sticky defaults; bind normalizes a transparent label; the keyboard eyedropper colors a note's label. App-state snapshots gain the three new keys. Co-Authored-By: Claude Fable 5.1 <[email protected]> * feat(editor): font-aware minimum sticky note size The 40×40 floor let a freshly placed note grow on the very first keystroke: one line at the default 20px ceiling plus the 16px padding needs 57px. - `STICKY_NOTE_MIN_SIZE = 75` replaces the width/height pair as the data floor (normalization, layout, restore) - `getStickyNoteMinSize({ fontSize, fontFamily })` = max(75, round(ceiling × lineHeight) + 2 × padding) is the UI floor: the creation snap on release uses the current font size/family, the single resize floor uses the label's ceiling (constant floor for an empty note). Excalifont: 75 up to the L ceiling, 77 at 36px, 92 at 48px Tests: the 20px drag now snaps to 75; a 48px ceiling yields a 92px note that keeps its height on the first character. Co-Authored-By: Claude Fable 5.1 <[email protected]> * test(editor): sticky note regression matrix (plan §7.4) + two Stats fixes Tests (27 new): Alt/center resize keeps the center of a content-pinned note; proportional multi-select scales the label's ceiling, a free multi-select of an empty note uses the requested height; a proportional gesture returning to scale 1 restores everything; rotated growth keeps the top edge; the fit takes ≤ 2 measurements on a warm keystroke and ≤ 12 on a cold search from a 512 ceiling; a lowered ceiling wins over a still-fitting old size; odd/fractional ceilings stay reachable on the ceiling-anchored grid; the font-aware minimum size. Bound arrows follow the note on unbind, after a content-constrained resize and after a font-size action, and are not moved twice when resized as part of the same selection. A top pick while editing a note's label targets the label and the sticky default; the preview eyedropper colors a selected note's label; paste styles resolves a since-deleted source's ceiling from the copied snapshot; a detached label with a stale ceiling neither shows nor writes it; duplication keeps the ceiling and base height; an empty note escaped from editing stays and is removed by undo; Stats W keeps the base height and a group W edit scales the ceiling; restore normalizes both sticky defaults, keeps the new top-pick slots and refits note + label under `refreshDimensions`; unit tests for `resolveColorTarget` (regular / sticky / mixed / editing) and the default normalization. Source fixes the tests surfaced: - Stats `DragInput`: the typed-value path handed callbacks the live elements map as "original", so gesture-start values (a note's width, its label's ceiling) read as the already-applied values; it now snapshots the scene like the pointer-drag path does - Stats group resize (`MultiDimension`): a group unit lists the note's label too and scaled it as a standalone element after the note, wiping the fitted size; the note's layout owns its label Co-Authored-By: Claude Fable 5.1 <[email protected]> * feat(editor): sticky note skeleton API, tool hint, help row, docs (plan §7.5) - `convertToExcalidrawElements` accepts `{ type: "stickynote", x, y, label?, ... }` (new `ValidStickyNote` skeleton member): the note is constructed through `newStickyNoteElement` + `normalizeStickyNoteGeometry` (250×250 default, `baseHeight` finalized, sticky color invariants), and a label is bound through the sticky fit so its `fontSize` becomes the ceiling and the note grows only if the text overflows at the minimum size (decision D7) - tool hint for the sticky note tool ("Click to place a note, or drag to size it") and the `N` shortcut row in the help dialog - dev-docs: "Sticky Notes" section in the element-skeleton page; CHANGELOG entry (PR number placeholder) Tests: skeleton defaults, invariant enforcement on given props, label binding with the ceiling and the note's text color, growth for an overflowing label. Co-Authored-By: Claude Fable 5.1 <[email protected]> * refactor: move the seeded PRNG (mulberry32) to @excalidraw/common Pure utility next to `randomInteger`/`reseed`; the sticky note corner jitter imports it from there. Co-Authored-By: Claude Fable 5.1 <[email protected]> * refactor(editor): drop the dead roughjs Drawable for sticky notes Nothing ever generated or drew it: both renderers paint a note directly from `getStickyNoteRenderPoints`, and every `ShapeCache` consumer is type-gated to the rectangle family or linear elements. It also disagreed with the rendered outline (generic corner radius, no jitter). Sticky notes now sit with text/image/frame in the `null`-shape group, which the cache treats as a valid value. Co-Authored-By: Claude Fable 5.1 <[email protected]> * feat(editor): sticky note creation-date footer Every note reserves a 32px band under its label body and paints the note's `created` date there, on canvas and in SVG export: "7 Sep" while the year is the current one, "7 Sep 2025" otherwise, nothing for an unknown timestamp (the band stays reserved so geometry never depends on data availability). The label form is chosen by width bucket, never measured, so painting stays measurement-free — server-side SVG export has no text measurer — and an absolute date means exports never go stale. - `STICKY_NOTE_FOOTER` and `STICKY_NOTE_BODY_INSET_Y` in @excalidraw/common; `getStickyNoteDateLabel` / `getStickyNoteFooter` in stickyNote.ts - the layout, `getBoundTextMaxHeight`, the text editor and both renderers share the one inset; a rotated label rotates about the note center, not the now-asymmetric body center - `getStickyNoteMinSize` returns `{ width, height }`; creation and resize clamp each axis to its own floor (a Shift drag stays square) - tests: date label, width buckets, body height, resize floor, SVG + canvas export, creation minimums Plan: sticky_notes_review_final.md §9. Co-Authored-By: Claude Fable 5.1 <[email protected]> * feat(editor): image tool into "More tools", letter shortcut badges With the sticky tool on N between text (8) and eraser (0), the numbered badge row no longer reads as a sequence. On desktop and tablet, move the image tool into the "More tools" menu (first entry, still `9`, `UIOptions.tools.image` keeps hiding it) and badge the toolbar tools with their letter shortcuts (V R D O A L P T N E) instead of numbers. The number keys keep working; tooltips and the help dialog are unchanged. The mobile toolbar keeps its image slot. - Toolbar.tsx: image item in `ExtraToolsDropdown` (trigger highlight and icon while active), `UIOptions` passed through for the gate - Tools.tsx: `keyBindingLabel` prefers the letter - tests: `UI.clickExtraTool`, image test uses it, toolbar describe in tool.test.tsx (menu placement, UIOptions gate, letter badges) Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(editor): a sticky note and its label share one ink The creation-date footer paints from the note's `strokeColor`, the label from its own, and three writes let them drift: coloring the label while editing it (the wysiwyg clears the selection, so `changeProperty` targets only the edited text), binding an already-colored text (only transparent was rewritten), and paste styles from a source whose container and label carry two colors. Red text under a black date. The note's `strokeColor` is its ink — the seed for a new label and what the footer paints with — and the label always mirrors it: - `syncStickyNoteInk(elements, prevElementsMap)`: after a write, copies the side that changed onto the other; when both changed or the data drifted, the label wins (it is the text the user styled); a transparent label takes the note's color; identity-preserving when nothing changes - entrances: the stroke action (after `changeProperty`, which covers edit mode), paste styles (before the relayout), bind text (the text's color wins, transparent falls back), the skeleton path (a label that sets its own color gives it to the note), restore (label wins over a drifted note) - the stroke picker reads "Text color" for notes (`labels.textColor`) - tests for the helper and for every entrance Plan: sticky_notes_review_final.md §12. Co-Authored-By: Claude Fable 5.1 <[email protected]> * make footer opacity=1 * chore: fix sticky note test import order * feat(editor): drag a tool out of the toolbar to drop a default-sized element Press a tool button and drag onto the canvas: a default-sized element follows the pointer as a translucent preview and is placed where the pointer is released. Sticky notes opt in; the mechanism is generic. `App.toolDrag.ts` owns the gesture. `DRAGGABLE_TOOLS` is the registry — a tool provides `createElement(app, center)` (the element it drops, centered on the pointer, from the same `currentItem*` defaults a click uses) and an optional `onDrop` (a sticky note starts editing its label). Adding a tool is one entry. The preview exists only on the unit until the drop: not in the scene, the store or the history, so collaborators never see it and Escape, pointercancel, window blur or a release outside the canvas leave nothing behind. It is painted by the existing `NewElementCanvas`, drawn exactly as it will land, with the canvas itself made translucent (a CSS opacity on the canvas — per-draw-call alpha would darken where a shadow and a fill overlap). The button arms on pointerdown (a new `onPointerDown` hook on the toggle `IconButton`) and the drag starts once the pointer has moved `DRAGGING_THRESHOLD`, so a plain click still selects the tool. Listeners live on the owner window like the other pointer gestures. The drop is `insertNewElement` + one store capture (one undo entry), selects the element and switches to the preferred selection tool — whatever tool was active (an arrow, say) must not stay armed under the dropped element or its editor. The hit test prefers `elementFromPoint` (sees through a touch's implicit capture on the button) and falls back to the event target where it is missing (jsdom). Made public on App for the unit: `store`, `startTextEditing`, `getCurrentItemRoundness`, `getCurrentItemStrokeWidth`. Tests: drag → preview with an untouched scene and history → drop centered at the pointer with one history entry, the editor open and the selection tool active, undo removes it; Escape mid-drag leaves nothing and a late pointerup is inert; a release outside the canvas drops nothing; a plain click still selects. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(editor): default a text element's fontSizeMax to null `newTextElement` left `fontSizeMax` undefined while restore normalizes it to `null`. `Delta.distinctKeysIterator` compares with `!==`, so a restored copy of a locally created text element reported a phantom `fontSizeMax` change — today only in collab reconciliation deltas, which are not undoable, but a latent source of spurious version bumps. Normalize to `null` in the constructor, like `labelPosition`. Snapshots change accordingly (`undefined` → `null`), including the embedded scene payload in the SVG export snapshot. Co-Authored-By: Claude Fable 5.1 <[email protected]> * chore(editor): drop a duplicated scheduleCapture in the sticky note finalize A paste error: the two calls in one tick set the same flag. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(editor): decide history visibility from the applied result Creating a sticky note and abandoning its editor by clicking out left two history entries: the note's creation, and one that only deleted the empty label and dropped the note's reference to it — nothing a user can see, so the first undo appeared to do nothing. (Escape left the same deletion pending instead, folded into the next entry.) The text tool's own click-create-and-abandon had the same invisible entry. History already skips entries with no visible change, but visibility was decided per property patch, before binding repair and layout ran: an `isDeleted` flip or a `boundElements` edit always counted, and anything layout moved afterwards never did. `ElementsDelta.applyTo` now decides it from the applied result. After the deltas, reordering, binding repair and text/arrow layout, the "before" and "after" of every affected element are compared: - `idsToCheck` = the changed elements plus what layout can move for them: a text's container and everything in `boundElements` (a container's label and arrows); the set is walked as it grows, so an arrow brings in its own label. - Unchanged elements are shared between the incoming map and the result and layout mutates them in place, so their "before" is a shallow copy; changed elements already have a fresh instance. The whole scene is never copied. - An element counts as visible only if it is not deleted and, for text, not empty — except an empty label carrying arrow bindings or sitting on an arrow, where it cuts the gap. `version`, `versionNonce`, `updated`, `created` are metadata and `index` is covered by the z-order check; `boundElements` is compared as `type:id` strings with references to empty labels dropped, so binding repair that rebuilds equal objects reads as unchanged. Everything else counts. A dev/test-only assertion records every element's version before layout and throws if layout bumped one outside `idsToCheck`, so a new layout dependency fails a test instead of letting undo silently skip a real change. For that to work the `return` had to leave the `finally` block: a return in `finally` overrides a thrown exception, so the existing dev/test rethrow for failures in reordering and layout never actually propagated. Production behavior is unchanged — the catch still returns with the flag forced to visible. Cost, production build, median per `applyTo`: sparse changes at parity through 50k elements; 10k visible bulk updates +13%; 10k metadata-only updates 44 → 95 ms, because proving "nothing visible" cannot stop at the first element — the case the old code answered wrongly. `applyTo` runs on undo/redo and the version slider, both user-paced. Creation and typing still undo separately, and a drop from the toolbar still records at the drop. Tests: delta-level (empty-label deletion and restoration on a rectangle and a sticky note ignored; a non-empty label, an arrow's label gap, a container resized by a restored label, arrow bindings on empty text, a label and an arrow repositioned by a container redraw all visible; excluded or already-applied properties and a metadata-only update invisible; snapshot restoration visible) and the sticky note flows (Escape, click-out, font change on an empty label, typing). Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(editor): snap sticky note placement to the grid * perf(editor): ignore arrow bindings in sticky layout input check * feat(editor): center a sticky note's label in the whole note, tighten the footer A middle-aligned label was centered in the body above the creation-date footer, so it sat visibly high — half a footer above the note's center. It is now centered in the whole padded note (`height − 2·padding`), the footer ignored, and only pushed up against the body's bottom once centering would run it into the footer. The fit and the growth rule are unchanged: the note still grows at the same label height, no vertical budget is lost, and top and bottom alignment are as before. Rotation already turned the label about the note's center. The footer takes less room: its band shrinks from 32px to 20px and the date's baseline is defined from the note's bottom edge (14px) rather than from the padding, so the glyphs sit ~11px from the edge (was 17) with ~13px to the label body above (was 19). The vertical inset follows (64 → 52), and with it the font-aware minimum note sizes. Tests: a middle-aligned label is centered on the note's center at three angles; centering is clamped only once it would overlap the footer; existing sizes derive from the inset constant. Co-Authored-By: Claude Fable 5.1 <[email protected]> * feat(editor): resize sticky notes proportionally by default * fix(editor): preserve resize flips and text container anchors Apply minimum sizes and proportional font scaling to dimension magnitudes, then restore their signs for shared flip geometry. Share rotation-aware positioning for text-driven height changes and pass vertical flip state through single and multiple-element resizing. Add regression coverage for mirrored resizing, proportional minimum sizes, and text growth after crossing the resize anchor. * feat(editor): lift a seeded sticky note corner at high roughness Curve one corner at roughness 2, chosen from the note seed so it stays consistent through redraws and resizes. Pull its shadow inward by half the bend while retaining the existing down-right offset. Share the generated paper and shadow paths between Canvas and SVG rendering. * fix(editor): share the default sticky note color with its quick pick * refactor(editor): rename a label's `fontSizeMax` to `baseFontSize` The field holds the font size the user picked, from which the sticky note layout derives the fitted `fontSize`. "Max" described today's fitter (it only shrinks); "base" names the intent and pairs with the note's `baseHeight`, the other user-set value the layout derives from. Renamed before the field ships, since it is persisted in scene files. The type is now required (`number | null`): the constructor and restore already always write it, and history deltas compare with strict inequality, so an omitted key against `null` would record a spurious change. Making it required lets tsc catch producers that omit it (one test fixture did). Helpers follow: `getBaseFontSize`, `getBaseFontSizeUpdate`. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(editor): slightly strengthen sticky note edges * feat(editor): color a sticky note's background while editing its label While editing a note's label the styles panel targets the label, which has no fill, so the background picker was gone and the note's color could only be changed after leaving the editor. The picker now stays available while editing, shows the note's fill, and a pick colors the note — text color and background in one editing pass. One helper, `getColorTargetElement`, decides where a color pick on an element lands (a background pick on a note's label goes to the note); the target resolver, the panel predicate, the panel value and the action all use it, so the picker also gets the sticky domain (sticky picks, no transparent, the sticky default written). Also fixes a latent bug in the background action: with nothing selected, `selectedElements.every(isLine…)` was vacuously true, so the action took the polygon branch and did nothing. Unreachable before, since no background picker was available without a selection. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix changelog * fix snaps * more snap --------- Co-authored-by: Claude Fable 5.1 <[email protected]> |
||
| .codesandbox | ||
| .github | ||
| .husky | ||
| dev-docs | ||
| examples | ||
| excalidraw-app | ||
| firebase-project | ||
| packages | ||
| public | ||
| scripts | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.development | ||
| .env.production | ||
| .env.test | ||
| .eslintignore | ||
| .eslintrc.json | ||
| .gitattributes | ||
| .gitignore | ||
| .lintstagedrc.js | ||
| .npmrc | ||
| .prettierignore | ||
| .watchmanconfig | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| crowdin.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| setupTests.ts | ||
| tsconfig.json | ||
| vercel.json | ||
| vitest.config.mts | ||
| yarn.lock | ||
Excalidraw Editor | Blog | Documentation | Excalidraw+
An open source virtual hand-drawn style whiteboard.
Collaborative and end-to-end encrypted.
Features
The Excalidraw editor (npm package) supports:
- 💯 Free & open-source.
- 🎨 Infinite, canvas-based whiteboard.
- ✍️ Hand-drawn like style.
- 🌓 Dark mode.
- 🏗️ Customizable.
- 📷 Image support.
- 😀 Shape libraries support.
- 🌐 Localization (i18n) support.
- 🖼️ Export to PNG, SVG & clipboard.
- 💾 Open format - export drawings as an
.excalidrawjson file. - ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
- ➡️ Arrow-binding & labeled arrows.
- 🔙 Undo / Redo.
- 🔍 Zoom and panning support.
Excalidraw.com
The app hosted at excalidraw.com is a minimal showcase of what you can build with Excalidraw. Its source code is part of this repository as well, and the app features:
- 📡 PWA support (works offline).
- 🤼 Real-time collaboration.
- 🔒 End-to-end encryption.
- 💾 Local-first support (autosaves to the browser).
- 🔗 Shareable links (export to a readonly link you can share with others).
We'll be adding these features as drop-in plugins for the npm package in the future.
Quick start
Note: following instructions are for installing the Excalidraw npm package when integrating Excalidraw into your own app. To run the repository locally for development, please refer to our Development Guide.
Use npm or yarn to install the package.
npm install react react-dom @excalidraw/excalidraw
# or
yarn add react react-dom @excalidraw/excalidraw
Check out our documentation for more details!
Contributing
- Missing something or found a bug? Report here.
- Want to contribute? Check out our contribution guide or let us know on Discord.
- Want to help with translations? See the translation guide.
Integrations
Who's integrating Excalidraw
Google Cloud • Meta • CodeSandbox • Obsidian Excalidraw • Replit • Slite • Notion • HackerRank • and many others
Sponsors & support
If you like the project, you can become a sponsor at Open Collective or use Excalidraw+.
Thank you for supporting Excalidraw
Last but not least, we're thankful to these companies for offering their services for free: