From 9ba66ed9b5bfbd4ca5bdfa23859cc48fedca5ac3 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:08:33 +0200 Subject: [PATCH] feat(editor): auto increment numbers/lists on duplicate (#12189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- packages/element/src/index.ts | 1 + packages/element/src/listMarker.ts | 364 ++++++++++++++++++ packages/element/tests/duplicate.test.tsx | 323 +++++++++++++++- .../actions/actionDuplicateSelection.tsx | 2 + .../excalidraw/components/App.duplicate.ts | 128 +++++- packages/excalidraw/components/App.text.ts | 8 + packages/excalidraw/components/App.tsx | 14 +- packages/excalidraw/types.ts | 8 +- packages/excalidraw/wysiwyg/textWysiwyg.tsx | 25 +- 9 files changed, 852 insertions(+), 21 deletions(-) create mode 100644 packages/element/src/listMarker.ts diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index c45184036f..8fa93b2de6 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -81,6 +81,7 @@ export * from "./groups"; export * from "./heading"; export * from "./image"; export * from "./linearElementEditor"; +export * from "./listMarker"; export * from "./mutateElement"; export * from "./newElement"; export * from "./positionElementsOnGrid"; diff --git a/packages/element/src/listMarker.ts b/packages/element/src/listMarker.ts new file mode 100644 index 0000000000..37c0f6193c --- /dev/null +++ b/packages/element/src/listMarker.ts @@ -0,0 +1,364 @@ +import { arrayToMap } from "@excalidraw/common"; + +import { refreshTextDimensions } from "./newElement"; +import { + getBoundTextElement, + getContainerElement, + redrawTextBoundingBox, +} from "./textElement"; +import { isBoundToContainer, isTextElement } from "./typeChecks"; + +import type { Scene } from "./Scene"; +import type { ExcalidrawElement, ExcalidrawTextElement } from "./types"; + +/** + * A list marker a text starts with: `1.`, `1)`, `(1)`, `[1]`, `a.`, `a)`, + * `(a)`, `A.`, `iv.` etc. Must be followed by whitespace (or nothing), so that + * `1.5` or `e.g.` aren't list markers. See also `LONE_NUMBER_REGEX`. + * + * (Letters don't take `[]`, as `[a]` tends to mean other things.) + */ +const LIST_MARKER_REGEX = + /^(\s*)(?:\((\d{1,9}|[a-zA-Z]+)\)|\[(\d{1,9})\]|(\d{1,9}|[a-zA-Z]+)([.)]))(?=\s|$)/; + +/** a text that's just a number counts as a list item too (e.g. a step badge) */ +const LONE_NUMBER_REGEX = /^(\s*)(\d{1,9})\s*$/; + +/** letters below `i` count alphabetically, from `i` up as roman numerals */ +const LAST_ALPHA_ORDINAL = "h".charCodeAt(0) - "a".charCodeAt(0) + 1; + +type ListMarkerKind = "decimal" | "alpha" | "roman"; + +/** + * Lists count up to 42 (so that e.g. `2024. foo` isn't a list item), roman + * numerals up to `xcix`. + */ +const MAX_ORDINAL: Record = { + decimal: 42, + alpha: 9, // i. (then switching to roman) + roman: 99, +}; + +const toRoman = (value: number) => + ["", "x", "xx", "xxx", "xl", "l", "lx", "lxx", "lxxx", "xc"][ + Math.floor(value / 10) + ] + ["", "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix"][value % 10]; + +const ROMAN_VALUES = new Map( + Array.from({ length: MAX_ORDINAL.roman }, (_, index) => [ + toRoman(index + 1), + index + 1, + ]), +); + +type ListMarker = { + /** where the marker's value starts in the text */ + index: number; + value: string; + kind: ListMarkerKind; + isUpperCase: boolean; + /** e.g. `1.` and `2.` are of the same style, but `1.` and `(1)` aren't */ + style: string; + /** 1-based */ + ordinal: number; + /** where the item's text (after the marker and whitespace) starts */ + contentIndex: number; +}; + +const parseLoneNumber = (text: string): ListMarker | null => { + const match = text.match(LONE_NUMBER_REGEX); + if (!match) { + return null; + } + + const [, indent, value] = match; + + return { + index: indent.length, + value, + kind: "decimal", + isUpperCase: false, + style: "decimal", + ordinal: parseInt(value, 10), + contentIndex: text.length, + }; +}; + +const parseListMarker = (text: string): ListMarker | null => { + const match = text.match(LIST_MARKER_REGEX); + if (!match) { + return null; + } + + const [, indent, parenValue, bracketValue, plainValue, plainSuffix] = match; + const value = parenValue ?? bracketValue ?? plainValue; + const lowerCaseValue = value.toLowerCase(); + const isUpperCase = /[A-Z]/.test(value); + const alphaOrdinal = lowerCaseValue.charCodeAt(0) - "a".charCodeAt(0) + 1; + + let kind: ListMarkerKind; + let ordinal: number; + + if (/\d/.test(value)) { + kind = "decimal"; + ordinal = parseInt(value, 10); + } else if (isUpperCase && /[a-z]/.test(value)) { + // mixed case + return null; + } else if (value.length === 1 && alphaOrdinal <= LAST_ALPHA_ORDINAL) { + kind = "alpha"; + ordinal = alphaOrdinal; + } else if (ROMAN_VALUES.has(lowerCaseValue)) { + kind = "roman"; + ordinal = ROMAN_VALUES.get(lowerCaseValue)!; + } else { + return null; + } + + const affixes = + parenValue !== undefined + ? "()" + : bracketValue !== undefined + ? "[]" + : plainSuffix; + + return { + index: indent.length + (plainValue !== undefined ? 0 : 1), + value, + kind, + isUpperCase, + style: `${kind}${isUpperCase ? "-upper" : ""}${affixes}`, + ordinal, + contentIndex: + match[0].length + text.slice(match[0].length).match(/^\s*/)![0].length, + }; +}; + +/** + * @returns the list marker the text starts with (or the number the text is), + * unless the text is a whole list (other lines start with markers of + * the same style) + */ +const getListItemMarker = (text: string): ListMarker | null => { + const marker = parseLoneNumber(text) ?? parseListMarker(text); + if (!marker || marker.ordinal > MAX_ORDINAL[marker.kind]) { + return null; + } + + const otherLines = text.slice(marker.index).split("\n").slice(1); + if ( + otherLines.some((line) => parseListMarker(line)?.style === marker.style) + ) { + return null; + } + + return marker; +}; + +/** @returns `null` if the marker would be past `MAX_ORDINAL` */ +const renumberListMarker = ( + text: string, + marker: ListMarker, + ordinal: number, +): string | null => { + if (ordinal > MAX_ORDINAL[marker.kind]) { + return null; + } + + let value: string; + if (marker.kind === "decimal") { + // keep zero padding (`01.` -> `02.`) + value = String(ordinal).padStart( + marker.value.startsWith("0") ? marker.value.length : 0, + "0", + ); + } else if (marker.kind === "alpha") { + value = String.fromCharCode("a".charCodeAt(0) + ordinal - 1); + } else { + value = toRoman(ordinal); + } + + if (marker.isUpperCase) { + value = value.toUpperCase(); + } + + return ( + text.slice(0, marker.index) + + value + + text.slice(marker.index + marker.value.length) + ); +}; + +/** a list marker advanced by duplication, to go back and forth */ +export type ListMarkerAdvance = { + elementId: ExcalidrawTextElement["id"]; + prevOriginalText: string; + nextOriginalText: string; + /** + * where the item's text (after the marker and whitespace) starts in + * `nextOriginalText` (its length if there's none) + */ + contentStart: number; + /** the label's container before the label (may have) grown it */ + prevContainerSize: { width: number; height: number } | null; +}; + +const setText = ( + element: ExcalidrawTextElement, + container: ExcalidrawElement | null, + originalText: string, + scene: Scene, +) => { + if (container) { + scene.mutateElement(element, { originalText }); + // re-wraps the label, and grows the container if need be + redrawTextBoundingBox(element, container, scene); + } else { + scene.mutateElement(element, { + originalText, + // re-wraps the text, and keeps it anchored as per its alignment + ...refreshTextDimensions( + element, + null, + scene.getNonDeletedElementsMap(), + originalText, + ), + }); + } +}; + +/** + * Advances the list markers of the duplicated texts and labels, so that + * duplicating `1. foo` gives `2. foo` (and `1` gives `2`). + * + * Duplicating several list items at once continues the list from the + * highest of them: duplicating `1.`, `3.`, `5.` gives `6.`, `7.`, `8.` (only + * if they're just markers, i.e. without text). + * + * Only if nothing but list items (texts, or containers with list item labels) + * is duplicated. + */ +export const advanceDuplicatedListMarkers = ( + duplicatedElements: readonly ExcalidrawElement[], + scene: Scene, +): ListMarkerAdvance[] => { + const duplicatesMap = arrayToMap(duplicatedElements); + const elementsMap = scene.getNonDeletedElementsMap(); + + // per marker style + const itemsByStyle = new Map< + string, + { + element: ExcalidrawTextElement; + container: ExcalidrawElement | null; + marker: ListMarker; + }[] + >(); + + for (const element of duplicatedElements) { + // a label goes with its container + if (isBoundToContainer(element) && duplicatesMap.has(element.containerId)) { + continue; + } + + const text = isTextElement(element) + ? element + : getBoundTextElement(element, elementsMap); + const marker = text && getListItemMarker(text.originalText); + // list items duplicated along with anything else (another text, a shape, + // a frame they're in…) are copied as they are + if (!text || !marker) { + return []; + } + + const items = itemsByStyle.get(marker.style) ?? []; + items.push({ + element: text, + container: text === element ? null : element, + marker, + }); + itemsByStyle.set(marker.style, items); + } + + // several list items are continued only if they're just markers (`1.`, + // `2.`, `3.`), not items with text (`1. foo`, `2. bar`) + const allItems = [...itemsByStyle.values()].flat(); + if ( + allItems.length > 1 && + allItems.some( + ({ element, marker }) => + marker.contentIndex < element.originalText.length, + ) + ) { + return []; + } + + const advances: ListMarkerAdvance[] = []; + + for (const items of itemsByStyle.values()) { + const maxOrdinal = Math.max(...items.map((item) => item.marker.ordinal)); + // (stable, so items with the same marker keep their order) + items.sort((a, b) => a.marker.ordinal - b.marker.ordinal); + + for (const [index, { element, container, marker }] of items.entries()) { + const nextOriginalText = renumberListMarker( + element.originalText, + marker, + maxOrdinal + 1 + index, + ); + if (nextOriginalText === null) { + continue; + } + + advances.push({ + elementId: element.id, + prevOriginalText: element.originalText, + nextOriginalText, + contentStart: + marker.contentIndex + + nextOriginalText.length - + element.originalText.length, + prevContainerSize: container + ? { width: container.width, height: container.height } + : null, + }); + + setText(element, container, nextOriginalText, scene); + } + } + + return advances; +}; + +/** + * Sets the advanced list markers back (`"prev"`), or forth again (`"next"`). + */ +export const applyListMarkerAdvances = ( + advances: readonly ListMarkerAdvance[], + direction: "prev" | "next", + scene: Scene, +) => { + const elementsMap = scene.getNonDeletedElementsMap(); + + for (const advance of advances) { + const element = elementsMap.get(advance.elementId); + if (!element || !isTextElement(element)) { + continue; + } + + const container = getContainerElement(element, elementsMap); + if (direction === "prev" && container && advance.prevContainerSize) { + // labels only ever grow their containers + scene.mutateElement(container, advance.prevContainerSize); + } + + setText( + element, + container, + direction === "prev" + ? advance.prevOriginalText + : advance.nextOriginalText, + scene, + ); + } +}; diff --git a/packages/element/tests/duplicate.test.tsx b/packages/element/tests/duplicate.test.tsx index abe8caa8b6..5c0fd9a0fd 100644 --- a/packages/element/tests/duplicate.test.tsx +++ b/packages/element/tests/duplicate.test.tsx @@ -16,11 +16,16 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; +import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom"; + import { + GlobalTestState, act, assertElements, + fireEvent, getCloneByOrigId, render, + waitFor, } from "@excalidraw/excalidraw/tests/test-utils"; import type { LocalPoint } from "@excalidraw/math"; @@ -31,7 +36,13 @@ import { duplicateElements, } from "../src/duplicate"; -import type { ExcalidrawLinearElement } from "../src/types"; +import { isBoundToContainer, isTextElement } from "../src/typeChecks"; + +import type { + ExcalidrawLinearElement, + ExcalidrawTextElement, + NonDeletedExcalidrawElement, +} from "../src/types"; const { h } = window; const mouse = new Pointer("mouse"); @@ -863,3 +874,313 @@ describe("duplication z-order", () => { ]); }); }); + +describe("duplicating list items", () => { + beforeEach(async () => { + await render(); + }); + + const duplicate = (texts: string[]) => { + const elements = texts.map((text, index) => + API.createElement({ type: "text", text, y: index * 100 }), + ); + API.setElements(elements); + API.setSelectedElements(elements); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + // (each duplicate goes right after its original) + const duplicates = h.elements.filter( + (element) => h.state.selectedElementIds[element.id], + ) as ExcalidrawTextElement[]; + + return duplicates.map((element) => { + expect(element).toMatchObject({ text: element.originalText }); + return element.originalText; + }); + }; + + it.each([ + ["1. foo", "2. foo"], + ["1) foo", "2) foo"], + ["(9) foo", "(10) foo"], + ["[1] foo", "[2] foo"], + ["09. foo", "10. foo"], + ["0.", "1."], + [" b. foo", " c. foo"], + ["A) foo", "B) foo"], + ["(a) foo", "(b) foo"], + ["(A) foo", "(B) foo"], + // below `i` alphabetically, from `i` up as roman numerals + ["h. foo", "i. foo"], + ["i. foo", "ii. foo"], + ["viii) foo", "ix) foo"], + ["(iv) foo", "(v) foo"], + ["XXXIX. foo", "XL. foo"], + ["l. foo", "li. foo"], + ["1. step\n a. sub-step", "2. step\n a. sub-step"], + // lone numbers + ["7", "8"], + [" 05 ", " 06 "], + // numbers up to 42 + ["41", "42"], + ["42", "42"], + ["2024", "2024"], + ["(41) foo", "(42) foo"], + ["42. foo", "42. foo"], + // not list items + ["1.5 kg", "1.5 kg"], + ["e.g. foo", "e.g. foo"], + ["[a] foo", "[a] foo"], + ["j. foo", "j. foo"], + ["Ii. foo", "Ii. foo"], + ["iiii. foo", "iiii. foo"], + ["xcix. foo", "xcix. foo"], + ["1. foo\n2. bar", "1. foo\n2. bar"], + ])("advances the marker of %j", (text, expected) => { + expect(duplicate([text])).toEqual([expected]); + }); + + it("continues the list from the highest of the duplicated items", () => { + expect(duplicate(["5.", "1.", "3.", "a.", "1", "1"])).toEqual([ + "8.", + "6.", + "7.", + "b.", + "2", + "3", + ]); + }); + + it.each<[string, () => NonDeletedExcalidrawElement[], string[]]>([ + [ + "labeled containers", + () => [ + ...API.createTextContainer({ label: { text: "1." } }), + ...API.createTextContainer({ label: { text: "2." } }), + ], + ["3.", "4."], + ], + // several list items only if they're just markers + [ + "list items with text", + () => [ + API.createElement({ type: "text", text: "1. aa" }), + API.createElement({ type: "text", text: "2. bb" }), + API.createElement({ type: "text", text: "3. cc" }), + ], + ["1. aa", "2. bb", "3. cc"], + ], + // along with anything else, list items are copied as they are + [ + "a labeled container and another element", + () => [ + ...API.createTextContainer({ label: { text: "1. foo" } }), + API.createElement({ type: "rectangle" }), + ], + ["1. foo"], + ], + [ + "a list item and another text", + () => [ + API.createElement({ type: "text", text: "1. foo" }), + API.createElement({ type: "text", text: "foo" }), + ], + ["1. foo", "foo"], + ], + [ + "a list item and another element", + () => [ + API.createElement({ type: "text", text: "1. foo" }), + API.createElement({ type: "rectangle" }), + ], + ["1. foo"], + ], + ])("duplicating %s", (_, createElements, expected) => { + const elements = createElements(); + API.setElements(elements); + API.setSelectedElements( + elements.filter((element) => !isBoundToContainer(element)), + ); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + expect( + elements + .filter(isTextElement) + .map( + (text) => + (getCloneByOrigId(text.id) as ExcalidrawTextElement).originalText, + ), + ).toEqual(expected); + }); + + it("stops letters at `i` (the switch to roman numerals)", () => { + expect(duplicate(["g.", "h."])).toEqual(["i.", "h."]); + }); + + it.each([ + ["rectangle", "9. foo", "10. foo"], + ["stickynote", "9. foo", "10. foo"], + ["ellipse", "3", "4"], + ] as const)( + "advances the marker of a %s label %j", + (type, text, expected) => { + const container = API.createElement({ type }); + const label = API.createElement({ + type: "text", + text, + containerId: container.id, + }); + API.setElements([container, label]); + h.app.scene.mutateElement(container, { + boundElements: [{ type: "text", id: label.id }], + }); + API.setSelectedElements([container]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: container.id }, + { id: label.id, originalText: text }, + { [ORIG_ID]: container.id, selected: true }, + { + [ORIG_ID]: label.id, + containerId: getCloneByOrigId(container.id).id, + originalText: expected, + }, + ]); + }, + ); + + it("undo reverts the marker before the duplication", () => { + const text = API.createElement({ type: "text", text: "1. foo" }); + API.setElements([text]); + API.setSelectedElements([text]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + // (undo loses the ORIG_ID) + const { id } = getCloneByOrigId(text.id); + + Keyboard.undo(); + assertElements(h.elements, [ + { id: text.id, originalText: "1. foo" }, + { id, selected: true, originalText: "1. foo" }, + ]); + + Keyboard.undo(); + assertElements(h.elements, [{ id: text.id }, { id, isDeleted: true }]); + }); + + it("alt-drag advances the markers from the start of the drag, undoably", async () => { + const [container, label] = API.createTextContainer({ + label: { text: "9. foo" }, + }); + API.setElements([container, label]); + API.setSelectedElements([container]); + + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.down(container.x + 5, container.y + 5); + mouse.move(50, 50); + }); + + expect(getCloneByOrigId(label.id)).toMatchObject({ + originalText: "10. foo", + }); + + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.up(); + }); + + // (undo loses the ORIG_ID) + const duplicate = getCloneByOrigId(container.id); + const duplicateLabel = getCloneByOrigId(label.id); + expect(duplicateLabel).toMatchObject({ originalText: "10. foo" }); + + // on to editing the item's text + const editor = await getTextEditor(); + expect(h.state.editingTextElement?.id).toBe(duplicateLabel.id); + expect(editor.value).toBe("10. foo"); + await waitFor(() => + expect([editor.selectionStart, editor.selectionEnd]).toEqual([4, 7]), + ); + Keyboard.exitTextEditor(editor); + + Keyboard.undo(); + assertElements(h.elements, [ + { id: container.id }, + { id: label.id, originalText: "9. foo" }, + { id: duplicate.id, selected: true, x: duplicate.x, y: duplicate.y }, + { id: duplicateLabel.id, originalText: "9. foo" }, + ]); + + Keyboard.redo(); + assertElements(h.elements, [ + { id: container.id }, + { id: label.id }, + { id: duplicate.id, selected: true }, + { id: duplicateLabel.id, originalText: "10. foo" }, + ]); + }); + + it("alt-drag doesn't edit a list item without text", () => { + const text = API.createElement({ type: "text", text: "1" }); + API.setElements([text]); + API.setSelectedElements([text]); + + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.down(text.x + 5, text.y + 5); + mouse.up(50, 50); + }); + + expect(getCloneByOrigId(text.id)).toMatchObject({ originalText: "2" }); + expect(h.state.editingTextElement).toBe(null); + }); + + it("alt-drag doesn't edit the list item when the drag gets interrupted", () => { + const text = API.createElement({ type: "text", text: "1. foo" }); + API.setElements([text]); + API.setSelectedElements([text]); + + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.down(text.x + 5, text.y + 5); + mouse.move(50, 50); + }); + fireEvent.pointerCancel(GlobalTestState.interactiveCanvas, { + pointerId: 1, + }); + + expect(getCloneByOrigId(text.id)).toMatchObject({ originalText: "2. foo" }); + expect(h.state.editingTextElement).toBe(null); + }); + + it("leaves the texts of a duplicated frame as they are", () => { + const frame = API.createElement({ type: "frame", width: 500 }); + const text = API.createElement({ + type: "text", + text: "1. foo", + frameId: frame.id, + }); + API.setElements([text, frame]); + API.setSelectedElements([frame]); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + assertElements(h.elements, [ + { id: text.id }, + { id: frame.id }, + { [ORIG_ID]: text.id, originalText: "1. foo" }, + { [ORIG_ID]: frame.id, selected: true }, + ]); + }); +}); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index f0386108f0..dea47f7cda 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -98,6 +98,8 @@ export const actionDuplicateSelection = register({ } } + app.duplicate.advanceListMarkers(duplicatedElements); + return { elements: syncMovedIndices( elementsWithDuplicates, diff --git a/packages/excalidraw/components/App.duplicate.ts b/packages/excalidraw/components/App.duplicate.ts index f9118b2d62..e6af94981d 100644 --- a/packages/excalidraw/components/App.duplicate.ts +++ b/packages/excalidraw/components/App.duplicate.ts @@ -10,18 +10,22 @@ import { import { addElementsToFrame, + advanceDuplicatedListMarkers, + applyListMarkerAdvances, deepCopyElement, duplicateElements, filterElementsEligibleAsFrameChildren, getCommonBounds, getSelectionStateForElements, isBindableElement, + isTextElement, newElementWith, reconcileDuplicatedElements, syncMovedIndices, updateBoundElements, } from "@excalidraw/element"; +import type { ListMarkerAdvance } from "@excalidraw/element"; import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { PointerDownState } from "../types"; @@ -44,7 +48,13 @@ type Duplication = Pick< * `runOnDuplicate()`. */ export class AppDuplicate { - constructor(private app: App) {} + constructor( + private app: App, + private dependencies: { + /** pointers currently down */ + getPointerCount: () => number; + }, + ) {} /** * Hands the duplication over to the host's `props.onDuplicate` (if any), @@ -70,6 +80,99 @@ export class AppDuplicate { ); }; + /** + * Runs `update` once what's pending is committed, capturing what it changes + * (if it returns `true`) as a separate undo step. + */ + private afterCommit(update: () => boolean) { + // (setState callbacks run after componentDidUpdate, which commits) + this.app.setState({}, () => { + if (update()) { + this.app.store.scheduleCapture(); + } + }); + } + + /** + * Advances the list markers of the duplicated texts (`1.` -> `2.`) once + * the duplication is committed, as a separate undo step, so that undo + * reverts the markers first. + */ + advanceListMarkers = (duplicatedElements: readonly ExcalidrawElement[]) => { + this.afterCommit(() => { + const duplicates = duplicatedElements.flatMap( + (element) => this.app.scene.getNonDeletedElement(element.id) ?? [], + ); + + return ( + advanceDuplicatedListMarkers(duplicates, this.app.scene).length > 0 + ); + }); + }; + + /** + * On alt-drag drop: the list markers advanced along with the drag go back, + * for the drag to be committed as is, and forth again once it is, as + * a separate undo step (before the browser gets to paint either). + * + * A single list item with text after its marker is then edited, with that + * text selected (`1. |foo|`), as it's likely to differ from the original's. + */ + commitDraggedListMarkers = ( + advances: readonly ListMarkerAdvance[], + { + editListItem, + }: { + /** `false` if the drag didn't end with a genuine pointerup */ + editListItem: boolean; + }, + ) => { + applyListMarkerAdvances(advances, "prev", this.app.scene); + + this.afterCommit(() => { + applyListMarkerAdvances(advances, "next", this.app.scene); + + const [advance] = advances; + if ( + editListItem && + advances.length === 1 && + advance.contentStart < advance.nextOriginalText.length + ) { + // once the marker is committed + this.afterCommit(() => { + this.editListItemText(advance); + return false; + }); + } + + return true; + }); + }; + + private editListItemText(advance: ListMarkerAdvance) { + const element = this.app.scene.getNonDeletedElement(advance.elementId); + if ( + !element || + !isTextElement(element) || + element.originalText !== advance.nextOriginalText || + // another interaction has started + this.dependencies.getPointerCount() > 0 + ) { + return; + } + + this.app.text.startTextEditing({ + sceneX: element.x, + sceneY: element.y, + insertAtParentCenter: false, + textElement: element, + initialSelection: { + start: advance.contentStart, + end: element.originalText.length, + }, + }); + } + /** * Duplicates elements so that they end up centered at the scene coords * (and in the frame at that point, if any). Doesn't update the scene. @@ -249,13 +352,6 @@ export class AppDuplicate { // (originals whose duplicates were vetoed are left behind) const duplicateElementsMap = arrayToMap(duplicatedElements); - duplicatedElements.forEach((element) => { - pointerDownState.originalElements.set( - element.id, - deepCopyElement(element), - ); - }); - const elementsWithIndices = syncMovedIndices( nextElements, duplicateElementsMap, @@ -304,6 +400,22 @@ export class AppDuplicate { })); this.app.scene.replaceAllElements(elementsWithIndices); + + // visible from the start of the drag, captured on drop (see + // `commitDraggedListMarkers()`) + pointerDownState.hit.advancedListMarkers = advanceDuplicatedListMarkers( + duplicatedElements, + this.app.scene, + ); + + // (after advancing the list markers, which may resize the duplicates) + duplicatedElements.forEach((element) => { + pointerDownState.originalElements.set( + element.id, + deepCopyElement(element), + ); + }); + selectedElements.forEach((element) => { if ( isBindableElement(element) && diff --git a/packages/excalidraw/components/App.text.ts b/packages/excalidraw/components/App.text.ts index f54666246e..a9eaf9fe06 100644 --- a/packages/excalidraw/components/App.text.ts +++ b/packages/excalidraw/components/App.text.ts @@ -170,6 +170,7 @@ export class AppText { { isExistingElement = false, initialCaretSceneCoords = null, + initialSelection = null, }: { isExistingElement?: boolean; /** @@ -177,6 +178,8 @@ export class AppText { * text should be auto-selected */ initialCaretSceneCoords?: { x: number; y: number } | null; + /** range of the text to select (takes precedence over the caret) */ + initialSelection?: { start: number; end: number } | null; }, ) { const elementsMap = this.app.scene.getElementsMapIncludingDeleted(); @@ -359,6 +362,7 @@ export class AppText { excalidrawContainer: this.dependencies.getContainer(), app: this.app, initialCaretSceneCoords, + initialSelection, // when text is selected, it's hard (at least on iOS) to re-position the // caret (i.e. deselect). There's not much use for always selecting // the text on edit anyway (and users can select-all from contextmenu @@ -601,6 +605,7 @@ export class AppText { container, autoEdit = true, initialCaretSceneCoords, + initialSelection, arrowEndpoint, textElement, }: { @@ -613,6 +618,8 @@ export class AppText { container?: ExcalidrawTextContainer | null; autoEdit?: boolean; initialCaretSceneCoords?: { x: number; y: number }; + /** range of an existing text to select (takes precedence over the caret) */ + initialSelection?: { start: number; end: number }; /** * creates the text as a label for this arrow endpoint: the binding then * dictates the text's position and alignment, overriding (sceneX, sceneY) @@ -843,6 +850,7 @@ export class AppText { initialCaretSceneCoords: existingTextElement ? initialCaretSceneCoords : null, + initialSelection: existingTextElement ? initialSelection : null, }); } else { this.app.setState({ diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index a26fe3f083..d770f1ccea 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -679,7 +679,9 @@ class App extends React.Component { public onStateChange: OnStateChange = this.appStateObserver.onStateChange; public bucketFill: AppBucketFill = new AppBucketFill(this); - public duplicate: AppDuplicate = new AppDuplicate(this); + public duplicate: AppDuplicate = new AppDuplicate(this, { + getPointerCount: () => gesture.pointers.size, + }); public toolDrag: AppToolDrag = new AppToolDrag(this); public flowchart: AppFlowchart = new AppFlowchart(this); public cursor: AppCursor = new AppCursor(this); @@ -8272,6 +8274,7 @@ class App extends React.Component { allHitElements: [], wasAddedToSelection: false, hasBeenDuplicated: false, + advancedListMarkers: [], arrowLabel: false, hasHitCommonBoundingBoxOfSelectedElements: this.isHittingCommonBoundingBoxOfSelectedElements( @@ -10358,6 +10361,15 @@ class App extends React.Component { this.textTool.handlePointerUp(childEvent, pointerDownState); + if (pointerDownState.hit.advancedListMarkers.length) { + this.duplicate.commitDraggedListMarkers( + pointerDownState.hit.advancedListMarkers, + // not for a replay by the missing-pointerup cleanup (a + // pointercancel, or the next interaction's pointerdown) + { editListItem: childEvent.type === "pointerup" }, + ); + } + // an armed bucket fill commits only on a GENUINE pointer up. The // missing-pointer-up cleanup replays this handler with the pointer // DOWN event (e.g. when a second finger lands mid-press β€” pinch/pan diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 7b35930d33..a9cf94570e 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -9,7 +9,10 @@ import type { import type { LinearElementEditor } from "@excalidraw/element"; -import type { MaybeTransformHandleType } from "@excalidraw/element"; +import type { + ListMarkerAdvance, + MaybeTransformHandleType, +} from "@excalidraw/element"; import type { PointerType, @@ -1284,6 +1287,9 @@ export type PointerDownState = Readonly<{ // Whether selected element(s) were duplicated, might change during the // pointer interaction hasBeenDuplicated: boolean; + // The list markers advanced by duplicating the selected element(s) + // (alt-drag) + advancedListMarkers: readonly ListMarkerAdvance[]; // Whether the pointer is hitting the common bounding box of selected // elements, which is useful for discriminating between selecitng // the entire selection vs a specific element diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index b6164f3863..b9a8863ece 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -257,6 +257,7 @@ export const textWysiwyg = ({ app, autoSelect = true, initialCaretSceneCoords = null, + initialSelection = null, }: { /** * textWysiwyg only deals with `originalText` @@ -273,6 +274,8 @@ export const textWysiwyg = ({ app: App; autoSelect?: boolean; initialCaretSceneCoords?: { x: number; y: number } | null; + /** range of `originalText` to select (takes precedence over the caret) */ + initialSelection?: { start: number; end: number } | null; }): SubmitHandler => { const ownerDocument = excalidrawContainer?.ownerDocument ?? document; const ownerWindow = ownerDocument.defaultView ?? window; @@ -724,18 +727,20 @@ export const textWysiwyg = ({ } }; - let pendingInitialSelection = (() => { - const caretIndex = getCaretIndexFromInitialSceneCoords(); + let pendingInitialSelection = + initialSelection ?? + (() => { + const caretIndex = getCaretIndexFromInitialSceneCoords(); - if (caretIndex === null) { - return null; - } + if (caretIndex === null) { + return null; + } - return { - start: caretIndex, - end: caretIndex, - }; - })(); + return { + start: caretIndex, + end: caretIndex, + }; + })(); if (onChange) { editable.onpaste = async (event) => {