mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-09-28 09:43:05 +08:00
* fix(editor): do not inherit groupIds/angle from container if not binding the text to it * fix(editor): text tool edits a label only when clicking the label itself Clicking elsewhere inside a labeled container creates an independent text at the clicked position, with no modifier needed (so also on touch/pen), instead of warping to the label center and editing the existing label. * feat(editor): highlight what the text tool would act on when hovering Mirror the click logic on hover: the text element a click would edit gets a box highlight, and an empty text-bindable container the click would bind into gets the same binding highlight arrows use (box for arrow containers, which the binding outline renderer can't draw). Cleared on pointerdown and managed only while the text tool owns the interaction, so the frame flows sharing elementsToHighlight are untouched. Also documents suggestedBinding, isMidpointSnappingEnabled and elementsToHighlight in AppState. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): hit-test arrow labels at their derived position An arrow label's stored coords aren't updated when the arrow moves (dragging deliberately skips them — the position is derived at render time), which left hitElementItself internally inconsistent: its bounds pre-check derives the position, but the precise shape test read the stale stored coords, so a moved arrow's label was only hittable where its old and new rects overlap. Substitute the derived coords for the precise test, and skip the version-keyed point cache for container-bound labels (their position can change without a version bump, mirroring ElementBounds). Pass the elements map through renderElementsBoxHighlight so the highlight box is also drawn at the label's derived position. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): dashed text box for the text-tool hover, cleared when the tool is left The text a click would edit gets the same subtle dashed box as a wrapped text being edited (at the label's derived position for arrows), instead of the frame-style bounding box. The hover highlights are cleared whichever way the text tool is left — Esc/finalize paths bypass setActiveTool. * refactor(editor): dedupe container-bound label position derivation An arrow label's accurate position is derived from the arrow rather than stored on the element (dragging deliberately skips updating it), and three call sites hand-rolled the same clone-with-derived-coords dance. Extract it into getTextElementWithAccuratePosition next to the existing position helpers. Also routes the text-tool dashed hover highlight through the helper, so it renders at the label's actual position instead of its stale stored coords. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): defer text-tool hover highlights to arrow text anchors The arrow-endpoint text binding (#11777) added its own text-tool hover affordance (hoveredArrowTextAnchor) covering arrow endpoints and midpoint label anchors, and gave endpoints precedence on click. Mirror that in the text-tool highlight updater: skip positions where a bindable endpoint would take the click, and leave arrow anchors (endpoint & midpoint label) entirely to the anchor affordance — the highlights now only cover editing an existing text and binding into an empty non-arrow container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): make endpoint-vs-text preference z-aware to prevent affordance flicker The endpoint offer was suppressed by any text element under the cursor, regardless of stacking order. With an arrow drawn over an existing text, the text's bbox edge cut into the endpoint's hit circle, and slow pointer movement flickered between the endpoint anchor and text editing. Drop the z-blind text check and let the existing z-order occlusion rule decide: whatever is stacked above owns its hit area. A text above the arrow keeps its edit behavior; when the arrow is on top, its endpoint has full preference over the whole hit circle, matching the drawn highlight. Container-bound labels need no special casing — a labeled container is interior-hittable, so the occlusion rule already yields to labels whose container sits above the arrow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): defer a text-tool center click so a drag creates free text A click on an empty container's center still binds a label, but the decision now waits for pointerup: dragging horizontally past the autowrap threshold instead creates an independent fixed-width text at the pointer origin, and small shapes are no longer provisionally enlarged while dragging. Tool changes and the missing-pointerup cleanup cancel the pending click. Shape hover and pointerdown share the empty-container / center-snap / Alt eligibility via getTextCreationContainerAtPosition, and the hover indicators are cleared when deferred creation starts (stale outline under a locked tool at 200% zoom). Also: a direct collision-cache regression for labels of moved arrows, exact width/position assertions for drag creation across mouse/pen/touch, labelPosition coverage at 25/50/75%, and stale AppState comments fixed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): redraw the dragged text box under a locked text tool Drag-sizing a new text mutates it in place without informing the scene, so sceneNonce never moves and the interactive canvas — which draws the dashed text box from the live element but memoizes on canvasNonce — kept the first size. Unlocked drags only repainted by accident: the tool had already reverted to selection, whose box-selection branch churns state every move. Fold the new text's versionNonce into canvasNonce, as is already done for framed new elements. Four rendering regressions cover successive drag widths on empty canvas and from a container's center, locked and unlocked. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(editor): extract the text tool's pointer interaction into AppTextTool Pure move of handleTextOnPointerDown, resetTextTool, maybeStartPendingText, maybeUpdateTextToolHighlightOnPointerMove and getTextCreationContainerAtPosition into App.textTool.ts — only `this.` became `this.app.` and the names lost their "Text" prefix. maybeDragNewGenericElement is now public so the pending center-drag can re-dispatch to it. No behavior change. Text editing itself (startTextEditing / handleTextWysiwyg and the hit helpers) is shared with Enter, double-click and the other entry points and stays in App. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): resolve text-tool targets once; single textToolHover state What a text-tool click would do at a position was computed in three places — the text/container highlight, the arrow-anchor hover and the pointerdown — with different modifier awareness, and shown through three AppState fields (elementsToHighlight, suggestedBinding, hoveredArrowTextAnchor) cleared from seven sites. The affordance could promise what the click wouldn't deliver: - pressing or releasing Alt without moving left the container outline stale (and Ctrl/Cmd left the text box stale) - Alt-hovering an empty arrow's midpoint advertised a label while the click created free text - Ctrl/Cmd hid the container outline (the renderer gates suggestedBinding on isBindingEnabled) while the click still bound a label - over a shape's or arrow's label the cursor was a crosshair while the box promised an edit AppTextTool.getTargetAt now answers the question once — endpoint → text → empty centered container (unless Alt) → free — and feeds the hover, the cursor and the pointerdown. The hover lives in one field, textToolHover (replacing hoveredArrowTextAnchor), drawn by one renderTextToolHover branch; it is cleared on pointerdown and when the tool is left, nowhere else. Alt and Ctrl/Cmd keydown/keyup refresh it at the last pointer position. The scene is hit-tested once per pointermove instead of twice. Also drops AppArrowText's anchor-state members (the resolver covers them) and getLabelCenter, dead since the label-center warp was removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(editor): let AppTextTool own the pending center click; explicit textElement The deferred center click was threaded through PointerDownState.text and a textCreation parameter that made startTextEditing — the entry point shared with Enter, double-click, sticky-note drops and autoshape — re-derive what the text tool's pointerdown had already established, then bail. The helper now arms `pending` itself (like AppBucketFill), decides it on pointermove/up, and PointerDownState loses its text slot. The affordance armed at pointerdown now stays up until the click resolves instead of being cleared and re-shown. startTextEditing takes an explicit `textElement` instead: the tool passes the text it resolved, or null to create — so a text selected through the host API can no longer hijack a text-tool click elsewhere (the Enter and double-click paths, which leave it undefined, resolve as before). The same selection shortcut leaves getTextBindableContainerAtPosition, which is now purely positional; the double-click path keeps it inline. Also: a pending container deleted mid-press (collab) now still ends the tool's turn, and the drag threshold's choices are documented in place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(editor): ctrl/cmd, not alt, opts out of labeling a container with the text tool Ctrl/cmd already turns off arrow-endpoint binding for the text tool; it now also keeps a center click from binding a label to an empty container, so one key means "no binding" throughout the tool, as elsewhere in the editor. It is read off the event rather than the arrow-binding preference — a label is not an arrow binding. Alt no longer plays a part in the text tool, so its hover refresh hooks go (alt keeps its generic drag meaning: resize from center). The double-click path is unchanged. Ported from #11210. Co-authored-by: Loric Brevet <loric.brevet@gmail.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): cancel a pending text-tool click on tool exit and pointer cancel; refresh hover and cursor when the viewport moves Three lifecycle gaps in the text tool's hover and pending center click: - A pending center click only checked the active tool at the next pointer event, so leaving the tool (Esc) and picking it again before releasing revived the old press: moving then created free text at the original position. And with no pointerup after a pointercancel (scroll, palm rejection), the pending click stayed armed for unrelated movement. The helper now has an explicit cancel, called when the tool is left and on pointercancel next to the bucket fill's. The cancel also clears the outline the armed click had set, which no hovering pointer would clear otherwise — including when a second finger turns the press into a pinch/pan (that discard goes through the same cancel). - The hover was refreshed on pointer moves and modifier keys, but not when the viewport scrolled or zoomed under a still pointer, so an outline stayed on a shape that had moved away. The refresh now re-derives the scene point from the pointer's last viewport position, remembers the last modifiers, and runs from the viewport-change lifecycle. - A modifier press changed the target without updating the cursor (an endpoint's pointer cursor survived Ctrl). The refresh sets the cursor from the same target, mirroring the pointermove path and leaving a panning or scrollbar gesture its own cursor. Also fixes a stale renderer comment about the container outline's binding gate. Tests: tool exit and re-pick mid-press, pointercancel, wheel scroll and zoom under a still pointer for container and text targets, cursor on modifier press and release. Verified in Chromium as well. Claude Fable 5.1, high effort Claude Opus 5.5 (1M), xhigh effort (moved the snapshot update to the caret-overflow branch) Claude Opus 5.5 (1M), xhigh effort (the cancel clears the armed outline, from the 2026-09-26 review) * fix(editor): tear a gesture down on pointercancel; a replayed cleanup no longer counts as a click A canceled press (the browser taking the pointer for a scroll, a pinch or palm rejection) left the gesture's window listeners and its missing-pointerup cleanup subscription in place. The next pointerdown then replayed the old up handler, whose end-of-gesture revert switched the tool to selection while the new press was being armed — so after a canceled center press with the text tool, the next center click switched tools and created nothing. pointercancel now runs the missing-pointerup cleanup immediately, so nothing of the canceled gesture lies in wait. And the up handler's tool revert is gated on a genuine pointerup: a replay by the cleanup — a second finger landing, a pointercancel, the next press after a lost pointerup — only tears the gesture down and leaves the tool to the press that follows, instead of reverting it under that press. The pointercancel regression no longer sends a synthetic mouse-up that hid this: after the cancel the tool must still be the text tool and the next center click must bind a label. It fails without the fix. Claude Fable 5.1, high effort * refactor(editor): cursor from the text-tool target alone; a hover refresh needs a hovering pointer Follow-ups from the whole-branch simplification review (simplification.md), narrowed with the reviewer: - The cursor is derived from the resolved target only. The old fallback to the plain hit under the pointer kept a text cursor over other texts while editing or drag-sizing — where a click has nothing to point at — and forced a second hit test and a coordinate recomputation on every modifier press and viewport change. Both go. With a locked text tool, editing or dragging now shows the crosshair everywhere; hover, endpoint and label cursors are unchanged. - The hover refresh takes the pointer's last viewport position only. The fallback to the last *scene* coordinates was reachable when a tap preceded any pointer movement, and after a viewport change it resolved at a position nothing hovers — a phantom affordance. A tap leaves nothing to refresh, pinned by a test that fails with the fallback in place. Nor does a finger ever hover: a touch's last move is where it lifted, so touch input doesn't count as a hovering pointer (mouse and pen still refresh). The test drives real touches — a tap with jitter on a text, a short drag from a center — instead of clearing the pointer bookkeeping by hand. The pointercancel test now checks the armed outline is gone right after the cancel, and a second-finger test covers the missing-pointerup discard. - The explicit pending-click cancel on pointercancel stays, and its reason is written down: the gesture teardown that follows flushes a queued pointermove, which would otherwise find the click still pending and resolve it as a drag. Verified in Chromium by the reviewer; the test harness runs the throttle synchronously, so it cannot be reproduced there. - Two duplicated test scenarios removed: the arrow-anchor hover test the endpoint suite already covers, and a repeated Ctrl assertion. Claude Fable 5.1, high effort Claude Opus 5.5 (1M), xhigh effort (touch input never hovers; tests, from the 2026-09-26 review) * refactor(editor): the text tool refreshes its own hover and cursor; viewport navigation check lives in AppPan App's `refreshTextToolHover` was text-tool logic kept in App only because its cursor gate read App-private state. It moves into `AppTextTool` as `refresh(modifiers?)`, which re-resolves the hover and sets the cursor that goes with it, taking the place of `refreshHover`. The gate — space held, a pan in progress, a scrollbar drag, the hand tool — is the same check the canvas pointermove makes before touching hover or cursor. It becomes `AppPan.isNavigating()`, used by both. The scrollbar drag stays App's own and reaches AppPan as a dependency, like the pointer count. No behavior change. Claude Opus 5.5 (1M), xhigh effort --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Loric Brevet <loric.brevet@gmail.com>
@excalidraw/utils
Install
npm install @excalidraw/utils
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
yarn add @excalidraw/utils
API
serializeAsJSON
See serializeAsJSON for API and description.
exportToBlob (async)
Export an Excalidraw diagram to a Blob.
exportToSvg
Export an Excalidraw diagram to a SVGElement.
Usage
Excalidraw utils is published as a UMD (Universal Module Definition). If you are using a module bundler (for instance, Webpack), you can import it as an ES6 module:
import { exportToSvg, exportToBlob } from "@excalidraw/utils";
To use it in a browser directly:
<script src="https://unpkg.com/@excalidraw/utils@0.1.0/dist/excalidraw-utils.min.js"></script>
<script>
// ExcalidrawUtils is a global variable defined by excalidraw.min.js
const { exportToSvg, exportToBlob } = ExcalidrawUtils;
</script>
Here's the exportToBlob and exportToSvg functions in action:
const excalidrawDiagram = {
type: "excalidraw",
version: 2,
source: "https://excalidraw.com",
elements: [
{
id: "vWrqOAfkind2qcm7LDAGZ",
type: "ellipse",
x: 414,
y: 237,
width: 214,
height: 214,
angle: 0,
strokeColor: "#000000",
backgroundColor: "#15aabf",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
roundness: null,
seed: 1041657908,
version: 120,
versionNonce: 1188004276,
isDeleted: false,
boundElementIds: null,
},
],
appState: {
viewBackgroundColor: "#ffffff",
gridSize: null,
},
};
// Export the Excalidraw diagram as SVG string
const svg = exportToSvg(excalidrawDiagram);
console.log(svg.outerHTML);
// Export the Excalidraw diagram as PNG Blob URL
(async () => {
const blob = await exportToBlob({
...excalidrawDiagram,
mimeType: "image/png",
});
const urlCreator = window.URL || window.webkitURL;
console.log(urlCreator.createObjectURL(blob));
})();