diff --git a/src/components/App.tsx b/src/components/App.tsx index 97833d0a1..3d4a95b81 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -93,7 +93,6 @@ import { getCursorForResizingElement, getDragOffsetXY, getElementWithTransformHandleType, - getNonDeletedElements, getNormalizedDimensions, getResizeArrowDirection, getResizeOffsetXY, @@ -246,12 +245,14 @@ import LayerUI from "./LayerUI"; import { Toast } from "./Toast"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { + SubtypeLoadedCb, SubtypeRecord, SubtypePrepFn, + checkRefreshOnSubtypeLoad, + isSubtypeAction, prepareSubtype, selectSubtype, subtypeActionPredicate, - isSubtypeAction, } from "../subtypes"; import { dataURLToFile, @@ -516,31 +517,15 @@ class App extends React.Component { } private addSubtype(record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) { - // Call this method after finishing any async loading for - // subtypes of ExcalidrawElement if the newly loaded code - // would change the rendering. - const refresh = (hasSubtype: (element: ExcalidrawElement) => boolean) => { + const subtypeLoadedCb: SubtypeLoadedCb = (hasSubtype) => { const elements = this.getSceneElementsIncludingDeleted(); - let refreshNeeded = false; - getNonDeletedElements(elements).forEach((element) => { - // If the element is of the subtype that was just - // registered, update the element's dimensions, mark the - // element for a re-render, and mark the scene for a refresh. - if (hasSubtype(element)) { - invalidateShapeForElement(element); - if (isTextElement(element)) { - redrawTextBoundingBox(element, getContainerElement(element)); - } - refreshNeeded = true; - } - }); // If there are any elements of the just-registered subtype, // refresh the scene to re-render each such element. - if (refreshNeeded) { + if (checkRefreshOnSubtypeLoad(hasSubtype, elements)) { this.refresh(); } }; - const prep = prepareSubtype(record, subtypePrepFn, refresh); + const prep = prepareSubtype(record, subtypePrepFn, subtypeLoadedCb); if (prep.actions) { this.actionManager.registerAll(prep.actions); } diff --git a/src/subtypes.ts b/src/subtypes.ts index 7a4afd340..a63a1673b 100644 --- a/src/subtypes.ts +++ b/src/subtypes.ts @@ -14,8 +14,13 @@ import { registerCustomShortcuts, } from "./actions/shortcuts"; import { register } from "./actions/register"; -import { hasBoundTextElement } from "./element/typeChecks"; -import { getBoundTextElement } from "./element/textElement"; +import { hasBoundTextElement, isTextElement } from "./element/typeChecks"; +import { + getBoundTextElement, + getContainerElement, + redrawTextBoundingBox, +} from "./element/textElement"; +import { invalidateShapeForElement } from "./renderer/renderElement"; // Use "let" instead of "const" so we can dynamically add subtypes let subtypeNames: readonly Subtype[] = []; @@ -319,9 +324,8 @@ export const selectSubtype = ( // Callback to re-render subtyped `ExcalidrawElement`s after completing // async loading of the subtype. -export type SubtypeLoadedCb = ( - hasSubtype: (element: ExcalidrawElement) => boolean, -) => void; +export type SubtypeLoadedCb = (hasSubtype: SubtypeCheckFn) => void; +export type SubtypeCheckFn = (element: ExcalidrawElement) => boolean; // Functions to prepare subtypes for use export type SubtypePrepFn = ( @@ -440,3 +444,26 @@ export const ensureSubtypesLoaded = async ( callback(); } }; + +// Call this method after finishing any async loading for +// subtypes of ExcalidrawElement if the newly loaded code +// would change the rendering. +export const checkRefreshOnSubtypeLoad = ( + hasSubtype: SubtypeCheckFn, + elements: readonly ExcalidrawElement[], +) => { + let refreshNeeded = false; + getNonDeletedElements(elements).forEach((element) => { + // If the element is of the subtype that was just + // registered, update the element's dimensions, mark the + // element for a re-render, and indicate the scene needs a refresh. + if (hasSubtype(element)) { + invalidateShapeForElement(element); + if (isTextElement(element)) { + redrawTextBoundingBox(element, getContainerElement(element)); + } + refreshNeeded = true; + } + }); + return refreshNeeded; +}; diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 018c8749e..059143745 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -16,8 +16,10 @@ import util from "util"; import path from "path"; import { getMimeType } from "../../data/blob"; import { + SubtypeLoadedCb, SubtypePrepFn, SubtypeRecord, + checkRefreshOnSubtypeLoad, prepareSubtype, selectSubtype, subtypeActionPredicate, @@ -45,7 +47,12 @@ export class API { } static addSubtype = (record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) => { - const prep = prepareSubtype(record, subtypePrepFn); + const subtypeLoadedCb: SubtypeLoadedCb = (hasSubtype) => { + if (checkRefreshOnSubtypeLoad(hasSubtype, h.elements)) { + h.app.refresh(); + } + }; + const prep = prepareSubtype(record, subtypePrepFn, subtypeLoadedCb); if (prep.actions) { h.app.actionManager.registerAll(prep.actions); } diff --git a/src/tests/subtypes.test.tsx b/src/tests/subtypes.test.tsx index a1c03e4e8..07cae3b56 100644 --- a/src/tests/subtypes.test.tsx +++ b/src/tests/subtypes.test.tsx @@ -1,9 +1,11 @@ import fallbackLangData from "./helpers/locales/en.json"; import { + SubtypeLoadedCb, SubtypeRecord, SubtypeMethods, SubtypePrepFn, addSubtypeMethods, + ensureSubtypesLoadedForElements, getSubtypeMethods, getSubtypeNames, hasAlwaysEnabledActions, @@ -16,7 +18,12 @@ import { render } from "./test-utils"; import { API } from "./helpers/api"; import ExcalidrawApp from "../excalidraw-app"; -import { ExcalidrawElement, FontString, Theme } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawTextElement, + FontString, + Theme, +} from "../element/types"; import { createIcon, iconFillColor } from "../components/icons"; import { SubtypeButton } from "../components/Subtypes"; import { registerAuxLangData } from "../i18n"; @@ -155,6 +162,18 @@ const prepareTest1Subtype = function ( return { actions, methods }; } as SubtypePrepFn; +let test2Loaded = false; + +const ensureLoadedTest2: SubtypeMethods["ensureLoaded"] = async (callback) => { + test2Loaded = true; + if (onTest2Loaded) { + onTest2Loaded((el) => isTextElement(el) && el.subtype === test2.subtype); + } + if (callback) { + callback(); + } +}; + const measureTest2: SubtypeMethods["measureText"] = function (element, next) { const text = next?.text ?? element.text; const customData = next?.customData ?? {}; @@ -165,8 +184,12 @@ const measureTest2: SubtypeMethods["measureText"] = function (element, next) { const fontString = getFontString({ fontSize, fontFamily }); const lineHeight = element.lineHeight; const metrics = textElementUtils.measureText(text, fontString, lineHeight); - const width = Math.max(metrics.width - 10, 0); - const height = Math.max(metrics.height - 5, 0); + const width = test2Loaded + ? metrics.width * 2 + : Math.max(metrics.width - 10, 0); + const height = test2Loaded + ? metrics.height * 2 + : Math.max(metrics.height - 5, 0); return { width, height, baseline: 1 }; }; @@ -185,12 +208,15 @@ const wrapTest2: SubtypeMethods["wrapText"] = function ( return `${text.split(" ").join("\n")}\nHello world.`; }; +let onTest2Loaded: SubtypeLoadedCb | undefined; + const prepareTest2Subtype = function ( addSubtypeAction, addLangData, onSubtypeLoaded, ) { const methods = { + ensureLoaded: ensureLoadedTest2, measureText: measureTest2, wrapText: wrapTest2, } as SubtypeMethods; @@ -201,6 +227,8 @@ const prepareTest2Subtype = function ( const actions = [test2Button]; actions.forEach((action) => addSubtypeAction(action)); + onTest2Loaded = onSubtypeLoaded; + return { actions, methods }; } as SubtypePrepFn; @@ -255,6 +283,7 @@ describe("subtype registration", () => { let prep2 = API.addSubtype(test2, prepareTest2Subtype); expect(prep2.actions).toStrictEqual([test2Button]); expect(prep2.methods).toStrictEqual({ + ensureLoaded: ensureLoadedTest2, measureText: measureTest2, wrapText: wrapTest2, }); @@ -262,6 +291,7 @@ describe("subtype registration", () => { prep2 = API.addSubtype(test2, prepareNullSubtype); expect(prep2.actions).toBeNull(); expect(prep2.methods).toStrictEqual({ + ensureLoaded: ensureLoadedTest2, measureText: measureTest2, wrapText: wrapTest2, }); @@ -630,3 +660,28 @@ describe("subtype actions", () => { expect(am.isActionEnabled(TEST_DISABLE3, { elements })).toBe(true); }); }); +describe("subtype loading", () => { + let elements: ExcalidrawElement[]; + beforeEach(async () => { + const testString = "A quick brown fox jumps over the lazy dog."; + elements = [ + API.createElement({ + type: "text", + id: "A", + subtype: test2.subtype, + text: testString, + }), + ]; + await render(, { localStorageData: { elements } }); + }); + it("should redraw text bounding boxes", async () => { + h.setState({ selectedElementIds: { A: true } }); + const el = h.elements[0] as ExcalidrawTextElement; + expect(el.width).toEqual(100); + expect(el.height).toEqual(100); + ensureSubtypesLoadedForElements(elements); + expect(el.width).toEqual(TWIDTH * 2); + expect(el.height).toEqual(THEIGHT * 2); + expect(el.baseline).toEqual(TBASELINE + 1); + }); +});