mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
2df993fb397dd6d64625908c9c10337727617f6f
4123
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2df993fb39 |
fix(editor): resizing text by a corner follows the cursor on the axis it grows most (#12262)
Text scaled by the height alone, so a corner dragged sideways didn't follow the cursor; it now scales as resizing keeping the aspect ratio does (which text always does), by the axis that grows most. Claude Opus 5.5 (1M), xhigh effort |
||
|
|
ef53e5417b |
feat(editor): crop editor modifiers as resizing, and double-click a handle to uncrop (#12260)
* fix(editor): shift in the crop editor: shift-click keeps it open, shift-drag moves the image along one axis
- shift-clicking the image being cropped no longer toggles it out of the
selection (which ended cropping)
- dragging the image within its crop follows the drag offset, axis-locked
with shift as dragging elements is, applied by its change since the
previous pointer move (pointerDownState.drag.lastOffset) so the image
still moves back right away after being dragged past its edge
- drops App.previousPointerMoveCoords, which only the crop drag used
Claude Opus 5.5 (1M), xhigh effort
* feat(editor): alt-drag a crop handle to crop from the center
- cropElementFromCenter crops the opposite side by as much as the dragged
one, keeping the element's center, as far as the image reaches on the
nearer side; with shift, keeping the aspect ratio
- no alt drag threshold in the crop editor: alt-clicks don't cycle the
selection there
Claude Opus 5.5 (1M), xhigh effort
* fix(editor): clicking a crop handle no longer leaves the crop editor
A press on a crop handle hits no element, so a click on one without a move
in between counted as a click outside the image.
Claude Opus 5.5 (1M), xhigh effort
* feat(editor): double-click a crop handle to uncrop its sides
- in the crop editor, double-clicking a side's handle uncrops that side, a
corner's its two sides, as dragging the handle out as far as the image
reaches does (uncropElementSides)
- with alt, the opposite side(s) too, so alt on a corner uncrops it all;
alt double-clicks aren't ignored in the crop editor, which doesn't cycle
the selection
Claude Opus 5.5 (1M), xhigh effort
* refactor(element): uncrop and crop from the center through one bounds helper
- cropElementToBounds crops the image to bounds relative to the element
(rotated and flipped with it); uncropElementSides and cropElementFromCenter
only compute those bounds
- uncropElementSides no longer drives cropElement with a pointer past the
image's edge, copying the crop it mutates, nor recurses for the mirrored
side
- maybeUncropSides takes the selected image, and the pointer type from
appState.lastPointerDownWith
Claude Opus 5.5 (1M), xhigh effort
* fix(editor): arrows bound to an image follow the sides uncropped by a double-click
Claude Opus 5.5 (1M), xhigh effort
* fix(editor): crop from the drag's start, as resizing does, so alt and shift apply mid-drag
- maybeHandleCrop crops the image as it was at pointer down
(pointerDownState.originalElements) rather than as of the previous pointer
move, and maybeHandleResize crops in the crop editor, so pressing or
releasing alt or shift mid-drag re-crops right away, as resizing does
- cropElement no longer mutates the crop of the element it's given (which is
now the drag's start state)
Claude Opus 5.5 (1M), xhigh effort
* fix(editor): no invisible rotation handle in the crop editor
The crop editor draws no rotation handle, but pressing where it would be hit
it, and dragging it cropped the image ("rotation" includes "n"). A press
there is now one outside the image, which leaves the editor.
Claude Opus 5.5 (1M), xhigh effort
* refactor(element): crop an image as resizing it would resize it
- cropElement sizes the crop from the pointer as resizing does
(getNextSingleWidthAndHeightFromPointer, from the center too), keeps it
within the image from what stays put (the opposite side, or the center),
and crops through cropElementToBounds; it replaces the per-handle cases,
recomputeOrigin and cropElementFromCenter
- options as resizing's: { shouldMaintainAspectRatio, shouldResizeFromCenter }
rather than a width aspect ratio (the element's, which is the drag's start)
- keeping the aspect ratio, a corner crops by the axis it grows most,
relatively, as resizing does (rather than by pixels), and an axis dragged
past what stays put has the minimal size rather than flipping
- an image already smaller than the minimal size crops no smaller, rather
than jumping to it
Claude Opus 5.5 (1M), xhigh effort
* fix(element): the image no longer shifts by a pixel while cropping it
The crop editor drew the cropped image from its own bitmap, snapped to whole
pixels by its own corner, over the faded uncropped image's: as the corner
moved by fractions of a pixel (cropping from the center, or keeping the
aspect ratio along a side), the image within the crop shifted by a pixel
against the faded rest. Within the crop, it now draws the uncropped image's
bitmap at full opacity, clipped to the crop.
Claude Opus 5.5 (1M), xhigh effort
|
||
|
|
e65eca999e |
refactor(editor): app.selection, and the rest of the selection tool out of App (#12258)
* refactor(editor): add AppSelection (app.selection) to select, add to, remove from and clear the selection - select(element) for click-select, the alt-click cycle and the context menu; add(element) for the selection tool and the lasso's touch select (was a near-copy); remove(element) for shift-click; clear() replaces App.deselectElements and three inline copies - isASelectedElement / isHittingCommonBoundingBoxOfSelectedElements move from the selection tool - select() drops a stale line editor of a no longer selected arrow; the context menu no longer spreads all of this.state into its setState - tests for the selection's frame, link selector and line editor rules Claude Opus 5.5 (1M), xhigh effort * refactor(editor): make the app.selection API generic over elements or ids, one or many - select / add / remove take elements or ids, one or many (missing and deleted ones skipped) - their rules become pure functions in App.selection.ts, for callers that need the next selection state rather than a setState (actions, duplication): getSelectionStateForElements (moved from @excalidraw/element, now keeping the current line editor), getSelectionStateAddingElements, getSelectionStateRemovingElements — one pass over the elements however many are added or removed - remove() recomputes selectedGroupIds (it put element ids into it and lost the other selected groups' flags), and leaves group editing once nothing is selected - the link popup on adding an element is the pointer's, not add()'s Claude Opus 5.5 (1M), xhigh effort * refactor(editor): move selectGroupsForSelectedElements from @excalidraw/element into App.selection.ts - editor state (selected group ids, the edited group), used by the editor only; moved as is - also ends the groups.ts <-> selection.ts import cycle in @excalidraw/element Claude Opus 5.5 (1M), xhigh effort * refactor(editor): move dragging the selection and box selection out of App into AppSelectionTool - handleSelectionDragOnPointerMove: dragging the selection (or the image within its crop), alt-drag duplicating past the threshold - handleBoxSelectionOnPointerMove: box selection (and of the line editor's points) - handleSelectionDragOnPointerUp: frame membership of the dragged selection - pure moves, verified mechanically - tests for shift-dragging along one axis and moving the image within its crop (both untested through the pointer) Claude Opus 5.5 (1M), xhigh effort * refactor(editor): one check for the selection's transform handle under the pointer - selectionTool.getTransformHandleAt() replaces the two copies in the hover cursor and on pointer down (line editor, elbow arrows, two-point lines, mobile lines, hovered line points; a lone element's or the selection's common bounds) - pointer down now also ignores the handles in the element link selector, as the hover cursor did - test for the resize cursor over the handles (untested) Claude Opus 5.5 (1M), xhigh effort * fix(editor): app.selection shows the link popup of what it selects - select / add / remove show the link popup of a lone selected element with a link (or an embeddable) and hide it otherwise; clear hides it — it was left as is, so select() showed it only if a previous selection had - the link editor stays open when its element is selected again - the pointer and lasso call sites no longer set it themselves Claude Opus 5.5 (1M), xhigh effort * feat(editor): app.selection ends what hangs on the deselected elements and skips locked ones - cropping ends unless the cropped image stays the selection, and an active embed is deactivated once deselected (the line editor already ended once its line is deselected) - select / add skip locked elements, unless includeLocked — which the context menu passes, to offer unlocking them - captureUpdate option, as updateScene's, to record the change for undo - deep option on select (the elements themselves, editing their shared group), which Ctrl+click now goes through - text editing isn't ended here: the editor ends itself on blur, and submitting it mid pointer event would commit half the event's state Claude Opus 5.5 (1M), xhigh effort * feat(editor): app.selection ends text editing, and add / remove / clear always end cropping - select / add of anything but the edited text (or its container) submit the text editor — after the current event (it submits with a flushSync), and only if that editing session is still open; clear / remove leave it (starting text editing clears the selection) - add / remove / clear end cropping; select keeps it only when selecting the cropped image again (a click inside it in the crop editor) Claude Opus 5.5 (1M), xhigh effort * fix(editor): app.selection derives the line editor from the final selection, and frame / deep fixes - the line editor is set from the selection once groups are expanded: an arrow selected with its group (e.g. by alt-click cycling) gets none, so dragging from its point moves the group rather than reshaping the arrow; add() sets it too (adding a lone arrow left Edit / Cmd+Enter throwing) - children of frames selected through the groups of the selected (or added) elements are left out, also ones selected in the same call — and adding within an edited group no longer deselects as if the edited group's frames were selected - deep selection never expands groups (it did for elements not sharing one) - a click-selected elbow arrow now gets its line editor on pointerdown, so that click's pointerup normalizes a hand-built route one gesture earlier Claude Opus 5.5 (1M), xhigh effort * feat(packages/excalidraw): excalidrawAPI.selection - app.selection on the imperative API — select / add / remove / clear, taking elements or ids, one or many, with the deep / includeLocked / captureUpdate options; the type exposes only those four - they're arrow properties, so they keep `this` when taken off the object - changelog entry Claude Opus 5.5 (1M), xhigh effort |
||
|
|
4c00f31ddc |
feat(editor): autoplay Google Drive videos when the embed is activated (#12246)
- Drive's player has no postMessage API (unlike YouTube/Vimeo), but its /preview page honors `autoplay=1`, so the first activation reloads the embed with it: a single click plays the video in view mode - reloaded via `contentWindow.location.replace` so it adds no history entry, and only once per iframe so re-activating doesn't restart it Claude Opus 5.5 (1M), xhigh effort |
||
|
|
2559257bb2 |
fix(editor): pan once and keep editing on a wheel-button drag over the text editor (#12244)
- the text editor's window pointerdown listener was added in capture mode but removed without it, so every past editing session kept panning too: the pan was multiplied by the number of texts edited since load - the pan focuses the container, which blurred the editor while blur still submitted it; submit is now disabled before the pan starts, and the editor is refocused on release Claude Opus 5.5 (1M), xhigh effort |
||
|
|
53973c3a42 |
feat(editor): alt-click to cycle the selection through overlapping elements (#12237)
* feat(editor): alt-click to cycle selection through overlapping elements - alt+click on the selected element (or group) selects the one below it under the pointer, wrapping around to the topmost - inside an edited group, cycles through that group's elements only - alt+drag duplicates only past a 10px threshold, so alt-clicks don't - alt+double-click no longer creates/edits text (except with autoshape) Claude Opus 5.5 (xhigh) * refactor(editor): move the selection tool out of App into AppSelectionTool (app.selectionTool) - handleSelectionOnPointerDown, clearSelection(IfNotUsingSelection), isASelectedElement, isHittingCommonBoundingBoxOfSelectedElements and the alt-click cycle target move as is - the click selection of the pointer up handler (alt-click cycle, shift toggle, click select, bbox-only deselect) becomes handleSelectionOnPointerUp - the selection's transform handles, maybeHandleResize / maybeHandleCrop, move as is - tests for alt-click cycling, and for cropping by dragging a crop handle Claude Opus 5.5 (1M), xhigh effort * feat(editor): alt-click cycles the selection on its resize handles too - alt-resizing starts only past the 10px drag threshold, as alt-drag duplicating does, so an alt-click on a resize handle can cycle - an alt-click on the selected element's resize handle (over the element) cycles the selection, but not once a resize happened Claude Opus 5.5 (1M), xhigh effort * feat(editor): hint at alt-click cycling while Alt is held - holding Alt over a single selected element (or group) with the selection or lasso tool shows "Alt-click to cycle selection, or drag to duplicate" - app.modifiers tracks the held Alt, Shift and Ctrl/Cmd outside appState, synced from key and pointer events and reset on blur; app.modifiers.useHeld(modifier?) re-renders only on the given one - the hover refresh after a pointer up replays the held modifiers, so it no longer releases them (the hint flickered on every alt-click) - selectionTool.canCycleSelection() shares the cycle rules between the hint and the click Claude Opus 5.5 (1M), xhigh effort |
||
|
|
ed10ac7dca |
feat(editor): alt-drag the text being edited to duplicate it (#12219)
* feat(editor): alt-drag the text being edited to duplicate it - alt-pressing the text editor ends the editing and hands the press over to the canvas, so the drag duplicates the text (or its container) - the handed-over press always moves what was edited, never resize handles or an arrow's points, midpoint or label beneath the editor - on drop, the duplicate is edited with all of its text selected (a list item's text only, as when alt-dragged on the canvas) Claude Opus 5.5 (xhigh) * fix(editor): no arrow hover highlights while editing its label - starting to edit text clears the hover state of the arrow's editor (kept up for the submit), so a midpoint hovered before the double-click doesn't stay highlighted - the arrow's hover affordances don't apply while editing text Claude Opus 5.5 (xhigh) * fix(editor): don't drop arrow hover updates on the same pointer move - the hovered point, midpoint and focus point were each set from `this.state`, so when one move changed several of them (e.g. from the midpoint straight onto an endpoint), only the last update survived Claude Opus 5.5 (xhigh) |
||
|
|
9ba66ed9b5 |
feat(editor): auto increment numbers/lists on duplicate (#12189)
* feat(editor): auto increment numbers/lists on duplicate - duplicating a text or a label (shape, arrow, sticky note) that starts with a list marker advances it: `1.` `1)` `(1)` `[1]`, `a.` `a)` `(a)` (`a`–`h`, then roman numerals `i`–`xcix`), and lone numbers (`1`) - markers must be followed by whitespace (`1.5`, `e.g.` aren't lists); whole lists are left as they are - numbers count up to 42, letters up to `i` - only if nothing but list items is duplicated (a label goes with its container); several items only if they're just markers (`1.` `2.`), continuing from the highest of them (`1.`, `3.`, `5.` -> `6.`, `7.`, `8.`) - the increment is a separate undo step after the duplication; alt-drag shows it from the start of the drag Opus 5.5 (1M context), xhigh * feat(editor): edit the text of an alt-duplicated list item - on alt-drag drop, a single list item with text after its marker enters the text editor with that text selected (`2. |foo|`) - not when duplicated along with other elements, nor when the drag doesn't end with a genuine pointerup (pointercancel, missing-pointerup cleanup) - `textWysiwyg` / `startTextEditing` take an `initialSelection` Opus 5.5 (1M context), xhigh |
||
|
|
1919728724 |
fix(editor): reach text containers below transparent ones (#12210)
The text tool (hover and click), and a double-click with nothing selected, took the top-most shape whose bounds held the pointer. A transparent shape stacked above a nested one kept the inner one out of reach at its center, and an arrow below a transparent shape couldn't get a label. - only a filled element or an image (the rule arrow binding uses, now exported as isOpaqueForBinding) or an arrow ends the search - of the containers found, the innermost one whose center is at the pointer wins, else the top-most as before - an arrow counts only where its path is hit, not anywhere in its bounds - a frame's children aren't hit outside the frame, where they're clipped (App.isClippedByFrameAt, shared with getElementsAtPosition) Claude Opus 5.5 (1M), xhigh effort |
||
|
|
35e854ecf7 |
feat(app): label E+ banner Upgrade / Sign in (#12204)
Claude Opus 5.5 (xhigh) |
||
|
|
5a406e5187 |
fix(app): correct typo in Mermaid input placeholder (#12186)
fix: correct typo in Mermaid input placeholder |
||
|
|
e283f724c8 |
refactor(editor): move the clipboard out of App into AppClipboard (app.clipboard) (#12197)
* refactor(editor): move the clipboard out of App into AppClipboard (app.clipboard) A pure move of the clipboard handling out of App.tsx (13,478 → 12,942 lines) into App.clipboard.ts: onCut and onCopy (the document's cut/copy events, through their actions), pasteFromClipboard (the paste event, parsed, and handed to a host's onPaste first), insertClipboardContent (inserting the pasted data as whatever it is: images or SVG code, elements, a Mermaid definition, embeddable URLs, a spreadsheet as a chart, mixed content, or text), addElementsFromMixedContentPaste and addTextFromPaste. The plain-paste flags move along as module-level state; Ctrl/Cmd+V's marking of a plain paste, and its reset, are App's former lines behind app.clipboard.onPasteShortcut and clearPlainPaste. Staying in App: addElementsFromPasteOrLibrary and insertImages, shared with drop, the library and text-to-diagram (insertImages becomes public for the clipboard), and onIframeSrcCopy, which belongs to the magic frame. Callers go through `app.clipboard`: no delegate is left on App, and AppClassProperties exposes `clipboard` (as it does `textTool`) instead of `pasteFromClipboard`. Beyond the move, `this.` becomes `this.app.` for what stays on App, onCut and onCopy become public, and the container ref comes in as a dependency. The moved code is identical to the removed, modulo those rewrites, whitespace and trailing commas (checked mechanically). Claude Opus 5.5 (1M), xhigh effort * test(editor): cover copy/cut events and more paste routes Only 80% of the clipboard code ran under the suite, and nothing went through the document's copy and cut events, which is how Ctrl/Cmd+C/X reach the editor in a browser. Now a copy event copies the selection and a cut event cuts it; a host's onPaste returning false keeps the paste from being inserted; a pasted spreadsheet opens the charts dialog; and with the image tool off, a paste of mixed content inserts its text. Claude Opus 5.5 (1M), xhigh effort |
||
|
|
3947883063 |
refactor(editor): move text editing out of App into AppText (app.text) (#12195)
* refactor(editor): move text editing out of App into AppText (app.text) A pure move of the text editing session, and of the text helpers around it, out of App.tsx (14,250 → 13,478 lines) into App.text.ts. It sits next to AppTextTool, which keeps the text tool's pointer interaction. AppText holds: - the session: startTextEditing (creates or picks the text to edit, and opens the editor), handleTextWysiwyg (the editor's lifecycle) and the open editor's submit handler - the part of the canvas a typed or pasted text is kept within: getTextSidePanels, getTextViewportOffsets and getMaxTextWidth (paste, which stays in App for now, calls into them) - where a new text goes: getTextCreationGridPoint and getTextWysiwygSnappedToCenterPosition - the queries that find the text or container to edit: getTextElementAtPosition, getTextBindableContainerAtPosition, getSelectedTextElement, getSelectedTextEditingContainerAtPosition and isEditingTextContent - the auto-resize handle: isHittingTextAutoResizeHandle and handleTextAutoResizeHandlePointerDown Callers go through `app.text`, startTextEditing's included: no delegate is left on App (no host or test used it). Beyond the move, `this.` becomes `this.app.` for what stays on App, members called from outside become public (App.deselectElements too), and the container ref and the styles panel mode come in as dependencies, as AppViewport's do. The moved code is identical to the removed, modulo those rewrites, whitespace and trailing commas (checked mechanically). Claude Opus 5.5 (1M), xhigh effort * test(editor): click the text auto-resize handle through the pointer The handle that turns a fixed-width text back into a growing one was only covered through its action: nothing hovered or clicked it. Hovering it on a selected wrapped text now shows the pointer cursor, and clicking it unwraps the text, through AppText's hit test and pointerdown handler. Claude Opus 5.5 (1M), xhigh effort |
||
|
|
3d056dbc8a |
feat(editor): wrap text at 800 and keep it clear of the side panels (#12192)
* feat(editor): wrap typed & pasted text at 800, clear of the side panels A free text growing as it's typed stopped only at the view's width (#12175), so on a large screen it ran to 2000+ px before wrapping, and a pasted text wrapped at half the view. Both now follow one rule, in `getMaxTextWidth`: - no wider than TEXT_MAX_WRAP_WIDTH (800, in @excalidraw/common) in scene units, at any zoom: the paste's former cap, now shared. A paste no longer wraps at half the view. - nor wider than the canvas between the side panels, less TEXT_VIEWPORT_PADDING at each side: the docked sidebar, as before; the full styles panel (desktop), which shows next to a text while it's edited, and once it's pasted and selected (pasting with nothing selected, its room is reserved: as last measured, else approximately); and the stats panel, only for a text beside it, i.e. with rows within that padding of it, as it covers only the top of the canvas' side. A text that starts wrapping is brought in between the same panels, as before. A paste is laid out as if the stats panel weren't there, and again, clear of it, only if it lands beside it. As the stats panel grows to show a selection's properties, with it open the paste is rendered selected first (not captured yet, via `flushSync`), so the panels are measured as they'll be; laid out again, it stays the same elements, and one undo step. A wrapped paste is then moved into view (as far as the view would have scrolled), instead of scrolling the view. The stats panel is marked as side viewport UI (`data-viewport-ui="side"`, named "stats"), so the viewport offsets' other users (zoom to fit, scroll to content, search, a host's `setViewport` with UI offsets) keep clear of it too while it's open. `AppViewport` gains `getSideInsets(names, { reserve })`, generalizing `getSidebarInsets`, and `getSideUIRect(name)`; `getOffsets` shares the reserve fallback through `getReservedSide`. Tests: the cap at 50% and 200%; a typed text clear of the styles and the stats panel, and of the stats panel only beside it; a paste wrapping as a typed one, with the styles panel's room reserved; a paste clear of the stats panel only where it lands beside it, and of the stats panel grown to show it, as one undo step; a wrapped paste moved into view, the view not scrolled. Claude Opus 5.5 (1M), xhigh effort * feat(editor): pan the canvas when the caret goes under the stats or styles panel The canvas follows the caret past the view's edge, and behind the docked sidebar, through the editor's box, which the browser scrolls to reveal a caret (#12175). The stats and the full styles panel don't span the box's height, so a caret typed in under them stayed out of sight. After each input, once the update (and any scroll it made) is in the app's state, the editor works out where the caret is: its line from `getWrappedTextLines`, its x from the width of the line before it, rotated with the text. In a line with RTL characters (`hasRTLChars`, new in @excalidraw/common) the x comes from the browser's bidirectional layout of the line instead, in its paragraph's direction (the editor's `dir="auto"` makes it `unicode-bidi: plaintext`: each paragraph goes its first strong character's way): with Hebrew then English, the caret after the English is mid-line, not at the line's edge. That's measured through a range in an off-screen mirror of the line, shared with the click-to-caret lookup. If the caret's under one of those panels, the canvas pans to bring it out on the canvas' side, with CARET_FOLLOW_PADDING to spare. Only then: a text beside or below a panel stays where it is. Tests: typing past the stats panel's left edge pans the canvas under it, but not below it; the caret in a bidirectional line, a paragraph after one going the other way, is found where the browser lays it out. Claude Opus 5.5 (1M), xhigh effort |
||
|
|
2d3707b816 |
feat(editor): add showHints to command palette (#12187)
* feat(editor): route showHints through the action system (#12165 follow-up) `showHints` was the only item in the Preferences submenu that flipped appState directly instead of going through an action, so it was missing everything the action system provides: no command palette entry, no `trackEvent`, no `viewMode` declaration. - add `actionToggleShowHints`, modelled on `actionToggleMidpointSnapping` (`viewMode: false` — hints live in the toolbar, which view mode hides) - register it under `ActionName` and export it from `actions/index.ts` - `PreferencesToggleShowHintsItem` now calls `executeAction` - list it in the command palette's editor commands - document `appState.showHints` / `MainMenu.DefaultItems.Preferences.ToggleShowHints` in the CHANGELOG, which the original commit never touched No keyboard shortcut: like `arrowBinding` and `midpointSnapping`, this is a set-once preference, and the natural mnemonic (`Alt+H`) is already distribute-horizontally. Adds `tests/showHints.test.tsx` — the preference previously had no behavioural coverage at all, only incidental snapshot entries. * chore(editor): drop showHints tests & changelog entry Both will be rewritten in a follow-up PR. Opus 5.5 (1M context), xhigh --------- Co-authored-by: hiennd <hiennd@aidia.vn> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
834e5af631 |
fix(editor): follow-ups to zen mode polish & delayed tooltips (#12188)
* fix(editor): follow-ups to zen mode polish & delayed tooltips (#12165) Tooltip state (timer + warm window) is shared by all instances, since they share one tooltip node. Without an owner, any unrelated Tooltip unmounting (a collaborator leaving, a sidebar breakpoint) cancelled or hid a tooltip the user was still hovering. Track the owning item and only hide from the paths that own it; `hideTooltip` no longer creates the tooltip node just to hide it. Zen mode hides or slides controls without the pointer moving, so nothing fired pointerleave to retract their tooltips — markZenModeTransition now hides the tooltip up front. `:focus-visible` was not excluded from the chrome-less zen-mode rules, so the keyboard focus ring (a box-shadow) disappeared for the main menu trigger, scroll-back-to-content and exit-zen-mode buttons. The zoom buttons lost their native `title`, which was the only channel carrying their shortcut to keyboard/AT users — expose it via `aria-keyshortcuts`, as Tools.tsx does. Also: schedule the zen-mode timer on `ownerWindow` like every other DOM-touching timer in App.tsx, re-read the container ref when unflagging (and unflag on unmount), and drop the footer-left `--transition-left` class, which never matched a selector. Adds the missing coverage for the transition-attribute lifecycle, the `openMenu`-across-view-mode fix, and the tooltip delay/warm window. Split out of #12187 and ported onto the #12173 Tooltip rework: the owner is set in `showTooltip`, and its MutationObserver already retracts a tooltip whose wrapper got `disabled`, so that extra effect is dropped, as is the delayed-show label ref (`showTooltip` takes the label by value). Opus 5.5 (1M context), xhigh * refactor(editor): drop tooltip owner tracking, trim tests Since #12173, `showTooltip`'s MutationObserver retracts a visible tooltip whose wrapper leaves the DOM, and its `isConnected` check drops a pending one. That makes the Tooltip unmount effect, which hid whatever tooltip was showing, redundant: removing it fixes the unrelated-unmount bug on its own, without tracking an owner. Also drop zenMode.test.tsx and the tooltip tests that duplicated others. Opus 5.5 (1M context), xhigh --------- Co-authored-by: hiennd <hiennd@aidia.vn> |
||
|
|
438d89861f |
docs(repo): remove obsolete CodeSandbox contribution workflow (#11908)
* docs: remove obsolete CodeSandbox contribution workflow https://codesandbox.io/p/github/excalidraw/excalidraw (forking the whole repo as an editable sandbox to submit PRs from) no longer works. It was documented in two places — the Contributing guide and the Development guide — as an alternative to the manual clone/fork setup. This doesn't touch the other CodeSandbox links in the docs (README, installation/integration/development guides for the @excalidraw/excalidraw package), which point at the separate with-script-in-browser usage example and are unrelated to this contribution workflow. * chore: trigger CI Claude Opus 5.5 (low) --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
02fc9f3505 |
fix(packages/excalidraw): setViewport stops following a collaborator (#12184)
Navigating with `setViewport` takes the view over from the followed user, as a user's pan or zoom does, so it now asks to stop following (`requestUnfollow`: `onUserFollow` with `action: "UNFOLLOW"`, when `userToFollow` is set). `setViewport(null)`, which only clears a lock, and a target that doesn't resolve don't navigate and leave following alone. Following itself doesn't go through `setViewport`: excalidraw-app and Excalidraw+ both apply the followed user's viewport with `updateScene` + `zoomToFitBounds`, so it's unaffected. Tests: navigating emits the UNFOLLOW intent once; clearing a lock and an unresolved target don't. Claude Opus 5.5 (1M), xhigh effort |
||
|
|
74423812ec |
perf(editor): avoid rebuilding id sets on every drag (#12183)
perf(element): reuse one moved-element ID set when dragging dragSelectedElements passed a fresh Array.from(elementsToUpdate) to every element's updateBoundElements call. Each call missed the per-array ID-set cache and rebuilt a selection-sized set, so a drag step was quadratic in the number of dragged elements. Build the array once per drag step, pass it to every call, and reuse its cached set (getSimultaneouslyUpdatedElementIds, now exported) for the unbinding checks too. Document that callers must pass one array instance per operation and must not change its membership afterwards. Dragging 1K / 2K / 4K selected rectangles in the app went from 45 / 160 / 643 ms to 18.5 / 31 / 54 ms per frame (same tab, same window, dev build). Add coverage for the shared array, grouped labels across z-order, frames with their children, bound arrows, bindings, unbinding, and unchanged coordinates. Claude Opus 5.5 (1M context), reasoning effort 40 |
||
|
|
84e3f5a40c |
perf(editor): avoid rebuilding id sets on every resize (#12180)
perf: avoid rebuilding simultaneously-updated id set per element on multi-element resize updateBoundElements() rebuilt a Set of all simultaneouslyUpdated ids on every call. Multi-element resize/rotate call it once per selected element with the same array, making each pointer move O(n^2). Cache the set per array in a WeakMap. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
974f054baf | fix(editor): embed YouTube live links (#12178) | ||
|
|
afed9e6e27 |
fix(editor): handle the save-as shortcut while editing text (#11922)
* fix(editor): handle the save-as shortcut while editing text Cmd/Ctrl+Shift+S did nothing while a text editor was open — the editor stayed open and no save happened. Plain Cmd/Ctrl+S worked. The wysiwyg keydown handler only branches on `actionSaveToActiveFile`, whose keyTest requires `!event.shiftKey`, so the shift variant matched nothing and was swallowed. This adds the sibling branch for `actionSaveFileToDisk`, mirroring what the plain shortcut already does: commit the text, then run the action. Complements #9281, which fixed the plain shortcut in the same handler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(editor): assert the save-as shortcut actually dispatches the save Review feedback: the regression test only asserted that editing ended, which `handleSubmit()` satisfies on its own — the test still passed with the `executeAction(actionSaveFileToDisk)` call deleted, so half the fix was unpinned. Now spies on `saveAsJSON`, which is what the action ultimately reaches, and captures `editingTextElement` at the moment it runs so the ordering (commit, then save) is pinned too. Verified: removing the dispatch now fails with `expected "saveAsJSON" to be called 1 times, but got 0 times`. Also adds a negative case — closing the editor with Escape must not save — so the spy proves the save is tied to the shortcut rather than to teardown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(editor): dispatch text-editor save shortcuts as keyboard actions, trim tests - pass the "keyboard" source for both save shortcuts in the wysiwyg keydown (was the default "api", which skipped the interaction gate and mistracked) - drop the explanatory comment on the save-as branch - fold the three save tests into one it.each in the existing container-unbound suite, covering both Cmd/Ctrl+S and Cmd/Ctrl+Shift+S and pinning that the text is committed before saveAsJSON runs Claude Opus 5.5 (1M context), xhigh --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
d29d864884 |
feat(editor): file drop overlay & shift-to-merge imported scene (#12177)
* feat(editor): add file drop overlay and Shift-to-insert support * fix(editor): report copy drop effect for file drags Shift defaults a drag to "move" (and a "Move" cursor), but a dropped file is only ever read in, never moved. Opus 5.5 (xhigh) * fix(editor): ignore file drops in view mode and when non-interactive The dragover/drop cancel listeners were only registered in edit mode, so elsewhere the browser opened a dropped file natively. Register them always, show a no-drop cursor where drops are ignored, and don't handle the drop. Opus 5.5 (xhigh) * feat(editor): cross out the existing file in the drop overlay when replacing Opus 5.5 (xhigh) * fix(editor): note that library files won't replace content in the drop overlay OS-dragged .excalidraw and .excalidrawlib files both report an empty MIME type until dropped, so the overlay can't tell them apart. Keep the replace copy for untyped files and add a secondary note for libraries. Opus 5.5 (xhigh) * fix(editor): use --overlay-bg-color for modal and file drop backdrops Replaces color-mix() in the file drop overlay, which is newer than our browserslist, with the (previously unused) --overlay-bg-color token, now hardcoded per theme, and reuses it for the <Modal> backdrop. Opus 5.5 (xhigh) * fix(editor): scope image-scene drop try to parsing, drop redundant preventDefault Only loadFromBlob is wrapped now, so an error while inserting or replacing isn't reported as an invalid file or followed by a plain image insert. App's dragover listener is always registered, so the overlay no longer needs to call preventDefault itself. Opus 5.5 (xhigh) * fix(editor): don't let the browser open files dropped on modals Modal portals live outside the editor container, so its dragover/drop listeners didn't cover them. Cancel file drags on body-level portal containers, leaving non-file drags (e.g. text into inputs) native. Opus 5.5 (xhigh) * chore(editor): move fileDrop locale strings next to overwriteConfirm Opus 5.5 (xhigh) * feat(editor): toast how to undo after a drop replaces the scene Opus 5.5 (xhigh) |
||
|
|
f3d99c42e9 |
fix(editor): pan the canvas to follow the caret instead of shifting the UI (#12175)
* fix(editor): keep the text editor flush with the canvas when a click places the caret in a tall text Editing a text that reaches past the viewport with the text tool (a click places the caret) scrolled the editor's content: the textarea is clamped to the viewport, focus() reveals the caret — which sits at the end of the value until the initial selection is applied — and the deferred refocus after the pointerdown's blur did that on an unfocused textarea, so the text moved up by the overflow while the box stayed put. The selection tool's single-click edit starts on pointerup, with no blur, so it never refocused and looked fine. After placing the initial caret — inside the visible box by construction — the textarea's own scroll goes back to zero. Verified in the browser on a 49-line text hanging 250px below the viewport at 90% zoom: internal scroll was 278px, now 0, for the text tool with the mousedown blur; the selection-tool path stays at 0. jsdom textareas don't scroll, so there is no unit test for it. Claude Fable 5.1, high effort * fix(editor): place the editor of a rotated text where the text is The editor's textarea was scaled and rotated about its own box's center, while the canvas rotates a text about the text's center. The two only agree when the box is the text's size — and it isn't once the box is clamped to the viewport (a tall text reaching past it) or, slightly, through the 5% height buffer. A rotated text scrolled partly off the viewport got its editor displaced by the rotated pivot offset (36px for a 49-line text at -18° and 130% zoom), and the clamp — a cut along the box's own edges — removed lines that were still on screen on the side the rotation lifted. Now the transform origin is the text's center whatever the box's size, with the translate derived from the text's dimensions, so the editor lands on the text in every case; the viewport clamps (right and bottom) apply only to an unrotated box, whose edges are the viewport's, and a rotated box is left whole. Since an unclamped box can reach past the viewport, both focus() calls pass preventScroll so revealing the caret can never scroll the container — the app owns scrolling. Verified in the browser: all four corners of the rotated editor coincide with the text's (they were off by up to 36px), typing at the off-screen end leaves the container unscrolled, and the unrotated case is unchanged — still clamped to the viewport bottom, top-left in place, no internal scroll on a click caret, select-all on Enter. Also updates the linear-editor style snapshot for the editor's explicit transform origin. Claude Fable 5.1, high effort Claude Opus 5.5 (1M), xhigh effort (moved the snapshot update in) * fix(editor): never let the browser scroll the editor container to reveal a caret The root container is `overflow: hidden`, which still makes it a scroll container the browser scrolls programmatically. Typing in a text editor whose box reaches past the viewport — a rotated text's editor cannot be clamped to the viewport without losing lines still on screen — made the browser reveal the caret by scrolling the container, pushing the whole UI out: two new lines below the viewport moved the toolbar up by 416px. `overflow: clip` clips exactly like `hidden` but is not a scroll container, so there is nothing for the caret reveal (or a focus) to scroll; `hidden` stays as the fallback where `clip` is unknown. The editor never scrolled this box itself. Verified in the browser with real key input on a rotated 49-line text hanging below the viewport at 130% zoom: the container stays at 0 and the toolbar in place through new lines and characters typed past the viewport. Claude Fable 5.1, high effort * fix(editor): the canvas follows the caret instead of the page scrolling to reveal it With the editor root at `overflow: clip` (previous commit), the browser's caret reveal skips the root, since it is no longer a scroll container. Full page, nothing moves, but the caret stays out of view. Embedded in a page that scrolls (directly, or in a same-origin iframe), the reveal scrolls that host page instead: 434px for a rotated text reaching past the editor's bottom edge, 345px for a text scrolled below it while edited. Only a cross-origin iframe stops it. The root goes back to `overflow: hidden`, so it absorbs the reveal again. While a text is being edited, the text editor hands any scroll of the root to the canvas (`viewport.translate` by the same screen distance) and resets the root. Scroll events fire before the frame is painted, so the root's shift is never seen. Nothing moves, host page included, and the canvas pans just far enough to bring the caret into view. The reveal is the browser's, so this covers everything that moves the caret: typing, Enter, arrow keys, paste, IME, and drag-selecting past the edge. Measured with real key input in Chromium, full page and in a same-origin iframe embed: host page and root unmoved, canvas panned 444px / 330-355px, caret visible, the editor still on its text. Outside text editing, a root scroll is left as it was. Test: a root scroll while editing is reset and pans the canvas (at 200% zoom, both axes) with the editor on it; with no text being edited, the root is left alone. Claude Opus 5.5 (1M), xhigh effort * fix(editor): never cut the text editor to the viewport; the canvas follows the caret for every text The editor of an unrotated text was cut to the viewport's right and bottom edges (the width of unbound text, plus a max-height), so a caret past an edge was revealed by scrolling the textarea's content inside the cut box. The canvas stood still, and the editor's text left its place on the canvas while typing. Rotated texts were already left whole, and their caret reveal panned the canvas (previous commit). Now no editor box is cut: rotated or not, it is the text's own box, and a caret past the viewport's edge is revealed by panning the canvas. Typing past the bottom edge, or a long line past the right edge, moves the view along; the editor stays on its text. What only existed for the cut goes with it: the `maxWidth` bookkeeping, and the reset of the textarea's internal scroll after placing a click's caret (a whole box never scrolls inside). Measured with real input in Chromium: 455px vertical pan for a 30-line text crossing the bottom edge, 454px horizontal pan for a line crossing the right edge, root and textarea unscrolled, editor on its text. A text-tool click on line 3 of a text taller than the viewport places the caret there without any pan or internal scroll. Tests: the max-width test now checks the editor isn't cut at either edge (it fails with the cut in place); the linear-editor style snapshot loses its max-height. Claude Opus 5.5 (1M), xhigh effort * feat(editor): leave room past the caret when the canvas follows it The browser reveals a caret only as far as it has to, so text typed past the viewport's edge came into view flush against the edge. The canvas now pans a further CARET_FOLLOW_PADDING (5 screen px) along the axis it follows, so the caret keeps a little room from the edge while the view follows it. Not done with the root's `scroll-padding`: the browser honors it only as far as it can scroll, and the editor box ends at the text (5% below it, nothing past a line's end). Measured in Chromium: `scroll-padding: 80px` left 39px below the caret and none to its right. Measured with real input: once the caret reaches the edge, every new line or character pans the canvas and the caret stays 4-5px from the edge, both typing down and along a long line. Test: the pan includes the padding, and a reveal along one axis pans along that axis only. Claude Opus 5.5 (1M), xhigh effort * feat(editor): the canvas follows a caret behind a docked sidebar; the reveal stays in the editor's box A caret that went behind a docked sidebar stayed hidden: it was still inside the editor root, so the browser had nothing to reveal and the canvas never followed. The text editor's container (`.excalidraw-textEditorContainer`) is now the box a caret is revealed into: the canvas area, clipped, and inset off a docked sidebar (measured from `.sidebar--docked` on every editor update, on the right or the left for RTL; the editor's position is relative to the box). A caret past its edge, or behind the sidebar, is revealed by scrolling the box, which the text editor turns into a canvas pan (+ padding) and resets, as it did for the root before. The reveal now never gets past the box, so the editor root is back to master's CSS, and what a host does to the root's overflow no longer matters: with the root forced to `overflow: visible` (as Excalidraw+ does, inside a wrapper that resets its own scroll), the canvas follows the same way and the wrapper never scrolls. Measured with real input in Chromium, full page, with the root forced to `visible`, in the Excalidraw+ arrangement, and in a same-origin iframe embed: nothing scrolled (host page, root, box), the canvas panned 449 / 360 / 580px for texts past the bottom edge, and for a line typed in behind the docked sidebar it panned 120px, leaving the caret 5px left of the sidebar. Clicks inside the editor still place the caret; clicks on the canvas still end editing. Tests: the pan test scrolls the editor's box; a new test covers the insets for a sidebar docked on either side, and their removal with the editor. Claude Opus 5.5 (1M), xhigh effort * refactor(editor): measure the sidebar through the viewport UI markers The text editor's box was kept off a docked sidebar by querying the `.sidebar--docked` class and measuring it inline in textWysiwyg. How far viewport UI reaches into the canvas is AppViewport's concern: `getOffsets` measures every UI marked `data-viewport-ui`, and the sidebar is marked (`side` / `sidebar`, except on phones, where it's an overlay). AppViewport gains `getSidebarInsets()`, which measures just the sidebar through those markers, with a `measureSide()` that `getOffsets` now shares. The text editor asks the viewport for the insets. An open sidebar that isn't docked covers the canvas the same way and now counts too; it closes when the canvas is clicked to start a text, so this rarely matters. `getOffsets` isn't used for the box: it gives one band per side for all marked UI, so the top toolbar and the styles panel (open while a text is edited) would pan the canvas as soon as one typed below them. Test: the docked-sidebar test's fake sidebar carries the markers. Claude Opus 5.5 (1M), xhigh effort * feat(editor): a text stops growing at the view's width, and is brought into view when it wraps Closes #8855. Typing a long line grew a free text past the view's edge. The canvas followed the caret, so the start of the text went out of view on the other side, and the box had to be resized by hand afterwards. A free text that grows with its content (`autoResize`) now stops at the view's width: the canvas minus the sidebar, less TEXT_VIEWPORT_PADDING (20px, in @excalidraw/common) at each side, in scene units, so it follows the zoom. On the keystroke that would cross it, the text becomes fixed width at exactly that width and wraps from then on (`autoResize: false`), anchored the way it grew, so a right-aligned or centered text keeps its edge or center. The editor switches to wrapping with it. A text that is already wider keeps growing; nothing shrinks. The rule is an optional `maxWidth` on `refreshTextDimensions`, which the editor's per-keystroke update passes. When a text starts wrapping in the editor, while typed or pasted into it, it is scrolled into view with the same room at the edges. Being exactly the padded view's width, it then fills it. That happens horizontally always, and vertically only if all of it fits; a taller text leaves the vertical to the caret-follow, so the view doesn't jump away from the caret. Pasting onto the canvas keeps its wrap width (half the view, 200-800), capped by the same limit, so that the 200 minimum can't outgrow a narrow or zoomed-in view. A paste that wrapped is scrolled fully into view; if it is taller than the view, its top is brought in. Scrolling into view is computed by a pure `scrollBoundsIntoView` in viewport.ts, next to `centerScrollOn` and `constrainScrollState`. It moves by the least amount, keeps the zoom, takes the usable area as `Offsets`, and returns null when nothing needs to move. The move itself goes through `viewport.translate`, as a user pan does: it respects scroll locks, yields to a host's pending locked navigation, and stops following a collaborator. No move, no translate. Measured with real input in Chromium on a 2560px view: typing character by character at 400% from 100px in, and a sentence inserted at once, both end as a wrapped text from 20 to 2540; with the docked sidebar the limit is 2226 (2560 - 294 - 40) and the text sits 20px left of it; a paste near the bottom-right corner ends 20px inside the right and bottom edges. Nothing scrolled the page or the editor box. Tests: the text stops and wraps at the view's width, and keeps that width; the limit follows the zoom and a docked sidebar; a right-aligned text keeps its right edge; a text already wider keeps growing (the not-cut-off test, reworked); starting to wrap scrolls it into view with room at its right edge, but not vertically when it's taller than the view; a paste isn't wider than the view at 800% and, wrapped, ends fully in view; `scrollBoundsIntoView` unit tests. Claude Opus 5.5 (1M), xhigh effort |
||
|
|
c10499eebb |
feat(editor): text tool edits what you click, shows what a click will do, drags out free text in shapes (#12082)
* 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> |
||
|
|
c12a8cd037 | fix(editor): reduce cursor hint cooldown to 30s (#12174) | ||
|
|
5feb2172d5 |
feat(editor): better library search + display names (#12173)
* feat(editor): delayed name tooltip on library items - library items with a name show it in a tooltip above the item after the standard tooltip delay (500ms, or right away when moving over from another tooltip); hidden on leave and on press - driven by pointer (not mouse) events so touch taps don't leave the tooltip behind, while the mouse-driven hover state (which reveals the checkbox on tablets) stays as is - no per-item hooks: Tooltip exposes `showTooltip()` / `hideTooltip()` (plus a `position` option) for elements that can't be wrapped in <Tooltip>, and <Tooltip> itself now uses them - a visible tooltip now hides once its item is removed from the DOM (e.g. filtered out by the library search, sidebar closed), which fires no pointerleave. A MutationObserver is active only while a tooltip is visible; a delayed tooltip whose item unmounted while pending no longer shows Opus 5.5 (1M context), xhigh * fix(editor): Escape in library search clears it instead of closing sidebar With a search active in an undocked sidebar, Escape closed the sidebar: the search relied on the browser's native clearing of search inputs, but nothing stopped the event from reaching the Sidebar's document Escape listener. Clear the search ourselves and stop the event on the input. A second Escape (empty search) still closes the sidebar. Also adds a test for Escape clearing the item selection without closing the sidebar (already working). Opus 5.5 (1M context), xhigh * feat(editor): larger named items in library search results - search results render in 2 columns with bigger previews and the item name below (ellipsified when it overflows); the whole card, name included, is clickable/draggable - the name tooltip only shows when the name is truncated - placeholders match the card size, and the grid is full width so it doesn't shrink to its content while only placeholders are rendered - reserve the library scrollbar's space (scrollbar-gutter: stable) so the content doesn't shift when results start/stop overflowing Opus 5.5 (1M context), xhigh |
||
|
|
5db42c3ddb |
feat(editor): zen mode polish, hide toolbar hints preference & delayed canvas tooltips (#12165)
* feat(editor): preference to hide toolbar hints Add a "Show hints" checkbox to the Preferences menu, backed by a new browser-persisted `appState.showHints` (default true). When off, HintViewer renders nothing. Also exposed as `Preferences.ToggleShowHints` for hosts composing their own preferences list. Opus 5.5 (1M context), low * fix(editor): keep main menu open when toggling view mode Entering view mode ran terminateActiveInteraction, which reset `openMenu` and closed the main menu the user had just toggled view mode from (Preferences submenu). The main menu stays available in view mode, so only close it when interaction gets disabled. Opus 5.5 (1M context), low * feat(editor): chrome-less canvas controls & in-place fades in zen mode - UI hidden in zen mode fades out in place (opacity + visibility) instead of sliding off the viewport, and is non-interactive from the start of the fade - visible canvas controls (toolbar, main menu trigger, zoom, scroll back to content, exit zen mode) drop their background/border/shadow; hover/active states still apply, popups & dialogs are unaffected - only the zoom level stays visible out of the zoom controls - chrome fades are applied only while toggling (data-zen-mode-transition on the container, set before the DOM update) so hover transitions stay instant - single ZEN_MODE_TRANSITION_DURATION, exposed to CSS as --zen-mode-transition-duration - zoom & undo/redo button groups paint a single background (on the group) instead of one per button Opus 5.5 (1M context), low * feat(editor): shift zoom level into the zoom-out slot in zen mode With the zoom +/- buttons hidden in zen mode, slide the zoom level over to the left edge of the group. The shift is applied to the Tooltip wrapper (new `className` prop) rather than the button, so the tooltip (positioned off the wrapper) moves along. Also give the reset-zoom button its own hover background: unlike the +/- buttons it has no .ToolIcon__icon, and lost its (incidental) hover once the per-button backgrounds were removed. Opus 5.5 (1M context), low * feat(editor): delayed tooltips on canvas buttons - Tooltip gets a `delay` prop: shows after 500ms, or right away if a tooltip was visible within the last 300ms (moving across adjacent buttons); passing over a button quickly doesn't flash one - enable it for the zoom, undo/redo & help buttons - zoom +/- use our Tooltip instead of the native `title`, and the reset zoom button drops its native `title` (it was showing both) - zen mode now hides the zoom +/- tooltip wrappers rather than the buttons, so the invisible wrappers don't show tooltips Opus 5.5 (1M context), low Co-authored-by: GitUserVivek <jagtapvs9832@gmail.com> |
||
|
|
e40d1cba95 |
feat(editor): compact styles panel (#12163)
* fix(editor): align buttons inflating styles panel intrinsic width The flex-wrap row break (zero-height flex-basis: 100% spacer) only breaks the line at layout time; for intrinsic sizing both rows were measured as a single line. Render the two rows as separate flex rows instead. Opus 5.5 (1M context), low * fix(editor): position range slider value label and track via CSS The label position and filled track were computed in JS from the input's offsetWidth, and only when the value changed, so they went stale when the container resized. Drive both from a --range-progress CSS variable, and drop the UA default inline margin that offset the track from the label. Opus 5.5 (1M context), low * feat(editor): compact styles panel Styles panel buttons are 32px regardless of screen size, rows are closer together, top-pick swatches are packed tighter, and the panel sizes to its content (up to the previous fixed width). Opus 5.5 (1M context), low * reduce large icon size on 1920px+ screens * fix color picker border color on mobile |
||
|
|
1118751f3e |
feat(editor): customizable font picker top picks (#12160)
* feat(editor): customizable font picker top picks Font families can be dragged from the font picker list onto the top-picks strip to replace a pick, the strip can be reordered by dragging, and right-clicking it resets to the default picks — same as the color picker top picks. The drag ghost is only the font's icon (on a strip-button tile). The color picker's drag & drop is generalized into components/TopPicksDnD (value-agnostic hook, reorder preview, drop outline, reset context menu, shared styles), with thin per-picker adapters (colorTopPicksDnD / fontTopPicksDnD) supplying value equality and the ghost visual. Custom picks are kept in `appState.fontTopPicks` (browser-persisted only, kept on canvas clear); restore drops unknown/internal/fallback families, dedupes, and caps at the strip's 3 slots. Claude Opus 5.5 (1M context), reasoning effort: 40 * feat(editor): reset link in top picks tips The "drag to pin" tip in the font picker and color picker popups now ends with a plain-text "Reset" link while that picker's top picks are customized, restoring the defaults without leaving the popup. Shared as TopPicksTip (tip text styles merged into it). The link never takes focus on click and hands it back to the picker when activated by keyboard, since it unmounts on reset; Enter/Space are kept from the pickers' key handlers. Claude Opus 5.5 (1M context), reasoning effort: 15 * fix(editor): don't clip or overlap font list item focus/pressed rings The font list scrolls (clipping outer box-shadows at its edges) and each item paints over the previous one's bottom edge, so the focus-visible and :active rings are drawn inset there. Pressing and holding items became common with dragging fonts onto the top picks. Claude Opus 5.5 (1M context), reasoning effort: 15 * fix(editor): cancelled top picks drag flying to viewport top-left The ghost (and its home rect) was built on drag activation, by which time the source could have been re-rendered away — the font list re-renders on hover — and a detached element measures as a zero rect. Build and measure it on pointerdown instead, while the source is mounted. Claude Opus 5.5 (1M context), reasoning effort: 15 * fix: Always show 3 slots to reorder Signed-off-by: Mark Tolmacs <mark@lazycat.hu> * fix: Cleanup Inconsistent state updates. Font picks go through useExcalidrawSetAppState directly, while color picks go through the action's updateData. Duplicate icons in the strip. Virgil and Excalifont both use FreedrawIcon, so pinning both shows two identical tiles that can only be told apart by their tooltips. Signed-off-by: Mark Tolmacs <mark@lazycat.hu> * fix: remove useMemo Signed-off-by: Mark Tolmacs <mark@lazycat.hu> --------- Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Co-authored-by: Mark Tolmacs <mark@lazycat.hu> |
||
|
|
dc2c16d9e2 |
fix(editor): Binding follow-ups to #10753 (#12156)
* fix: Midpoint highlight color in dark mode
The highlighted binding midpoint hardcoded rgba(3, 93, 161) for dark mode,
the pre-filter color from when the interactive canvas was inverted by a
CSS filter. The canvas now draws post-filter colors directly, so it
rendered dark blue. Use BINDING_HIGHLIGHT_RGB like the rest of the binding
highlight.
Claude Opus 5.5 (1M context), high reasoning effort
* fix: Show binding midpoints like before the hit test rework
The binding highlight drew all four midpoints for any suggested binding,
including simple arrows with the cursor deep inside the element. Restore
the previous visibility: simple arrows show no midpoints from inside, and
from outside only the one nearest the cursor once it gets near; elbow
arrows show all of them. The highlighted midpoint still comes from the
suggested binding, so it sits where the arrow actually snaps.
Claude Opus 5.5 (1M context), high reasoning effort
* fix: Images and locked opaque elements occlude binding
Only filled shapes hid the elements behind them from binding. Images now
count as opaque, and locked opaque elements hide what is behind them too
(they still can't be bound to). Frames are future-proofed: once they get
a background, an opaque frame hides the non-children behind it without
ending the search, as its children sit just below it in z-order.
Claude Opus 5.5 (1M context), high reasoning effort
* refactor: Add DEFAULT_ZOOM for computations without an editor zoom
Replace the scattered { value: 1 } as Zoom / AppState["zoom"] casts used
as a zoom fallback (elbow arrow routing, transform, element rendering,
binding repair on restore, tests) with a shared frozen constant.
Claude Opus 5.5 (1M context), high reasoning effort
|
||
|
|
4850bf336f |
fix(editor): Binding hit test based on distance (#10753)
--------- Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
2b9da96100 |
fix(editor): Freedraw hit test precision (#11550)
--------- Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
31df3e6ef2 |
fix(editor): Tab convert bound arrow update (#12143)
--------- Signed-off-by: Mark Tolmacs <mark@lazycat.hu> |
||
|
|
97c68dd371 | refactor(editor): split out duplication logic into App.duplicate.ts (#12125) | ||
|
|
14e1c614d4 |
feat(packages/excalidraw): support onDuplicate replacements, vetoes and lookups (#12124)
feat(packages/excalidraw): make `onDuplicate` changes stick, support veto & lookups The editor kept working with the duplicates it created after calling `props.onDuplicate`, so a host returning new objects for them detached them from the frame assignment and bound text redraw on paste, and from the alt-drag handover. - Add `reconcileDuplicatedElements()` and use it in all three duplication paths (paste/library insert, duplicate action, alt-drag). Elements the host returns for duplicates are shallow-merged into the editor's own objects (omitted properties are kept), invalidating what may have been cached for them by then (shape, bounds). Other returned elements keep the replace semantics. - Omitting a duplicate (or returning it as deleted) vetoes it. So does returning `false` for the duplication as a whole. Bound text goes with its vetoed container, and references to vetoed duplicates are cleared from the remaining ones (as in `fixDuplicatedBindingsAfterDuplication`). If no duplicate remains the duplication is cancelled: paste and the action do nothing, alt-drag keeps moving the originals. On a partial veto during alt-drag the vetoed originals are left behind. - On paste, resolve the target frame (and the z-order under it) before the callback, so that the host sees the duplicates the way they end up in the scene, and a vetoed duplicate isn't brought back. - Pass lookups covering just the participating elements as a third argument: `duplicateElements`, `originalElements`, `origIdToDuplicateId` and `duplicateIdToOrigId`. Claude Fable 5.1 |
||
|
|
c0ad61c674 |
feat(editor): rightclick to pan canvas (#12110)
* refactor(editor): move the drag-pan out of App.tsx into App.pan.ts `handleCanvasPanUsingWheelOrSpaceDrag` — gating, the Linux middle-click paste guard, the throttled move and the teardown — moves as-is into an `AppPan` helper (`app.pan`), in the shape of `App.cursor` and `App.viewport`. The session state it ran on was module-level in App.tsx (`isHoldingSpace`, `isPanning`, `flushPanMove`), shared by every `App` instance in the page: with two editors mounted, a wheel-button pan in one made the other ignore wheel input. It is instance state now — `spaceHeld`, `active`, the pending move flush and the teardown — behind `start`, `isActive`, `flushMove`, `end` and the space accessors. Only the pointer count is injected; `gesture` stays where it is. `AppWheel` asks `app.pan` directly instead of taking those two callbacks as dependencies, the wysiwyg's wheel-button pan calls `app.pan.start`, and `maybeCleanupAfterMissingPointerUp` ends the pan explicitly, which leaves `lastPointerUp` to the scrollbar drag alone. `savePointer` becomes public for the teardown. No behavior change. Claude Fable 5.1, high effort * feat(editor): pan by dragging with the secondary mouse button A secondary-button press starts a pan session like the wheel button does, except that it stays a right-click until the pointer travels past the drag threshold: nothing moves and the cursor stays put before that; past it the canvas follows the pointer (from that point, the threshold distance is not caught up) and no menu opens. Released before the threshold, the session opens the context menu itself at the release point, through the same `openContextMenu` the `contextmenu` handler now delegates to. That handler ignores the platform's `contextmenu` event while a secondary-button session is active — macOS and Linux fire it on mousedown, before a drag can be told from a click — and once after the session ends if it has not fired yet, which is Windows firing it on mouseup. Touch long-press, pen barrel taps and keyboard-triggered menus never go through a session, so they open as before. On macOS and Linux the menu now opens on release rather than on press. View mode already let any button pan; a right-click there opens the view-mode menu on release the same way. With interaction disabled the drag still pans (when navigation is enabled) and no menu opens. Tests: the threshold, the menu on release, both platform orders of the native event, no menu after a drag, element selection under the pointer, and the non-interactive editor. Claude Fable 5.1, high effort * fix(editor): let the contextmenu after mouseup open the menu for a right-click, as before The secondary-button session opened the menu on release everywhere. On Windows the platform's `contextmenu` fires after mouseup, by which time our menu was rendered under the pointer; the event then targeted the popover instead of the canvas, the canvas handler that prevented the native menu never ran, and the native menu opened on top of ours. The session now opens the menu on release only where the platform's `contextmenu` already came with the press (macOS, Linux) and was swallowed because a drag could not be told from a click yet. Where it follows the release, it opens the menu itself, exactly as before this branch, and lands on the canvas as there is nothing under the pointer. After a drag that event is swallowed once, as it was. Tests: after mouseup, nothing is open at release and the platform's event opens the menu; the mousedown-order and drag cases are unchanged. Claude Fable 5.1, high effort * reintroduce: ctrl+wheel should always zoom for both input devices * fix(editor): lower right-button pan threshold to 5px * fix(editor): broadcast the final pointer position after panning * fix(editor): suppress cursor broadcasts while panning |
||
|
|
a918648012 |
feat(packages/excalidraw): add renderOverrides to allow overriding of element rendering (#12100)
* feat(packages/excalidraw): add renderOverrides to allow overriding rendering of elements * refactor(renderer): centralize overrides and inherit label offsets * fix(renderer): preserve frame-name editing during visual overrides * perf(renderer): limit override culling to translated elements * fix(renderer): keep frame-title hit bounds at document positions * fix(renderer): skip clearing an already clear override snapshot `setElementRenderOverrides(null)` (or an empty map) on an editor whose snapshot is already empty no longer copies, derives offsets or schedules a render. Every other submission still repaints, equivalent ones included: a value comparison would cost about as much as the copy it saves on every changing frame, so hosts are told to submit only on change and to dedupe at their own level, where the snapshot's structure is known. Fable 5.1, medium reasoning * refactor(renderer): drop two override edge cases Culling no longer keeps an off-screen container alive because its bound label alone would be in view: the label follows its container, as it does for document culling, and the case needed a label wider than its (short arrow) container at the viewport edge during a translation. Frame clipping no longer tracks whether an element carries an explicit offset entry (`hasPositionOverride`); a non-zero offset is what matters. An explicit zero offset leaves the element at rest, where the ordinary overlap check already decides correctly. Fable 5.1, medium reasoning * perf(renderer): leave bound-label entries out of override culling A bound label is drawn with its container and its own entry in the visible list is skipped by the static renderer, so override culling has no reason to adjust that entry. Doing so made a visible container whose label is document-off-screen (a long arrow with its label past the viewport) report the label as newly visible on every translation, which rebuilt the visible set from the whole scene each frame. Translated elements now update only their own membership; the label lookup per translated element goes too. Fable 5.1, medium reasoning |
||
|
|
4e758db17e |
feat(editor): wheel-button zoom and "zoom with scroll wheel" preference (#12099)
* refactor(editor): move wheel handling out of App.tsx into App.wheel.ts
The wheel handler (pan, ctrl/cmd zoom, shift horizontal pan) and the
non-interactive browser-zoom guard move as-is into a new `AppWheel`
helper (`app.wheel`), in the same shape as `App.cursor` / `App.viewport`.
The zoom math becomes a private `zoomBy(deltaY)` so the upcoming wheel
features (wheel-button zoom, zoom-with-scroll-wheel preference) only add
branches to the dispatch instead of another copy of it.
`isPanning` is module state in App.tsx, so it is handed in as a
dependency the way `AppViewport` gets `isGestureActive`; the debounced
`shouldCacheIgnoreZoom` reset is shared with the touch pinch handler and
becomes public. No behavior change.
Claude Fable 5.1, high effort
* feat(editor): zoom by scrolling with the wheel button held down
Scrolling while the wheel (middle) mouse button is held zooms the canvas
around the pointer, exactly like ctrl/cmd+wheel. The button is what
starts a drag-pan and the wheel sits under the same finger, so a pan can
be zoomed one-handed without reaching for a modifier. It wins over the
modifiers (shift included) and over the drag-pan's own wheel gate, which
until now swallowed all wheel input mid-pan. It is read off
`event.buttons`, so no extra state is tracked.
Zooming mid-pan exposed three ways the point grabbed by the pan could
drift from under the cursor, compounding with every tick while the
pointer moves between zooms:
- The drag-pan wrote its scroll as a plain object computed from
`this.state`. Under React 19 a wheel zoom is flushed asynchronously,
and the pan's animation frame runs before that flush, so the pan
overwrote the zoom's anchoring scroll with a pre-zoom value — the zoom
then happened around the viewport origin instead of the cursor. The pan
is now a state updater.
- A pointer move still waiting for its frame landed after the zoom, which
had anchored on a viewport that didn't include it. The wheel handler
flushes the pending move first.
- The zoom updater read `viewport.lastPosition` when React evaluated it,
by which time the pointer had moved on; it also started from
`app.state.zoom`, so ticks queued before a flush collapsed into one.
Both are now taken at wheel time / from the updater's own state.
Tests: plain wheel pans, shift pans horizontally and ctrl/cmd zooms
(pinning the baseline); with the button held, scrolling zooms in and out
regardless of shift; during a wheel-button drag-pan, plain wheel input is
still swallowed while wheel-button scrolling zooms, and panning is back
after release. Two regression tests pin the grabbed point under the
cursor: one batches the zoom with a pan landing in the same React flush,
one holds the pan's frame back until after the wheel fires (through a
file-level `withBatchedUpdatesThrottled` mock, since the test setup makes
the throttle synchronous). Both fail on the previous code by 11.8 and
2.7 scene units respectively.
Claude Fable 5.1, high effort
* feat(editor): "zoom with scroll wheel" preference
Adds `appState.zoomWithScrollWheel` (default `false`, persisted to
browser storage like the other preferences), for mouse users who would
rather scroll to zoom: a plain wheel zooms around the pointer, and
ctrl/cmd+wheel pans vertically instead — the two roles swap, the way
tldraw's wheel-behavior preference does it. Shift+wheel keeps panning
horizontally, and the wheel-button zoom keeps winning over everything.
One consequence to be aware of: a trackpad pinch arrives as ctrl+wheel,
so with the preference on it pans.
The toggle lives in the main menu's preferences submenu ("Zoom with
scroll wheel", right under "Select on"), exposed as
`MainMenu.DefaultItems.Preferences.ToggleZoomWithScrollWheel` and backed
by the `zoomWithScrollWheel` action, allowed in view mode since it only
concerns navigation.
Tests: the preference is off by default and its action toggles it; with
it on, plain wheel zooms while ctrl and cmd wheel pan vertically, shift
still pans horizontally and the wheel button still zooms. Snapshots pick
up the new default.
Claude Fable 5.1, high effort
* perf(editor): keep the zoom bitmap cache across pan frames during a wheel zoom
`viewport.translate` cleared `shouldCacheIgnoreZoom` on every call. Wheel
zoom sets the flag so elements draw from zoom-scaled cached bitmaps, but
a drag-pan frame landing between two ticks cleared it again, and the
static renderer then re-rasterized every element at a zoom the next tick
was about to change. Wheel-button zoom-while-panning interleaves the two
by design (the old `isPanning` gate made the combination impossible with
a mouse); trackpad pan-plus-pinch has always alternated plain and ctrl
wheel events the same way.
Measured with 1,200 rough shapes on screen, a 12-tick zoom with a small
pointer move on every frame: 10,434 / 8,774 bitmaps regenerated in two
runs, longest frame 242 / 283 ms, gesture 2.9 / 2.3 s. With the pointer
still: 0 bitmaps, 9 ms, 0.39 s.
The reset now happens only when a gesture interrupts an animated
`setViewport`, whose frames set the flag and whose completion handler
would have cleared it. Everywhere else the flag is left to the zoom paths
and their 300 ms debounce. After the change the moving-pointer run
regenerates 0 bitmaps, longest frame 25–33 ms, gesture 0.7–0.8 s. A
plain wheel pan within 300 ms of a zoom now draws from scaled bitmaps
until the debounce fires instead of re-rasterizing on its first frame.
Tests: pan frames between wheel-button zoom ticks keep the flag until the
gesture is idle (fails on the previous code); the interrupted-animation
test also pins the reset, with a 60 s animation and a polling assertion
so a saturated machine cannot finish the animation before the check.
Claude Fable 5.1, high effort
* fix(editor): handle wheel input over a frame label once
The frame label had a React `onWheel` handler while also sitting inside
the editor container, whose native wheel listener already accepts
`.frame-name` targets. Both received the same event, so every wheel tick
over a label panned or zoomed twice (measured: −140 px of scroll for a
tick that moves the canvas −70 px elsewhere). React registers `wheel`
as passive, so the React path could not even preventDefault. Predates
this branch; with the zoom-with-scroll-wheel preference it became a
two-step zoom per tick over labels.
Drop the prop. With no React path left, the wheel handler's event type
narrows to the native `WheelEvent`.
Test: a wheel event on a frame label pans one step; it fails on the
previous code by exactly one extra step.
Claude Fable 5.1, high effort
* refactor(editor): model the wheel preference as an input device
`appState.zoomWithScrollWheel` becomes `appState.inputDevice:
"auto" | "mouse" | "trackpad"`.
Claude Fable 5.1, high effort
* fix(editor): decide whether a translate zoomed against the state it applies to
`viewport.translate` captured `prevZoom` from `app.state` before queuing
its update, then a second updater compared it with the zoom it found in
`prevState` to tell a zoom apart from a pan — a zoom drops the rubberband
overscroll to 0, a pan keeps it. With a zoom queued in the same React
flush, `app.state` still holds the pre-zoom value, so a pan applied on
top of that zoom read as a zoom of its own and hard-clamped the
overscroll away in one frame instead of easing it back. Two wheel events
in one flush could already do this; the wheel-button zoom makes it
routine, as the handler applies the pending pan move on every tick.
The two `setState` calls become one updater: the update is applied to
`prevState`, the zoom comparison is between that state and the result,
and the constrained viewport is merged into it. No captured state, no
ordering to get wrong.
Test: rubberbanded past the box edge, a ctrl+wheel zoom tick and a wheel
pan batched into one flush keep the overscroll; on the previous code it
lands exactly on the edge.
Claude Fable 5.1, high effort
* fix(editor): pan sideways on a horizontal-only wheel; no-op zoom ticks at the limit
In mouse mode a wheel event with only a horizontal delta — a tilt wheel,
a sideways two-finger scroll — took the zoom branch with `deltaY` 0: no
viewport change, but every such event flipped the bitmap-cache flag,
cancelled collab follow and re-armed the cache debounce, and the sideways
scroll itself went nowhere. A wheel with no vertical delta never zooms
now; it falls through to the pan, which moves the canvas by `deltaX`. A
wheel with no delta at all is ignored outright.
The zoom updater also returns early when the tick lands on the zoom it
started from — at MIN or MAX zoom — instead of setting the cache flag and
redrawing the scene on every tick. This predates the branch. To make
that a real no-op, `viewport.translate` treats a `null` update as nothing
to do rather than re-constraining and re-setting the whole state.
Tests: in mouse mode a horizontal-only wheel pans sideways and leaves the
cache flag alone; a zoom-out tick at MIN_ZOOM changes nothing. Both fail
on the previous code.
Claude Fable 5.1, high effort
|
||
|
|
afa3a653fc |
feat(editor): sticky notes (#12064)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix changelog * fix snaps * more snap --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6574de60b3 |
feat(editor): snap element bitmaps, scroll and grid to whole device pixels (#12063)
* feat(editor): blit cached element bitmaps on whole device pixels Elements are painted from cached bitmaps, blitted 1:1 with smoothing disabled for unrotated and right-angle elements. Nearest-neighbor at a fractional offset is then a pixel-exact copy shifted to the nearest pixel — except at an exact half pixel, where a GPU-accelerated canvas decides the rounding per scanline by float precision: a few rows sample the neighboring row and glyph strokes come out doubled or broken, varying with scroll and position. A centered label lands on a half pixel routinely (odd free space ÷ 2), which is how it showed up on sticky notes. Measured in Chrome: a label blitted at y = 639.5 had 3 of 82 scanlines taken from the adjacent row; rounded to 640 it was clean. Round the blit position to whole device pixels for those elements. The context is scaled at the blit (canvas scale × zoom ÷ devicePixelRatio), so the rounding goes through `context.getTransform()`: rounding the pre-zoom value only lands on device pixels at 100% zoom. Rotated elements are resampled regardless and are left alone; export draws directly and is unaffected. Tests: the static canvas's `drawImage` events, with their CTM, land on whole device pixels for text and a rectangle at zoom 1, 1.5, 0.73 and 2.2 under fractional scroll; a rotated element keeps its offset. Extracted from dwelle/sticky-notes-2 (originally eddb67ca0). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(editor): draw the canvases at a whole-device-pixel scroll Panning writes a fractional scroll (pointer delta ÷ zoom). With every bitmap landing on its nearest device pixel, a fractional scroll means elements with different sub-pixel phases jump on different frames — a one-pixel wobble between neighbors, a label shifting against its box — and one scroll value in every pixel puts every integer-positioned element on the half-pixel boundary at once. Round the scroll to whole device pixels at render time, in all three renderers with the same formula so overlays sit on the content — the way a browser scrolls a page: each element keeps its own constant sub-pixel phase and the scene moves rigidly. The real scroll in state is untouched, so hit-testing and DOM overlays are unaffected; the difference stays under half a device pixel. Export is excluded (it draws vectors). Tests: the helper rounds to device pixels and is identity when already whole; a rectangle at integer coordinates blits on whole device pixels at zoom 1 and 1.5 under fractional scroll, and a scroll a tenth of a pixel further lands on the same pixels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(editor): draw grid lines on whole device pixels at every zoom The grid was nudged onto the pixel grid only at 100% zoom, and only when the scroll happened to be whole; at any other zoom its one-pixel lines landed on fractional device pixels and rendered as two lighter pixels. Draw each line a whole number of device pixels wide, centered to cover them exactly (on the half pixel when the count is odd), computed from the zoom and the canvas scale — which also makes "one pixel" one device pixel group at devicePixelRatio 2 instead of a fractional pair. Lines thinner than a device pixel (zoomed far out) keep their sub-pixel width: their lightness is the point. The zoom-1-only nudge is subsumed. Test: with grid mode on, every grid path start on the static canvas has its across-axis coordinate on a half device pixel at zoom 1, 1.5 and 2.2 under fractional scroll. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): snap right-angle blits via the transform origin; grid width in device pixels Two review findings. Elements rotated by 90° or 270° enter the snapping branch (smoothing is off for right angles too), but their transform has near-zero `a` and `d` with the scale in `b`/`c`. Dividing by the diagonal sent the bitmap far offscreen — a rectangle at (110.37, 120.61) vanished at both angles. The snap now moves the transform's origin onto the rounded device position of the blit and draws at (0, 0): rotation, mirroring and scale stay in `a`–`d`, no inverse is needed, and the origin is an exact integer even in the canvas's float32 matrix (mapping a rounded position back through user space left it ~1e-5 off). The grid's "at least one device pixel wide" test compared `(1 / zoom) × zoom × scale` against 1 exactly; at 190% that product is 0.9999999999999999, so those lines skipped snapping and stayed blurry while neighboring zooms were crisp. The width is now computed straight in device pixels, `min(scale, max × zoom × scale)`, so there is no round trip to tolerate. Tests: rectangles at π/2 and 3π/2 blit on whole device pixels and stay next to the element; the grid test includes 190%. The blit helper in the test maps positions through the full matrix. The CHANGELOG bullets link the PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(editor): one devicePixelRatio read for the blit, no-op padding scaling dropped `drawElementFromCanvas` read `window.devicePixelRatio` eleven times; it reads it once. That read stays the global one on purpose: the cached bitmap was generated with it (`generateElementCanvas`) and the blit's size math needs the same value, so switching this path to the owner window's ratio is a separate change that has to include bitmap generation. `(padding × scale) / scale` is `padding`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): snap blits only where smoothing is off, through one predicate The snap ran during zoom gestures too, where the cached bitmaps stay at the old scale and are resampled with smoothing on: rounding there could only make elements twitch as the scale changes. The smoothing switch and the snap now share `canSnapElement` — unrotated or at a right angle, and not mid-gesture — so the two cannot drift apart. It is eligibility, not a guarantee of a 1:1 copy: freedraw is blitted with smoothing on and a size-capped cache is rescaled; snapping is harmless for both. Test: with `shouldCacheIgnoreZoom` set, the blit keeps its fractional position. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): snap a label relative to its container, tie-safe Each bitmap rounding its own origin lets a label shift by a pixel against its box as a drag crosses half pixels (nearest-neighbor sampling did the same implicitly before the explicit snap). A label under an unrotated container now anchors to the container's rounded origin and rounds its offset from it separately, so both land on whole pixels and their distance never changes; every other element anchors to itself with a zero offset, through the same code path. The offset is formed from scene coordinates before the scroll is added, and a tiny bias breaks an exact half-pixel tie the same way every frame. Ties are common — any odd scene offset at 150% — and the float noise of a fractional drag otherwise flips `Math.round` between the two neighbors: a 200-wide box with a centered label went 92 → 91 → 92 device pixels across drags of 0, 0.3 and 0.4. Tests: a plain fractional offset and the half-pixel tie keep a constant device offset across five fractional drags at 150%. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
854d00c31b |
feat(editor): track element creation timestamps (#12060)
feat(editor): element creation timestamp tracking Co-authored-by: Márk Tolmács <mark@lazycat.hu> |
||
|
|
0361bdd9cb |
chore(editor): Update translations from Crowdin (#11942)
* New translations en.json (Italian) [ci skip] * New translations en.json (Persian) [ci skip] * New translations en.json (Swedish) [ci skip] * New translations en.json (Slovenian) [ci skip] * New translations en.json (Slovenian) [ci skip] * New translations en.json (Indonesian) [ci skip] * New translations en.json (Indonesian) [ci skip] * New translations en.json (Thai) [ci skip] * Auto commit: Calculate translation coverage --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
2b1c2a9fbf |
fix(editor): dismiss dropdown menus on outside clicks (#12053)
fix: dismiss dropdown menus on outside clicks |
||
|
|
f1a79b73df |
perf(editor): stop inverting the interactive canvas via CSS filter in dark mode (#12050)
The interactive canvas (selection outlines, transform handles, binding highlights, collaborator cursors, snap lines, scrollbars) was still composited through `filter: invert(93%) hue-rotate(180deg)` in dark mode (#10578 only de-filtered the static canvas). The canvas covers the whole viewport and repaints on nearly every pointer move, so browsers that composite in software (Firefox/Zen on software WebRender or with hardware acceleration disabled) paid for a full-viewport color-matrix pass per frame and dropped from 120fps to ~40fps during marquee/drag. Light mode was unaffected. Colors are now mapped in JS via `applyDarkModeFilter`, like the static canvas. Hardcoded dark-mode values are the exact post-filter equivalents of the previous ones, so rendering is unchanged, with two intentional exceptions: - collaborator cursors and remote selection outlines render in the collaborators' actual colors (matching their avatars) instead of inverted ones (per the existing TODO in clients.ts) - the lasso trail now uses the theme's `--color-selection` like the marquee selection, instead of a hardcoded light-theme purple that never matched in dark mode `--color-selection` for the dark theme now holds the actual rendered color (#b4b0ff); hosts overriding it no longer need to pre-invert. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
214cd6e6e8 |
feat(editor): allow for dragging bound text along an arrow's path (#10947)
Arrow labels can be dragged along their arrow. A label's place is stored on the text element as labelPosition, a normalized arc-length parameter (0–1) over the arrow's whole path, and the label's x/y is derived from it. Because the parameter covers the whole path rather than one segment, the label keeps its place when points are dragged, when midpoints are inserted or removed, and when the arrow is resized. (An earlier iteration anchored labels to a segment; that approach is gone.) Closes #9338. --------- Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Co-authored-by: Mark Tolmacs <mark@lazycat.hu> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
e1bb9ff8f8 |
feat(packages/excalidraw): support rendering into another document (#11974)
* feat: support rendering into another document * add missed places * tweak jsdoc --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
4a6f4e7a32 |
feat(app): d2c streaming (#11960)
* support d2c streaming * render status spinner while streaming * enable analytics for ai |
||
|
|
e160ff7ba0 |
chore(editor): Update translations from Crowdin (#11815)
* New translations en.json (Romanian) [ci skip] * New translations en.json (Romanian) [ci skip] * New translations en.json (Romanian) [ci skip] * New translations en.json (French) [ci skip] * New translations en.json (Spanish) [ci skip] * New translations en.json (Arabic) [ci skip] * New translations en.json (Bulgarian) [ci skip] * New translations en.json (Catalan) [ci skip] * New translations en.json (Czech) [ci skip] * New translations en.json (Danish) [ci skip] * New translations en.json (German) [ci skip] * New translations en.json (Greek) [ci skip] * New translations en.json (Basque) [ci skip] * New translations en.json (Finnish) [ci skip] * New translations en.json (Hebrew) [ci skip] * New translations en.json (Hungarian) [ci skip] * New translations en.json (Italian) [ci skip] * New translations en.json (Japanese) [ci skip] * New translations en.json (Korean) [ci skip] * New translations en.json (Kurdish) [ci skip] * New translations en.json (Lithuanian) [ci skip] * New translations en.json (Dutch) [ci skip] * New translations en.json (Punjabi) [ci skip] * New translations en.json (Polish) [ci skip] * New translations en.json (Portuguese) [ci skip] * New translations en.json (Russian) [ci skip] * New translations en.json (Slovak) [ci skip] * New translations en.json (Slovenian) [ci skip] * New translations en.json (Swedish) [ci skip] * New translations en.json (Turkish) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Chinese Simplified) [ci skip] * New translations en.json (Chinese Traditional) [ci skip] * New translations en.json (Vietnamese) [ci skip] * New translations en.json (Galician) [ci skip] * New translations en.json (Portuguese, Brazilian) [ci skip] * New translations en.json (Indonesian) [ci skip] * New translations en.json (Persian) [ci skip] * New translations en.json (Khmer) [ci skip] * New translations en.json (Tamil) [ci skip] * New translations en.json (Bengali) [ci skip] * New translations en.json (Marathi) [ci skip] * New translations en.json (Thai) [ci skip] * New translations en.json (Norwegian Nynorsk) [ci skip] * New translations en.json (Kazakh) [ci skip] * New translations en.json (Latvian) [ci skip] * New translations en.json (Azerbaijani) [ci skip] * New translations en.json (Hindi) [ci skip] * New translations en.json (Burmese) [ci skip] * New translations en.json (Chinese Traditional, Hong Kong) [ci skip] * New translations en.json (Sinhala) [ci skip] * New translations en.json (Uzbek) [ci skip] * New translations en.json (Norwegian Bokmal) [ci skip] * New translations en.json (Occitan) [ci skip] * New translations en.json (German, Switzerland) [ci skip] * New translations en.json (Bengali, India) [ci skip] * New translations en.json (Kabyle) [ci skip] * New translations en.json (Karakalpak) [ci skip] * New translations en.json (Indonesian) [ci skip] * New translations en.json (Romanian) [ci skip] * New translations en.json (Romanian) [ci skip] * New translations en.json (French) [ci skip] * New translations en.json (Spanish) [ci skip] * New translations en.json (Arabic) [ci skip] * New translations en.json (Bulgarian) [ci skip] * New translations en.json (Catalan) [ci skip] * New translations en.json (Czech) [ci skip] * New translations en.json (Danish) [ci skip] * New translations en.json (German) [ci skip] * New translations en.json (Greek) [ci skip] * New translations en.json (Basque) [ci skip] * New translations en.json (Finnish) [ci skip] * New translations en.json (Hebrew) [ci skip] * New translations en.json (Hungarian) [ci skip] * New translations en.json (Italian) [ci skip] * New translations en.json (Japanese) [ci skip] * New translations en.json (Korean) [ci skip] * New translations en.json (Kurdish) [ci skip] * New translations en.json (Lithuanian) [ci skip] * New translations en.json (Dutch) [ci skip] * New translations en.json (Punjabi) [ci skip] * New translations en.json (Polish) [ci skip] * New translations en.json (Portuguese) [ci skip] * New translations en.json (Russian) [ci skip] * New translations en.json (Slovak) [ci skip] * New translations en.json (Slovenian) [ci skip] * New translations en.json (Swedish) [ci skip] * New translations en.json (Turkish) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Chinese Simplified) [ci skip] * New translations en.json (Chinese Traditional) [ci skip] * New translations en.json (Vietnamese) [ci skip] * New translations en.json (Galician) [ci skip] * New translations en.json (Portuguese, Brazilian) [ci skip] * New translations en.json (Indonesian) [ci skip] * New translations en.json (Persian) [ci skip] * New translations en.json (Khmer) [ci skip] * New translations en.json (Tamil) [ci skip] * New translations en.json (Bengali) [ci skip] * New translations en.json (Marathi) [ci skip] * New translations en.json (Thai) [ci skip] * New translations en.json (Norwegian Nynorsk) [ci skip] * New translations en.json (Kazakh) [ci skip] * New translations en.json (Latvian) [ci skip] * New translations en.json (Azerbaijani) [ci skip] * New translations en.json (Hindi) [ci skip] * New translations en.json (Burmese) [ci skip] * New translations en.json (Chinese Traditional, Hong Kong) [ci skip] * New translations en.json (Sinhala) [ci skip] * New translations en.json (Uzbek) [ci skip] * New translations en.json (Norwegian Bokmal) [ci skip] * New translations en.json (Occitan) [ci skip] * New translations en.json (German, Switzerland) [ci skip] * New translations en.json (Bengali, India) [ci skip] * New translations en.json (Kabyle) [ci skip] * New translations en.json (Karakalpak) [ci skip] * New translations en.json (Romanian) [ci skip] * New translations en.json (French) [ci skip] * New translations en.json (Indonesian) [ci skip] * New translations en.json (Hebrew) [ci skip] * New translations en.json (Hebrew) [ci skip] * New translations en.json (Romanian) [ci skip] * New translations en.json (French) [ci skip] * New translations en.json (Spanish) [ci skip] * New translations en.json (Arabic) [ci skip] * New translations en.json (Bulgarian) [ci skip] * New translations en.json (Catalan) [ci skip] * New translations en.json (Czech) [ci skip] * New translations en.json (Danish) [ci skip] * New translations en.json (German) [ci skip] * New translations en.json (Greek) [ci skip] * New translations en.json (Basque) [ci skip] * New translations en.json (Finnish) [ci skip] * New translations en.json (Hebrew) [ci skip] * New translations en.json (Hungarian) [ci skip] * New translations en.json (Italian) [ci skip] * New translations en.json (Japanese) [ci skip] * New translations en.json (Korean) [ci skip] * New translations en.json (Kurdish) [ci skip] * New translations en.json (Lithuanian) [ci skip] * New translations en.json (Dutch) [ci skip] * New translations en.json (Punjabi) [ci skip] * New translations en.json (Polish) [ci skip] * New translations en.json (Portuguese) [ci skip] * New translations en.json (Russian) [ci skip] * New translations en.json (Slovak) [ci skip] * New translations en.json (Slovenian) [ci skip] * New translations en.json (Swedish) [ci skip] * New translations en.json (Turkish) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Chinese Simplified) [ci skip] * New translations en.json (Chinese Traditional) [ci skip] * New translations en.json (Vietnamese) [ci skip] * New translations en.json (Galician) [ci skip] * New translations en.json (Portuguese, Brazilian) [ci skip] * New translations en.json (Indonesian) [ci skip] * New translations en.json (Persian) [ci skip] * New translations en.json (Khmer) [ci skip] * New translations en.json (Tamil) [ci skip] * New translations en.json (Bengali) [ci skip] * New translations en.json (Marathi) [ci skip] * New translations en.json (Thai) [ci skip] * New translations en.json (Norwegian Nynorsk) [ci skip] * New translations en.json (Kazakh) [ci skip] * New translations en.json (Latvian) [ci skip] * New translations en.json (Azerbaijani) [ci skip] * New translations en.json (Hindi) [ci skip] * New translations en.json (Burmese) [ci skip] * New translations en.json (Chinese Traditional, Hong Kong) [ci skip] * New translations en.json (Sinhala) [ci skip] * New translations en.json (Uzbek) [ci skip] * New translations en.json (Norwegian Bokmal) [ci skip] * New translations en.json (Occitan) [ci skip] * New translations en.json (German, Switzerland) [ci skip] * New translations en.json (Bengali, India) [ci skip] * New translations en.json (Kabyle) [ci skip] * New translations en.json (Karakalpak) [ci skip] * New translations en.json (Romanian) [ci skip] * New translations en.json (Italian) [ci skip] * New translations en.json (Chinese Traditional) [ci skip] * New translations en.json (Japanese) [ci skip] * New translations en.json (Japanese) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Hebrew) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Romanian) [ci skip] * New translations en.json (French) [ci skip] * New translations en.json (Spanish) [ci skip] * New translations en.json (Arabic) [ci skip] * New translations en.json (Bulgarian) [ci skip] * New translations en.json (Catalan) [ci skip] * New translations en.json (Czech) [ci skip] * New translations en.json (Danish) [ci skip] * New translations en.json (German) [ci skip] * New translations en.json (Greek) [ci skip] * New translations en.json (Basque) [ci skip] * New translations en.json (Finnish) [ci skip] * New translations en.json (Hebrew) [ci skip] * New translations en.json (Hungarian) [ci skip] * New translations en.json (Italian) [ci skip] * New translations en.json (Japanese) [ci skip] * New translations en.json (Korean) [ci skip] * New translations en.json (Kurdish) [ci skip] * New translations en.json (Lithuanian) [ci skip] * New translations en.json (Dutch) [ci skip] * New translations en.json (Punjabi) [ci skip] * New translations en.json (Polish) [ci skip] * New translations en.json (Portuguese) [ci skip] * New translations en.json (Russian) [ci skip] * New translations en.json (Slovak) [ci skip] * New translations en.json (Slovenian) [ci skip] * New translations en.json (Swedish) [ci skip] * New translations en.json (Turkish) [ci skip] * New translations en.json (Ukrainian) [ci skip] * New translations en.json (Chinese Simplified) [ci skip] * New translations en.json (Chinese Traditional) [ci skip] * New translations en.json (Vietnamese) [ci skip] * New translations en.json (Galician) [ci skip] * New translations en.json (Portuguese, Brazilian) [ci skip] * New translations en.json (Indonesian) [ci skip] * New translations en.json (Persian) [ci skip] * New translations en.json (Khmer) [ci skip] * New translations en.json (Tamil) [ci skip] * New translations en.json (Bengali) [ci skip] * New translations en.json (Marathi) [ci skip] * New translations en.json (Thai) [ci skip] * New translations en.json (Norwegian Nynorsk) [ci skip] * New translations en.json (Kazakh) [ci skip] * New translations en.json (Latvian) [ci skip] * New translations en.json (Azerbaijani) [ci skip] * New translations en.json (Hindi) [ci skip] * New translations en.json (Burmese) [ci skip] * New translations en.json (Chinese Traditional, Hong Kong) [ci skip] * New translations en.json (Sinhala) [ci skip] * New translations en.json (Uzbek) [ci skip] * New translations en.json (Norwegian Bokmal) [ci skip] * New translations en.json (Occitan) [ci skip] * New translations en.json (German, Switzerland) [ci skip] * New translations en.json (Bengali, India) [ci skip] * New translations en.json (Kabyle) [ci skip] * New translations en.json (Karakalpak) [ci skip] * New translations en.json (Romanian) [ci skip] * Auto commit: Calculate translation coverage --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> |
||
|
|
884225b1c7 |
fix(app): localize hardcoded "Sign up"/"Sign in" menu labels (#11893)
fix(localization): update signup and signin labels for localization Co-authored-by: VishRk <VishRk@users.noreply.github.com> |