update appState on copy-styles & improve paste

This commit is contained in:
dwelle 2020-12-12 21:24:52 +01:00
parent 9f6e3c5a9d
commit ef82e15ee8
6 changed files with 1000 additions and 1078 deletions

View File

@ -1,28 +1,110 @@
import { import {
isTextElement, isTextElement,
isExcalidrawElement,
redrawTextBoundingBox, redrawTextBoundingBox,
getNonDeletedElements,
} from "../element"; } from "../element";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { import {
DEFAULT_FONT_SIZE, ExcalidrawElement,
DEFAULT_FONT_FAMILY, ExcalidrawElementPossibleProps,
DEFAULT_TEXT_ALIGN, } from "../element/types";
} from "../constants"; import { AppState } from "../types";
import {
canChangeSharpness,
getSelectedElements,
hasBackground,
hasStroke,
hasText,
} from "../scene";
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
type AppStateStyles = {
[K in AssertSubset<
keyof AppState,
typeof copyableStyles[number][0]
>]: AppState[K];
};
type ElementStyles = {
[K in AssertSubset<
keyof ExcalidrawElementPossibleProps,
typeof copyableStyles[number][1]
>]: ExcalidrawElementPossibleProps[K];
};
type ElemelementStylesByType = Record<ExcalidrawElement["type"], ElementStyles>;
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; let COPIED_STYLES: {
appStateStyles: Partial<AppStateStyles>;
elementStyles: Partial<ElementStyles>;
elementStylesByType: Partial<ElemelementStylesByType>;
} | null = null;
/* [AppState prop, ExcalidrawElement prop, predicate] */
const copyableStyles = [
["currentItemOpacity", "opacity", () => true],
["currentItemStrokeColor", "strokeColor", () => true],
["currentItemStrokeStyle", "strokeStyle", hasStroke],
["currentItemStrokeWidth", "strokeWidth", hasStroke],
["currentItemRoughness", "roughness", hasStroke],
["currentItemBackgroundColor", "backgroundColor", hasBackground],
["currentItemFillStyle", "fillStyle", hasBackground],
["currentItemStrokeSharpness", "strokeSharpness", canChangeSharpness],
["currentItemLinearStrokeSharpness", "strokeSharpness", isLinearElementType],
["currentItemStartArrowhead", "startArrowhead", isLinearElementType],
["currentItemEndArrowhead", "endArrowhead", isLinearElementType],
["currentItemFontFamily", "fontFamily", hasText],
["currentItemFontSize", "fontSize", hasText],
["currentItemTextAlign", "textAlign", hasText],
] as const;
const getCommonStyleProps = (
elements: readonly ExcalidrawElement[],
): Exclude<typeof COPIED_STYLES, null> => {
const appStateStyles = {} as AppStateStyles;
const elementStyles = {} as ElementStyles;
const elementStylesByType = elements.reduce((acc, element) => {
// only use the first element of given type
if (!acc[element.type]) {
acc[element.type] = {} as ElementStyles;
copyableStyles.forEach(([appStateProp, prop, predicate]) => {
const value = (element as any)[prop];
if (value !== undefined && predicate(element.type)) {
if (appStateStyles[appStateProp] === undefined) {
(appStateStyles as any)[appStateProp] = value;
}
if (elementStyles[prop] === undefined) {
(elementStyles as any)[prop] = value;
}
(acc as any)[element.type][prop] = value;
}
});
}
return acc;
}, {} as ElemelementStylesByType);
// clone in case we ever make some of the props into non-primitives
return JSON.parse(
JSON.stringify({ appStateStyles, elementStyles, elementStylesByType }),
);
};
export const actionCopyStyles = register({ export const actionCopyStyles = register({
name: "copyStyles", name: "copyStyles",
perform: (elements, appState) => { perform: (elements, appState) => {
const element = elements.find((el) => appState.selectedElementIds[el.id]); COPIED_STYLES = getCommonStyleProps(
if (element) { getSelectedElements(getNonDeletedElements(elements), appState),
copiedStyles = JSON.stringify(element); );
}
return { return {
appState: {
...appState,
...COPIED_STYLES.appStateStyles,
},
commitToHistory: false, commitToHistory: false,
}; };
}, },
@ -35,31 +117,49 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({ export const actionPasteStyles = register({
name: "pasteStyles", name: "pasteStyles",
perform: (elements, appState) => { perform: (elements, appState) => {
const pastedElement = JSON.parse(copiedStyles); if (!COPIED_STYLES) {
if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false }; return { elements, commitToHistory: false };
} }
const getStyle = <T extends ExcalidrawElement, K extends keyof T>(
element: T,
prop: K,
) => {
return (COPIED_STYLES?.elementStylesByType[element.type]?.[
prop as keyof ElementStyles
] ??
COPIED_STYLES?.elementStyles[prop as keyof ElementStyles] ??
element[prop]) as T[K];
};
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
if (appState.selectedElementIds[element.id]) { if (appState.selectedElementIds[element.id]) {
const commonProps = {
backgroundColor: getStyle(element, "backgroundColor"),
strokeWidth: getStyle(element, "strokeWidth"),
strokeColor: getStyle(element, "strokeColor"),
strokeStyle: getStyle(element, "strokeStyle"),
fillStyle: getStyle(element, "fillStyle"),
opacity: getStyle(element, "opacity"),
roughness: getStyle(element, "roughness"),
strokeSharpness: getStyle(element, "strokeSharpness"),
};
if (isTextElement(element)) {
const newElement = newElementWith(element, { const newElement = newElementWith(element, {
backgroundColor: pastedElement?.backgroundColor, ...commonProps,
strokeWidth: pastedElement?.strokeWidth, fontSize: getStyle(element, "fontSize"),
strokeColor: pastedElement?.strokeColor, fontFamily: getStyle(element, "fontFamily"),
strokeStyle: pastedElement?.strokeStyle, textAlign: getStyle(element, "textAlign"),
fillStyle: pastedElement?.fillStyle,
opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness,
});
if (isTextElement(newElement)) {
mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
}); });
redrawTextBoundingBox(newElement); redrawTextBoundingBox(newElement);
}
return newElement; return newElement;
} else if (isLinearElement(element)) {
return newElementWith(element, {
...commonProps,
startArrowhead: getStyle(element, "startArrowhead"),
endArrowhead: getStyle(element, "endArrowhead"),
});
}
return newElementWith(element, commonProps);
} }
return element; return element;
}), }),

View File

@ -110,3 +110,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
startArrowhead: Arrowhead | null; startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
export type ExcalidrawElementTypes = Pick<ExcalidrawElement, "type">["type"];
/** @private */
type __ExcalidrawElementPossibleProps_withoutType<T> = T extends any
? { [K in keyof Omit<T, "type">]: T[K] }
: never;
/** Do not use for anything unless you really need it for some abstract
API types */
export type ExcalidrawElementPossibleProps = UnionToIntersection<
__ExcalidrawElementPossibleProps_withoutType<ExcalidrawElement>
> & { type: ExcalidrawElementTypes };

9
src/global.d.ts vendored
View File

@ -46,6 +46,15 @@ type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> & type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
Required<Pick<T, RK>>; Required<Pick<T, RK>>;
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (
x: infer R,
) => any
? R
: never;
/** Assert K is a subset of T, and returns K */
type AssertSubset<T, K extends T> = K;
// PNG encoding/decoding // PNG encoding/decoding
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
type TEXtChunk = { name: "tEXt"; data: Uint8Array }; type TEXtChunk = { name: "tEXt"; data: Uint8Array };

File diff suppressed because it is too large Load Diff

View File

@ -81,6 +81,12 @@ export class API {
verticalAlign?: T extends "text" verticalAlign?: T extends "text"
? ExcalidrawTextElement["verticalAlign"] ? ExcalidrawTextElement["verticalAlign"]
: never; : never;
startArrowhead?: T extends "arrow" | "line" | "draw"
? ExcalidrawLinearElement["startArrowhead"]
: never;
endArrowhead?: T extends "arrow" | "line" | "draw"
? ExcalidrawLinearElement["endArrowhead"]
: never;
}): T extends "arrow" | "line" | "draw" }): T extends "arrow" | "line" | "draw"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
: T extends "text" : T extends "text"
@ -130,8 +136,8 @@ export class API {
case "draw": case "draw":
element = newLinearElement({ element = newLinearElement({
type: type as "arrow" | "line" | "draw", type: type as "arrow" | "line" | "draw",
startArrowhead: null, startArrowhead: rest.startArrowhead ?? null,
endArrowhead: null, endArrowhead: rest.endArrowhead ?? null,
...base, ...base,
}); });
break; break;

View File

@ -1,7 +1,7 @@
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { copiedStyles } from "../actions/actionStyles"; import { getDefaultAppState } from "../appState";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { setLanguage, t } from "../i18n"; import { setLanguage, t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
@ -768,82 +768,224 @@ describe("regression tests", () => {
}); });
}); });
it("selecting 'Copy styles' in context menu copies styles", () => { it("copy-styles updates appState defaults", () => {
UI.clickTool("rectangle"); h.app.updateScene({
mouse.down(10, 10); elements: [
mouse.up(20, 20); API.createElement({
type: "rectangle",
id: "A",
x: 0,
y: 0,
opacity: 90,
strokeColor: "#FF0000",
strokeStyle: "solid",
strokeWidth: 10,
roughness: 2,
backgroundColor: "#00FF00",
fillStyle: "solid",
strokeSharpness: "sharp",
}),
API.createElement({
type: "arrow",
id: "B",
x: 200,
y: 200,
startArrowhead: "bar",
endArrowhead: "bar",
}),
API.createElement({
type: "text",
id: "C",
x: 200,
y: 200,
fontFamily: 3,
fontSize: 200,
textAlign: "center",
}),
],
});
h.app.setState({
selectedElementIds: { A: true, B: true, C: true },
});
const defaultAppState = getDefaultAppState();
expect(h.state).toEqual(
expect.objectContaining({
currentItemOpacity: defaultAppState.currentItemOpacity,
currentItemStrokeColor: defaultAppState.currentItemStrokeColor,
currentItemStrokeStyle: defaultAppState.currentItemStrokeStyle,
currentItemStrokeWidth: defaultAppState.currentItemStrokeWidth,
currentItemRoughness: defaultAppState.currentItemRoughness,
currentItemBackgroundColor: defaultAppState.currentItemBackgroundColor,
currentItemFillStyle: defaultAppState.currentItemFillStyle,
currentItemStrokeSharpness: defaultAppState.currentItemStrokeSharpness,
currentItemStartArrowhead: defaultAppState.currentItemStartArrowhead,
currentItemEndArrowhead: defaultAppState.currentItemEndArrowhead,
currentItemFontFamily: defaultAppState.currentItemFontFamily,
currentItemFontSize: defaultAppState.currentItemFontSize,
currentItemTextAlign: defaultAppState.currentItemTextAlign,
}),
);
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2, button: 2,
clientX: 1, clientX: 1,
clientY: 1, clientY: 1,
}); });
const contextMenu = document.querySelector(".context-menu"); const contextMenu = document.querySelector(".context-menu");
expect(copiedStyles).toBe("{}");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
expect(copiedStyles).not.toBe("{}");
const element = JSON.parse(copiedStyles); expect(h.state).toEqual(
expect(element).toEqual(API.getSelectedElement()); expect.objectContaining({
currentItemOpacity: 90,
currentItemStrokeColor: "#FF0000",
currentItemStrokeStyle: "solid",
currentItemStrokeWidth: 10,
currentItemRoughness: 2,
currentItemBackgroundColor: "#00FF00",
currentItemFillStyle: "solid",
currentItemStrokeSharpness: "sharp",
currentItemStartArrowhead: "bar",
currentItemEndArrowhead: "bar",
currentItemFontFamily: 3,
currentItemFontSize: 200,
currentItemTextAlign: "center",
}),
);
}); });
it("selecting 'Paste styles' in context menu pastes styles", () => { it("paste-styles action", () => {
UI.clickTool("rectangle"); h.app.updateScene({
mouse.down(10, 10); elements: [
mouse.up(20, 20); API.createElement({
type: "rectangle",
UI.clickTool("rectangle"); id: "A",
mouse.down(10, 10); x: 0,
mouse.up(20, 20); y: 0,
opacity: 90,
// Change some styles of second rectangle strokeColor: "#FF0000",
clickLabeledElement("Stroke"); strokeStyle: "solid",
clickLabeledElement("#c92a2a"); strokeWidth: 10,
clickLabeledElement("Background"); roughness: 2,
clickLabeledElement("#e64980"); backgroundColor: "#00FF00",
// Fill style fillStyle: "solid",
fireEvent.click(screen.getByTitle("Cross-hatch")); strokeSharpness: "sharp",
// Stroke width }),
fireEvent.click(screen.getByTitle("Bold")); API.createElement({
// Stroke style type: "arrow",
fireEvent.click(screen.getByTitle("Dotted")); id: "B",
// Roughness x: 0,
fireEvent.click(screen.getByTitle("Cartoonist")); y: 0,
// Opacity startArrowhead: "bar",
fireEvent.change(screen.getByLabelText("Opacity"), { endArrowhead: "bar",
target: { value: "60" }, }),
API.createElement({
type: "text",
id: "C",
x: 0,
y: 0,
fontFamily: 3,
fontSize: 200,
textAlign: "center",
}),
API.createElement({
type: "rectangle",
id: "D",
x: 200,
y: 200,
}),
API.createElement({
type: "arrow",
id: "E",
x: 200,
y: 200,
}),
API.createElement({
type: "text",
id: "F",
x: 200,
y: 200,
}),
],
});
h.app.setState({
selectedElementIds: { A: true, B: true, C: true },
}); });
mouse.reset();
// Copy styles of second rectangle
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2, button: 2,
clientX: 40, clientX: 1,
clientY: 40, clientY: 1,
}); });
let contextMenu = document.querySelector(".context-menu"); fireEvent.click(
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); queryByText(
const secondRect = JSON.parse(copiedStyles); document.querySelector(".context-menu") as HTMLElement,
expect(secondRect.id).toBe(h.elements[1].id); "Copy styles",
)!,
);
mouse.reset(); h.app.setState({
// Paste styles to first rectangle selectedElementIds: { D: true, E: true, F: true },
});
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2, button: 2,
clientX: 10, clientX: 201,
clientY: 10, clientY: 201,
}); });
contextMenu = document.querySelector(".context-menu"); fireEvent.click(
fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!); queryByText(
document.querySelector(".context-menu") as HTMLElement,
"Paste styles",
)!,
);
const firstRect = API.getSelectedElement(); const defaultAppState = getDefaultAppState();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a"); expect(h.elements.find((element) => element.id === "D")).toEqual(
expect(firstRect.backgroundColor).toBe("#e64980"); expect.objectContaining({
expect(firstRect.fillStyle).toBe("cross-hatch"); opacity: 90,
expect(firstRect.strokeWidth).toBe(2); // Bold: 2 strokeColor: "#FF0000",
expect(firstRect.strokeStyle).toBe("dotted"); strokeStyle: "solid",
expect(firstRect.roughness).toBe(2); // Cartoonist: 2 strokeWidth: 10,
expect(firstRect.opacity).toBe(60); roughness: 2,
backgroundColor: "#00FF00",
fillStyle: "solid",
strokeSharpness: "sharp",
}),
);
expect(h.elements.find((element) => element.id === "E")).toEqual(
expect.objectContaining({
opacity: defaultAppState.currentItemOpacity,
strokeColor: defaultAppState.currentItemStrokeColor,
strokeStyle: defaultAppState.currentItemStrokeStyle,
strokeWidth: defaultAppState.currentItemStrokeWidth,
roughness: defaultAppState.currentItemRoughness,
backgroundColor: "#00FF00",
fillStyle: "solid",
strokeSharpness: "sharp",
startArrowhead: "bar",
endArrowhead: "bar",
}),
);
expect(h.elements.find((element) => element.id === "F")).toEqual(
expect.objectContaining({
opacity: defaultAppState.currentItemOpacity,
strokeColor: defaultAppState.currentItemStrokeColor,
strokeStyle: defaultAppState.currentItemStrokeStyle,
strokeWidth: 10,
roughness: 2,
backgroundColor: "#00FF00",
fillStyle: "solid",
strokeSharpness: "sharp",
fontFamily: 3,
fontSize: 200,
textAlign: "center",
}),
);
}); });
it("selecting 'Delete' in context menu deletes element", () => { it("selecting 'Delete' in context menu deletes element", () => {