Skip to content

Commit 4f32647

Browse files
authored
Merge pull request #2893 from zsviczian/excalidraw-automate-cleanup
ExcalidrawAutomate and ExcalidrawView Utils cleanup
2 parents d5e2467 + b0d3125 commit 4f32647

5 files changed

Lines changed: 151 additions & 19 deletions

File tree

RefactorPlan.md

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,11 +1556,115 @@ grouped with a text element, e.g. via a script calling
15561556
changes elsewhere, including `getViewColorPalette()` since its logic itself
15571557
was already confirmed correct and unchanged.
15581558

1559-
**If resumed:** `ExcalidrawAutomate.ts` (57) is the natural next file-by-file
1560-
triage target, followed by re-checking `AIUtils.ts` (46, previously declined
1561-
as external-boundary — worth confirming that conclusion still holds now
1562-
that so much else has changed) and a final full-repo sweep once the large
1563-
clusters are gone.
1559+
**Done (2026-08-14, session 4): `ExcalidrawAutomate.ts` fully triaged, 57 → 0.**
1560+
On `master` (the `excalidraw-type-import-fix` PR was merged as #2892 in the
1561+
interim; `master` is now at `2.27.0-beta.2`). Same methodology, three
1562+
clusters:
1563+
1564+
1. **Safe, fixed (49 of 57).** `cloneElement()`/`cloneElements()`'s inner
1565+
map both do `JSON.parse(JSON.stringify(el))` to deep-clone an already-known
1566+
`ExcalidrawElement` — cast to `Mutable<ExcalidrawElement>` (`cloneElement`)
1567+
and a small local `ClonedElementDraft = Mutable<ExcalidrawElement> &
1568+
{containerId?; startBinding?; endBinding?}` (`cloneElements`, since the
1569+
remap logic generically probes text/arrow-only relationship fields across
1570+
whatever element variant it receives — matches the union's actual base
1571+
fields plus the two Excalidraw-typed cross-cutting ones, not an invented
1572+
shape). One follow-on: `newEl.boundElements.map((bound: {id: string; type:
1573+
string}) => ...)` had a same-shape-but-looser-than-necessary inline
1574+
annotation; swapped for the real imported `BoundElement` type. Also a
1575+
`new Promise((resolve) => ...)` with no generic, resolved with `string |
1576+
null` in different branches — TS had defaulted the whole chain to
1577+
`unknown`; added the explicit `Promise<string | null>`.
1578+
2. **Initially suppressed as external-boundary (8), then genuinely fixed
1579+
once the actual sources became available — 0 suppressions remain.** Two
1580+
distinct sources, both traced to their exact origin before touching
1581+
anything: `addMermaid()`'s `result.files` traced to the upstream
1582+
`mermaidToExcalidraw()` function's own declared return type (`{
1583+
elements?: ExcalidrawElement[]; files?: any; error?: string } | undefined`
1584+
in `@zsviczian/excalidraw`'s `MermaidToExcalidrawLib.d.ts`) — `files?:
1585+
any` was the *library's own* declared type, not something the earlier
1586+
`@excalidraw/common` fix masked or could improve from this side. First
1587+
pass suppressed both with `eslint-disable-next-line` plus a one-line
1588+
justification per site, matching this repo's documented suppression
1589+
format. The user then fixed it at the actual source: updated
1590+
`mermaidToExcalidraw`'s declaration in the fork
1591+
(`packages/excalidraw/components/TTDDialog/MermaidToExcalidrawLib.ts`)
1592+
to `files?: BinaryFiles`, rebuilt, and refreshed this repo's installed
1593+
`node_modules` copy — both suppressions simply deleted, no cast needed,
1594+
the value is now genuinely typed. For `cloneElements()`'s
1595+
`JSON.parse(elementsOrClipboard)`, the user pointed at the authoritative
1596+
source instead of accepting the suppression: `actionCopy`/
1597+
`serializeAsClipboardJSON()` in the fork's own
1598+
`packages/excalidraw/clipboard.ts` show the real envelope Excalidraw's
1599+
own copy action produces (`{type: "excalidraw/clipboard", elements,
1600+
files}`, `EXPORT_DATA_TYPES.excalidrawClipboard === "excalidraw/clipboard"`
1601+
confirmed against `packages/common/src/constants.ts`, matching the
1602+
plugin's own runtime check exactly). Replaced the suppressions with a real
1603+
union type (`{type: string; elements: ExcalidrawElement[]} |
1604+
ExcalidrawElement[]`, the second arm being this method's own added
1605+
convenience for a bare JSON-stringified array, not an Excalidraw-produced
1606+
format) and one added `!Array.isArray(parsed)` guard ahead of the
1607+
`.type` check purely to let the union narrow before that property access
1608+
— logically a no-op versus the original runtime behavior, since a bare
1609+
array was already implicitly guaranteed to fail the `.type ===
1610+
"excalidraw/clipboard"` comparison (arrays have no `.type`).
1611+
1612+
`npm run build`/`npm run lib`/`node --check dist/main.js` all pass clean
1613+
with **zero `eslint-disable` suppressions anywhere in this file**;
1614+
33-warning circular-dependency baseline unchanged; `dist/main.js` is
1615+
4,716,872 bytes (+19 bytes from the prior checkpoint, from the fork's own
1616+
rebuild — this repo's change is compile-time only). Confirmed via
1617+
`git stash`/pop full-repo ESLint diff: 229 → 172 (57 fewer), zero
1618+
regressions, the entire delta contained to this one file. Committed
1619+
(`efb00d1d`) on branch `excalidraw-automate-cleanup`, pushed; PR not yet
1620+
opened (`gh` unavailable in this environment — no token/CLI to author it
1621+
directly, link and prepared title/body handed to the user instead).
1622+
1623+
**Done (2026-08-14, session 5): `excalidrawViewUtils.ts` fully triaged,
1624+
20 → 0.** On branch `excalidraw-automate-cleanup` (continued rather than
1625+
starting a new branch). Two clusters:
1626+
1627+
1. **The exact `RegExpMatchIteratorResult`-shaped bug again (16 of 20).**
1628+
`isTextImageTransclusion()`'s `const match = text.trim().matchAll(...).next();`
1629+
— a bare `let`/`const` with no annotation, assigned via `.next()` on a
1630+
`matchAll()` iterator, collapsing to implicit `any` and cascading through
1631+
16 downstream accesses. Same exact shape as the very first fix in this
1632+
whole `no-unsafe-*` effort (`ExcalidrawData.ts`, session predating this
1633+
page's start) — annotated `IteratorResult<RegExpMatchArray, undefined>`.
1634+
The plan's own "if resumed" note several sections up predicted exactly
1635+
this: "look for more `ExcalidrawData.ts`-shaped cases... a local variable
1636+
whose real type is already known... just missing on one declaration."
1637+
2. **`Function.prototype.bind()` degrading a real signature to `any` (4).**
1638+
`getViewColorPalette()`'s `cmFactory = view.hookServer?.getCM?.bind(...) ??
1639+
view.plugin.ea.getCM.bind(...)`. Probed each side independently
1640+
(throwaway type-probes, added and reverted): `view.hookServer?.getCM` and
1641+
`view.plugin.ea.getCM` both resolve cleanly to their real declared
1642+
signature, `(color: TInput) => ColorMaster` — the `any` is introduced
1643+
exclusively by `.bind()`'s own type declaration failing to preserve it.
1644+
A plain variable type annotation on `cmFactory` satisfied `tsc` but
1645+
*not* `eslint`'s `no-unsafe-assignment` (which still flags assigning a
1646+
provably-`any`-typed expression regardless of the target annotation);
1647+
switched to an explicit `as (color: TInput) => ColorMaster` cast instead,
1648+
which both tools accept. Imported the real `TInput`/`ColorMaster` types
1649+
from `@zsviczian/colormaster` — the file's own pre-existing local
1650+
`ColorMasterLike` structural type and `isColorMasterLike()` guard (used
1651+
for everything *after* the `cmFactory(...)` call) were left untouched,
1652+
since only the call signature itself needed fixing.
1653+
1654+
`npm run build`/`npm run lib`/`node --check dist/main.js` all pass clean;
1655+
33-warning circular-dependency baseline and `dist/main.js` byte size both
1656+
unchanged (4,716,872 bytes — compile-time only). Confirmed via `git
1657+
stash`/pop full-repo ESLint diff: 172 → 152 (20 fewer), zero regressions,
1658+
entire delta contained to this one file.
1659+
1660+
**If resumed:** `AIUtils.ts` (46, previously declined as external-boundary)
1661+
is the natural next target — worth confirming that conclusion still holds
1662+
given how many things in this effort turned out to have a real fix hiding
1663+
behind what first looked like a boundary, rather than assuming. After that,
1664+
a final full-repo sweep once the remaining clusters are gone. Realistic
1665+
floor is still not 0 — `AIUtils.ts`'s provider-JSON boundary and Obsidian's
1666+
own `FrontMatterCache: {[key: string]: any}` remain genuine, unavoidable
1667+
external boundaries by their own nature, not by insufficient effort.
15641668

15651669
## Related, separate effort: `ExcalidrawData.ts` structural extraction
15661670

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"@popperjs/core": "^2.11.8",
3131
"@radix-ui/number": "^1.1.2",
3232
"@zsviczian/colormaster": "^1.2.2",
33-
"@zsviczian/excalidraw": "0.18.122",
33+
"@zsviczian/excalidraw": "0.18.123",
3434
"chroma-js": "^3.1.2",
3535
"clsx": "^2.0.0",
3636
"es6-promise-pool": "2.5.0",

src/shared/ExcalidrawAutomate.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
ExcalidrawFrameElement,
1818
ExcalidrawTextContainer,
1919
ElementsMap,
20+
FixedPointBinding,
21+
BoundElement,
2022
} from "@zsviczian/excalidraw/types/element/src/types";
2123
import { ColorMap, MimeType } from "../types/embeddedFileLoaderTypes";
2224
import {
@@ -581,7 +583,7 @@ export class ExcalidrawAutomate {
581583
const blob = new Blob([data], { type });
582584

583585
// Read the blob as Data URL
584-
const base64String = await new Promise((resolve) => {
586+
const base64String = await new Promise<string | null>((resolve) => {
585587
const reader = new FileReader();
586588
reader.onload = () => {
587589
if (typeof reader.result === "string") {
@@ -4712,7 +4714,7 @@ export class ExcalidrawAutomate {
47124714
* @returns {ExcalidrawElement} The cloned element with a new ID.
47134715
*/
47144716
cloneElement(element: ExcalidrawElement): ExcalidrawElement {
4715-
const newEl = JSON.parse(JSON.stringify(element));
4717+
const newEl = JSON.parse(JSON.stringify(element)) as Mutable<ExcalidrawElement>;
47164718
newEl.id = nanoid();
47174719
return newEl;
47184720
}
@@ -4733,8 +4735,16 @@ export class ExcalidrawAutomate {
47334735
// 1. Parse the input
47344736
if (typeof elementsOrClipboard === "string") {
47354737
try {
4736-
const parsed = JSON.parse(elementsOrClipboard);
4738+
// Matches the envelope serializeAsClipboardJSON()/actionCopy produce
4739+
// upstream (packages/excalidraw/clipboard.ts): {type: "excalidraw/clipboard",
4740+
// elements, files}. The bare-array branch below is this method's own
4741+
// convenience for a plain JSON-stringified element array, not an
4742+
// Excalidraw-produced format.
4743+
const parsed = JSON.parse(elementsOrClipboard) as
4744+
| { type: string; elements: ExcalidrawElement[] }
4745+
| ExcalidrawElement[];
47374746
if (
4747+
!Array.isArray(parsed) &&
47384748
parsed.type === "excalidraw/clipboard" &&
47394749
Array.isArray(parsed.elements)
47404750
) {
@@ -4779,9 +4789,18 @@ export class ExcalidrawAutomate {
47794789
});
47804790

47814791
// 3. Clone and remap relationships
4792+
// Deep-cloned elements are probed for relationship fields (containerId,
4793+
// startBinding, endBinding) that only exist on some ExcalidrawElement
4794+
// union members -- widened here so the generic remap logic below can
4795+
// read/write them regardless of which variant a given element actually is.
4796+
type ClonedElementDraft = Mutable<ExcalidrawElement> & {
4797+
containerId?: string | null;
4798+
startBinding?: FixedPointBinding | null;
4799+
endBinding?: FixedPointBinding | null;
4800+
};
47824801
const clonedElements: ExcalidrawElement[] = elements.map((el) => {
47834802
// Deep clone the element
4784-
const newEl = JSON.parse(JSON.stringify(el));
4803+
const newEl = JSON.parse(JSON.stringify(el)) as ClonedElementDraft;
47854804

47864805
// Update element ID
47874806
newEl.id = idMap.get(el.id)!;
@@ -4801,7 +4820,7 @@ export class ExcalidrawAutomate {
48014820
// Remap Bound Elements (e.g., the rectangle holding the text, or arrows attached to a shape)
48024821
if (newEl.boundElements && Array.isArray(newEl.boundElements)) {
48034822
newEl.boundElements = newEl.boundElements.map(
4804-
(bound: { id: string; type: string }) => ({
4823+
(bound: BoundElement) => ({
48054824
...bound,
48064825
id: idMap.get(bound.id) || bound.id, // Fallback to original ID if bound element wasn't cloned
48074826
}),

src/utils/excalidrawViewUtils.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { ColorMaster } from "@zsviczian/colormaster";
2+
import type { TInput } from "@zsviczian/colormaster/types";
13
import {
24
MAX_IMAGE_SIZE,
35
IMAGE_TYPES,
@@ -810,7 +812,10 @@ export function isTextImageTransclusion(
810812
callback: (link: string, file: TFile) => void,
811813
): boolean {
812814
const REG_TRANSCLUSION = /^!\[\[([^|\]]*)?.*?]]$|^!\[[^\]]*?]\((.*?)\)$/g;
813-
const match = text.trim().matchAll(REG_TRANSCLUSION).next(); //reset the iterator
815+
const match: IteratorResult<RegExpMatchArray, undefined> = text
816+
.trim()
817+
.matchAll(REG_TRANSCLUSION)
818+
.next(); //reset the iterator
814819
if (match?.value?.[0]) {
815820
const link = match.value[1] ?? match.value[2];
816821
const file = view.app.metadataCache.getFirstLinkpathDest(
@@ -948,9 +953,13 @@ export function getViewColorPalette(
948953
return [basePalette as string];
949954
}
950955

951-
const cmFactory =
952-
view.hookServer?.getCM?.bind(view.hookServer) ??
953-
view.plugin.ea.getCM.bind(view.plugin.ea);
956+
// Function.prototype.bind()'s type declaration doesn't preserve getCM's
957+
// real (color: TInput) => ColorMaster signature, collapsing it to any --
958+
// annotated explicitly instead, matching the signature confirmed above.
959+
const cmFactory = (view.hookServer?.getCM?.bind(view.hookServer) ??
960+
view.plugin.ea.getCM.bind(view.plugin.ea)) as (
961+
color: TInput,
962+
) => ColorMaster;
954963
type ColorMasterLike = {
955964
lightness?: number;
956965
alpha?: number;

0 commit comments

Comments
 (0)