mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
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
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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<ListMarkerKind, number> = {
|
||||
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,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,8 @@ export const actionDuplicateSelection = register({
|
||||
}
|
||||
}
|
||||
|
||||
app.duplicate.advanceListMarkers(duplicatedElements);
|
||||
|
||||
return {
|
||||
elements: syncMovedIndices(
|
||||
elementsWithDuplicates,
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -679,7 +679,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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<AppProps, AppState> {
|
||||
allHitElements: [],
|
||||
wasAddedToSelection: false,
|
||||
hasBeenDuplicated: false,
|
||||
advancedListMarkers: [],
|
||||
arrowLabel: false,
|
||||
hasHitCommonBoundingBoxOfSelectedElements:
|
||||
this.isHittingCommonBoundingBoxOfSelectedElements(
|
||||
@@ -10358,6 +10361,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user