This repository contains the Obsidian Excalidraw plugin: an Obsidian.md plugin that integrates a heavily customized Excalidraw fork into Obsidian.
This is not a generic React canvas app. Many implementation choices are driven by:
- Obsidian plugin lifecycle constraints
- Electron desktop and mobile compatibility
- popout window support
- startup time and bundle size
- compatibility with a customized upstream Excalidraw fork
- advanced plugin-specific features such as scripts, automation, custom pens, custom fonts, markdown embeds, PDF export, OCR, AI features, and deep vault integration
When working in this repo, optimize for correctness inside Obsidian and for preserving existing behavior. Do not assume that a simpler-looking web-app pattern is safe here.
Use https://github.com/obsidian-typings/obsidian-typings as a reference when dealing with Obsidian APIs, especially undocumented or weakly documented ones.
- The shipped plugin is effectively three files:
main.js,styles.css, andmanifest.json. - The production build emits these into
dist/. - The bundle is intentionally assembled as a single CommonJS
main.jswith embedded runtime payloads. package.jsonis primarily the library/package manifest. Its version is not the canonical plugin release version.- Plugin release versions live in
manifest.jsonandmanifest-beta.json. package.jsonmainandtypespoint to the library build inlib/, not the Obsidian runtime bundle.
src/core/main.tsowns plugin startup, lifecycle, settings loading, migration, and manager initialization.src/view/ExcalidrawView.tsis the main runtime surface for the editor/view experience.src/core/managers/contains most lifecycle-adjacent coordination logic.src/shared/contains major reusable subsystems such as Excalidraw Automate, dialogs, data handling, script engine support, LaTeX, workers, and SVG conversion.src/utils/contains lower-level helpers for Obsidian integration, export, files, PDF, AI, dynamic styling, and scene manipulation.rollup.config.mjsis a critical part of the runtime architecture, not just a packaging detail.
Treat build code and runtime code as one system.
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
- Runtime entry point:
src/core/main.ts, compiled byrollup.config.mjstodist/main.js. - Required release artifacts:
main.js,manifest.json, andstyles.css. - Node.js: use Node 22 or newer. Check
node --versionbefore diagnosing build-tool failures; mixed Node/Corepack installations can produce misleading errors. - Package manager: npm. Use
npm installand the scripts in this repository'spackage.json. - Runtime bundler: Rollup. Do not replace this build with a sample-plugin esbuild setup; Rollup also assembles compressed runtime payloads and merged CSS.
- Primary lint command:
npm run code.npm run lintis broader and may expose unrelated repository backlog. - Types: Obsidian type definitions plus conservative declarations for intentionally used unpublished APIs.
Follow Obsidian's Developer Policies and Plugin Guidelines. In particular:
- Default to local/offline operation. Only make network requests when essential to the feature.
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in
README.mdand in settings. - Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
- Clearly disclose any external services used, data sent, and risks.
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
- Avoid deceptive patterns, ads, or spammy notifications.
- Register and clean up all DOM, app, and interval listeners using the provided
register*helpers so the plugin unloads safely.
- Prefer sentence case for headings, buttons, and titles.
- Use clear, action-oriented imperatives in step-by-step copy.
- Use bold to indicate literal UI labels. Prefer "select" for interactions.
- Use arrow notation for navigation: Settings → Community plugins.
- Keep in-app strings short, consistent, and free of jargon.
- Prefer Obsidian's established classes such as
mod-warningbefore adding plugin-specific CSS. Consult the Obsidian CSS variables and component conventions when styling settings or dialogs. - Never use
document.createElement(),Document.createElement(), orDocument.createDocumentFragment(). Always use Obsidian'screateEl(),createDiv(),createSpan(),createSvg(), andcreateFragment()helpers. When an element must deliberately belong to a specific document that Obsidian's helpers cannot target, such as a canvas used for drawing or an element inside an iframe, use thedeliberateCreateElementfunction injected byrollup.config.mjsand declare it in the consuming module, for exampledeclare const deliberateCreateElement: (document: Document, tagName: string) => HTMLElement;. - Use semantic interactive elements and Obsidian's event helpers. Link-like navigation should use a real anchor, not a button merely styled as a link; use
onClickEventwhen applying custom navigation behavior. Validate touch interactions on a physical mobile device because desktop mobile emulation proves layout, not native touch activation. - When a control already has a styled tooltip, do not also set an HTML
titleattribute. Keep its accessible name inaria-label;titleproduces a second native Chromium/Electron tooltip and must not replace accessible labeling. - Do not write an element's
styleattribute directly. UsesetStyleandremoveStylefromsrc/utils/styleUtils.tswhen a dynamic inline style is genuinely necessary. - Use the existing visibility helpers instead of the native
hiddenproperty or attribute, which is not reliable in Obsidian's styled UI. ChoosehideElement/showElementorsetComponentVisibilityfromsrc/utils/styleUtils.ts, orsetElementHidden/setElementDisplayfromsrc/utils/htmlUtils.tswhen a boolean/display-oriented API is clearer. - Search
src/utils/styleUtils.tsandsrc/utils/htmlUtils.tsbefore introducing new DOM styling helpers or display classes. - Run Obsidian CodeScanner after CSS changes. Preserve compatibility with the declared minimum Obsidian version and prefer a widely supported property when it provides the same result; for example, a basic underline is preferable to optional underline-thickness/offset styling.
- For a known vault path, use the most specific synchronous lookup:
app.vault.getFolderByPath()for folders andapp.vault.getFileByPath()for files. UsegetAbstractFileByPath()only when either type is intentionally accepted, and avoid adapter-level existence checks when the Vault API already models the target. - Radix content in the customized Excalidraw package may be rendered through
ObsidianRadixPortaldirectly under the owning document's body. A body portal escapes component ancestor selectors and modal stacking contexts. When a trigger is visible but its menu or popover is not, first inspect whether the content mounted behind a modal or lost ancestor-scoped styles. Use a class on the portaled content, a portal-safe selector, and an explicit stacking level when required; validate main-window, popout, click-outside, and Escape behavior.
- Keep startup light. Defer heavy work until needed.
- Avoid long-running tasks during
onload; use lazy initialization. - Batch disk access and avoid excessive vault scans.
- Debounce/throttle expensive operations in response to file system events.
- For temporary performance diagnostics, use a unique searchable prefix and emit one copyable string per event rather than logging expandable objects. Keep timing-only callbacks and state isolated from production behavior.
- Distinguish synchronous API duration, queue completion, browser-task yields, animation-frame callbacks, and actual paint; none is a substitute for the others when explaining perceived latency.
- Temporary diagnostics are not product observability. Do not log vault
contents, and include filenames only when the maintainer explicitly needs
per-file attribution. Remove the diagnostics before commit and search both
src/and the builtdist/main.jsfor their prefix.
- TypeScript with
"strict": truepreferred. - Keep
main.tsminimal: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules. - Split large files: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
- Use clear module boundaries: Each file should have a single, well-defined responsibility.
- Bundle everything into
main.js(no unbundled runtime deps). - Avoid Node/Electron APIs if you want mobile compatibility; set
isDesktopOnlyaccordingly. - Prefer
async/awaitover promise chains; handle errors gracefully.
- Don't assume desktop-only behavior unless
isDesktopOnlyistrue. - Avoid using desktop only objects and functions such as Node Buffer, SharedArrayBuffer, etc. For the special case when this is required, make proper mobile safe guards.
- Avoid large in-memory structures; be mindful of memory and storage constraints.
- Treat a physical phone or tablet as a separate validation environment for touch targets, synthesized clicks, scrolling gestures, focus, and the mobile WebView. Desktop mobile emulation is useful for responsive layout checks but is not a substitute for real touch testing.
Do
- Add commands with stable IDs (don't rename once released).
- Provide defaults and validation in settings.
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
- Use
this.register*helpers for everything that needs cleanup.
Don't
- Introduce network calls without an obvious user-facing reason and documentation.
- Ship features that require cloud services without clear disclosure and explicit opt-in.
- Store or transmit vault contents unless essential and consented.
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
- API documentation: https://docs.obsidian.md
- Developer policies: https://docs.obsidian.md/Developer+policies
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
- Style guide: https://help.obsidian.md/style-guide
src/core/main.ts: plugin class, lifecycle ordering, view registration, settings load/save/migration, startup instrumentationsrc/core/index.ts: library/public API entry used by consumers of the lib buildsrc/core/settings.ts: settings interface, defaults, settings tab UIsrc/core/managers/: command, event, file, observer, package, style, and markdown post processor managerssrc/core/editor/: editor-specific helpers and mode handlingsrc/view/: Excalidraw views, loading view, sidepanel, view managerssrc/shared/: Excalidraw Automate, script engine, dialogs, OCR, LaTeX, data model, workers, suggesters, SVG parsersrc/utils/: file/path/export/PDF/Obsidian/scene/UI/helper utilitiessrc/lang/: localization helpers and locale filessrc/constants/: constants, icons, startup script, safe URLssrc/types/: project TypeScript contracts
rollup.config.mjs: main plugin build and runtime payload embeddingMathjaxToSVG/: separate subproject built before the main plugin and embedded into the bundlescripts/: build helpers such asbuild-mathjax.mjsstyles.css: plugin stylesheet merged with Excalidraw CSS during builddocs/: user-facing documentation and API/docs contentea-scripts/: downloadable/example scripts for the script engine, not the main plugin runtimetest-data/: fixtures and sample content, not a conventional automated test suite
Lifecycle ordering matters.
onload()insrc/core/main.tsregisters views, extensions, icons, the ribbon action, settings loading, Excalidraw Automate initialization, the markdown post processor, and theonLayoutReadycallback.onloadOnLayoutReady()initializes the package manager, event manager, observer manager, command manager, compression worker, Excalidraw config, monkey patches, styles manager, script engine, fonts, image cache, and finally switches loading views to real Excalidraw views.
Important constraints:
- Keep markdown post processor registration in
onload(). Obsidian expects post processors to be registered there. - Monkey patches are intentionally registered after layout is ready.
- Startup order is performance-sensitive and behavior-sensitive.
- Do not move initialization steps across
onload()andonLayoutReady()casually. ExcalidrawView.onUnloadFile()deliberately avoids callingsuper.onUnloadFile()to avoid duplicate autosave behavior.
If a task touches startup behavior, read the surrounding lifecycle code before editing.
This project uses a non-trivial Rollup build because startup time, popout-window behavior, and bundle size matter.
- The main build entry is
src/core/main.ts. - Production output is
dist/main.jsplusdist/styles.cssanddist/manifest.json. inlineDynamicImportsis enabled for the main bundle.- The build compresses and embeds selected runtime payloads into
main.js. styles.cssis merged with upstream Excalidraw CSS and minified.
The build embeds or injects runtime code for:
- React, ReactDOM/client, and the official JSX runtime entry points built from the installed npm packages
- the customized
@zsviczian/excalidrawObsidian artifact built from Excalidraw's ESM source graph MathjaxToSVGlz-string- selected compressed locale payloads
These payloads are executed or unpacked at runtime. This is intentional.
React and the Excalidraw package are separate payloads. React must not be bundled into the Excalidraw artifact, because the plugin creates a matching private React runtime in every Obsidian window. Mermaid is also intentionally absent from the artifact and is loaded lazily at runtime through Excalidraw Extras. All other required Excalidraw assets are expected to work offline except the deliberately lazy CJK font subsets.
The customized component lives in the sibling zsviczian/excalidraw repository. When both repositories are available locally, it is normally at ../excalidraw; verify the actual workspace path and branch instead of assuming it.
- The Excalidraw repository uses Yarn and builds the consumer-specific payload from
packages/excalidrawwithyarn build:obsidian. - That build emits four files under
packages/excalidraw/dist/obsidian/: production and development JavaScript plus production and development CSS. - This plugin consumes the same four paths from
node_modules/@zsviczian/excalidraw/dist/obsidian/inrollup.config.mjs. - For a temporary unpublished integration test, build the sibling package and copy only those four generated files into the installed package under
node_modules. Do not changepackage.jsonorpackage-lock.jsonto a localfile:dependency merely for this handoff. A laternpm installrestores the published package. - For the durable handoff, publish a new
@zsviczian/excalidrawversion, update this repository's dependency, runnpm install, and rebuild the plugin. - A local artifact copy proves integration only. If plugin source consumes a new fork API, do not describe the plugin handoff as commit- or release-ready until the published package is installed and the exact dependency and lockfile contain that API, unless the maintainer explicitly requests a paired intermediate commit.
- Never hand-edit or commit generated
dist/,lib/, ornode_modulesartifacts as source fixes. - Treat the repositories as separate Git histories. Check branch, status, diff, build, and commit state independently in each one, and do not commit or publish unless explicitly requested.
The customized Excalidraw runtime receives Obsidian capabilities through typed host adapters. Treat this as the only supported plugin-to-fork dependency-inversion boundary.
- Do not expose or recover the plugin through component props,
appState,window,globalThis,app.plugins, or fork-sidehostPluginvariables. - Keep adapters narrow and semantic. They may expose operations such as reading a current limit or running a named action, but never the plugin instance, the complete settings object, or an active view.
- Capabilities used by
@excalidraw/commonor lower layers belong inObsidianCommonHostAdapter. Capabilities used only by the Excalidraw package belong inObsidianExcalidrawHostAdapter. - View-specific state must remain instance-scoped. Do not put an active view into either window-runtime adapter; expose a semantic plugin-side action when the component genuinely requires such behavior.
PackageManagerregisters both adapters once per evaluated Excalidraw runtime and window. React components and individualExcalidrawViewinstances must not configure or dispose them.- Adapter methods must read live plugin state instead of capturing settings snapshots during registration.
PackageManagerowns the complete lifetime: dispose registrations before removing a package or window runtime, make cleanup idempotent, and roll back all registrations if configuring any adapter fails.
A closure that references the plugin is not itself a memory leak. The risk is allowing a registry, listener, or evaluated runtime retaining that closure to outlive its owning PackageManager registration.
The fork is the source of truth for host-adapter contracts and protocol constants. Import or derive types from its published declarations where possible. If the evaluated window.ExcalidrawLib surface requires an ambient declaration, derive that declaration from the fork types instead of restating property lists or unions locally.
The host adapters are an internal protocol between this plugin and its exact @zsviczian/excalidraw dependency. They are not a compatibility surface between arbitrary historical plugin and fork versions.
- A breaking adapter change must increment the relevant protocol version and update the fork implementation, fork tests, plugin adapter, and plugin ambient runtime declaration in one coordinated checkpoint.
- Fail fast during package loading when a required boundary is absent or incompatible. Do not retain global-plugin discovery or legacy bridge fallbacks solely to support mismatched plugin and fork versions.
- Preserve the general backwards-compatibility requirements for persisted settings, serialized scenes, scripts, commands, and public APIs; this exception applies only to the paired internal host protocol.
- For a maintainer-coordinated release, build and verify the fork package first, publish it, update the plugin's exact dependency, run
npm install, and rebuild and smoke-test against the published artifact before committing the plugin handoff.
src/core/managers/PackageManager.tsmanages window-scoped React/ReactDOM/Excalidraw packages.- This is necessary because the plugin must work in Obsidian/Electron popout windows.
- Do not replace this with a naive global singleton approach.
- The runtime is built from official npm package entry points and kept in plugin/package lexical scope. Do not assign React or ReactDOM to
window; only the documentedwindow.ExcalidrawLibcompatibility surface remains global. - Rendering, DOM ownership, events, observers, portals, and React roots must use the owning view window where appropriate.
- Treat
HTMLElement.onWindowMigrated()as a destructive runtime boundary. Its callback runs after Obsidian has moved the view container to another document, while the existing React root and Excalidraw API still belong to the source window runtime. - For a dirty migration, synchronously capture every API-owned value needed for persistence and unmount the source React root before the first
await. Do not move synchronization, compression, Vault/native file access,closeLeafView(), or another asynchronous step ahead of that unmount. On macOS/Electron, doing so reproducibly allowed the source popout window to be destroyed beforeroot.unmount(), freezing Obsidian and disconnecting DevTools. - Cancel deferred initialization and scene-file loaders before migration unmount, and require delayed loader callbacks to match the exact API instance and file path that started them. Component-owned image decoding can still outlive a synchronous
addFiles()call, so the Excalidraw runtime must also stop after an awaited decode when its editor has unmounted; never delay migration unmount to wait for image work. - The migration callback owns the single persistence flush. Generic
onClose()andonUnloadFile()safeguards must not start duplicate migration saves, and the retired source view must reject blur-save side effects and vault-modify synchronization after its API is unmounted. - A popout-to-main migration may serialize from a synchronously captured drawing snapshot, but the replacement main-window view must perform the final drawing-file write. Never initiate the final Vault/native write from the source popout callback.
Window ownership for rendering is not the same as ownership for persistent plugin data.
- Existing plugin-level IndexedDB and local-storage data belongs to Obsidian's main application window and must remain shared across normal views and popouts.
- Do not change persistent storage to
view.ownerWindow, create one database per popout, or infer a storage migration from a rendering bug unless the task explicitly requires that behavior. - Diagnose persistence and presentation separately. For example, a visible history button conditioned on loaded records proves the load path worked even when a portaled history menu is hidden.
- If a new feature is intentionally view-local, document that exception and test window migration and popout teardown explicitly.
- Classify every affected store as disposable derived cache or durable user data before changing its schema. Image previews may be rebuilt; drawing backups must not be deleted as cache migration cleanup.
- Create current stores and remove obsolete disposable stores in one IndexedDB version-change transaction. Prefer lazy cache rebuilding over deserializing and rewriting a large legacy cache during startup.
- If opening or upgrading fails, close and clear unusable database handles and readiness promises. A closed legacy connection must never make the cache report itself as ready.
- Use browser APIs such as
BlobandFileReaderfor in-memory payload conversion so the path remains mobile-safe. Vault file access must still use Obsidian's Vault API. - Validate cold upgrade, preservation of durable stores, first cold rebuild, warm reopen, clear, timed purge, plugin reload, and one mobile run. Persistent plugin data remains owned by the main application window; popouts need a usage smoke test, not a separate database.
MathjaxToSVG/is a standalone subproject with its own build.scripts/build-mathjax.mjshashes the subproject, installs dependencies if needed, and rebuilds it when inputs change.- The main plugin build expects
MathjaxToSVG/dist/index.jsto exist and embeds it.
- English is loaded directly.
- Some non-English locales are compressed and embedded at build time.
rollup.config.mjstokenizes runtime-dependent strings in locales.src/lang/helpers.tsresolves those tokens at runtime.- If you add new runtime-dependent locale patterns, you must keep
rollup.config.mjsandsrc/lang/helpers.tsin sync.
- Safe URLs are centralized in
src/constants/safeUrls.ts. - The build tokenizes URL constants and resolves them at runtime.
- If you change safe URL handling, rebuild and verify both token emission and token resolution.
- The release workflow publishes
dist/main.js,dist/styles.css, anddist/manifest.json. - Stable releases use
manifest.json. - Pre-releases swap in
manifest-beta.jsonin the GitHub release workflow. rollup.config.mjscurrently readsmanifest-beta.jsonwhen injectingPLUGIN_VERSION, so version changes must be intentional and consistent.
- Prefer repository-local patterns over upstream Excalidraw assumptions.
- Many unusual solutions are workarounds for Obsidian or Electron limitations. Do not remove them just because they look unconventional.
- The codebase uses non-published Obsidian APIs and monkey patches where necessary.
- Performance and startup-time optimizations are first-class design constraints.
- Popout window support is a first-class design constraint.
- Backwards compatibility is a strong default requirement.
- Preserve existing abstractions unless the task clearly requires a redesign.
- Avoid broad refactors unless there is strong evidence they are necessary.
- Use
RefactorPlan.mdas the living architectural record. Update the progress table and append an action-log entry after each completed or reverted checkpoint. - Make one independently testable behavior change or mechanical extraction at a time. Prefer moving code intact before simplifying it.
- Preserve timers, observers, semaphores, lifecycle ordering, and unpublished-API workarounds unless their purpose has been traced and an equivalent behavior has been verified across affected platforms.
- Do not convert
ExcalidrawViewwholesale into React. It must remain an ObsidianTextFileView; React is the child rendering runtime. Extract cohesive view-scoped controllers and components while retaining compatibility delegates on the view. - For duplicate utilities, compare every implementation and caller before consolidation. Marginal behavior differences must be shown unused or deliberately preserved.
- Do not derive a runtime settings sanitizer from the TypeScript interface. Interfaces do not exist at runtime, settings evolve frequently, and unknown keys may belong to a newer or companion version. Remove obsolete keys only through an explicit, reviewed migration or retirement decision.
- End every checkpoint with risk-based manual test recommendations: identify the highest-probability failure, the affected workflow, and whether main-window, popout, desktop operating systems, and mobile need separate coverage.
Treat the following as the target convention for all new code and for any future naming-cleanup pass. The current repository contains legacy exceptions. Do not rename files opportunistically inside behavior changes; do naming cleanup in a dedicated, compatibility-aware refactor.
- Use PascalCase filenames when the file's main export is a class, React component, modal, manager, view, or similarly named object with clear identity. The filename should match the primary export. Examples already in the repo include
ExcalidrawView.ts,CommandManager.ts, andReleaseNotes.ts. - Use lowerCamelCase filenames for helper and utility modules whose main exports are functions or small related helpers. Examples already in the repo include
fileUtils.ts,pathUtils.ts, andexportUtils.ts. - Use lowerCamelCase filenames for grouped type modules unless the file is intentionally mirroring an externally established name.
- Use PascalCase for classes, React components, dialogs, managers, views, interfaces, and domain-level type aliases.
- Use lowerCamelCase for functions, methods, variables, parameters, and object properties.
- Use UPPER_SNAKE_CASE for exported constants and locale keys.
- Keep persisted settings keys, serialized fields, and frontmatter keys stable and lowerCamelCase unless an explicit migration is added.
- For future rename work, fix typos and inconsistent acronym casing in a dedicated pass rather than mixing those renames with behavior changes. Existing examples worth normalizing later include files such as
YoutTubeUtils.ts,modifierkeyHelper.ts, andTTDDialogPersistanceAdater.ts.
src/core/: plugin bootstrap, plugin-wide lifecycle, registration, settings, and orchestration. If a change affects the plugin globally rather than a single open view, start here.src/core/managers/: plugin-global coordinators that own subscriptions, registration, or lifecycle-managed behavior.src/core/editor/: markdown/editor bridge behavior and editor-specific UX.src/view/: the live Excalidraw pane and view-owned behavior.src/view/components/: React components rendered inside the main Excalidraw view or tightly coupled toExcalidrawViewstate.src/view/managers/: view-scoped controllers that belong to a single view instance rather than the whole plugin.src/view/sidepanel/: sidepanel-only view code.src/shared/: reusable subsystems used by multiple areas such ascore,view, scripting, import/export, or dialogs.src/shared/Dialogs/: Obsidian modals, prompts, release-note content, and user-facing dialog flows.src/shared/Dialogs/Messages.tsis the source for next-version change messages shown to users.src/shared/components/: reusable UI helpers that are not owned solely by the main Excalidraw view.src/shared/Suggesters/: suggestion modals and suggestion-specific helpers.src/shared/Workers/: worker entrypoints and worker-specific helper code.src/shared/OCR/: OCR integrations and OCR-specific logic.src/shared/svgToExcalidraw/: SVG parsing and import pipeline code.src/utils/: side-effect-light helpers. If a module owns long-lived state, subscriptions, or plugin/view lifecycle, it likely does not belong inutils.src/constants/: shared constants, icon definitions, startup content, and URL registries. Keep logic here minimal and obvious.src/types/: shared TypeScript contracts and ambient declarations, not implementation logic.src/lang/locale/: user-visible strings.en.tsis the source of truth for new keys.MathjaxToSVG/: a separate subproject with its own build and runtime role. Treat it as an independent package with the same documentation and compatibility expectations as the main plugin.
Use TSDoc as the documentation standard. It is the modern TypeScript-friendly evolution of JSDoc and is the right fit for this repository; do not think in terms of JavaDoc for TS code.
- Require TSDoc for exported classes, exported functions, public methods, public library APIs, and non-obvious modules.
- Public APIs in
src/core/index.tsandsrc/shared/ExcalidrawAutomate.tsshould always have complete TSDoc. - Settings migrations, compatibility shims, build-time tokenization code, and package-loading code should also carry clear high-signal documentation.
- Add short module-level documentation to files whose behavior is easy to misread, such as
rollup.config.mjs,src/core/managers/PackageManager.ts, migration code insrc/core/main.ts, and theMathjaxToSVG/subproject. - Internal/private code should only be commented when the behavior is non-obvious. Avoid trivial comments.
Backwards compatibility is a strong requirement in this repository.
- Do not break persisted settings, serialized scene data, frontmatter keys, command IDs, or documented user workflows without an explicit migration or compatibility layer.
- Do not rename public API methods, script-engine entry points, or library exports casually.
src/core/index.tsandsrc/shared/ExcalidrawAutomate.tsare especially sensitive. - Naming-only refactors must preserve observable behavior.
- If a rename reaches beyond a purely internal import graph, prefer temporary aliases, re-exports, or compatibility wrappers during migration.
- If settings shape or stored values change, update defaults, settings UI, load/save flow, and migration logic together.
- Assume user scripts, vault content, templates, embeds, release-note references, and community documentation may depend on existing names and behavior.
- The paired plugin-to-fork host protocol is the deliberate exception described above: coordinate and version breaking contract changes instead of preserving fallbacks for mismatched package versions.
- Every user-visible change intended for the next release should be documented in
src/shared/Dialogs/Messages.tsunder the next upcoming version key. - Keep
Messages.tsentries concise, user-facing, and focused on observable behavior, not implementation detail. - If a change affects scripting, API behavior, settings, or migration, mention that explicitly in the release-note entry.
- Every new user-visible language string must be added first in
src/lang/locale/en.ts. - The same change must also update
src/lang/locale/ru.ts,src/lang/locale/es.ts,src/lang/locale/zh-cn.ts, andsrc/lang/locale/zh-tw.ts. - These maintained locales are part of the build-time localized bundle path, so they are not optional follow-up work.
- Preserve locale key names and the existing comment grouping by owning file or subsystem.
- If a string contains URLs or runtime-dependent tokens, follow the existing locale patterns so build-time tokenization and runtime resolution continue to work.
React usage in this repository is special because React and ReactDOM are package-managed per window to support Obsidian popout windows and runtime package injection.
- It is fine to import React for types, component definitions, JSX compilation, and nearby established patterns.
- Do not assume a single global React/ReactDOM runtime is safe for rendering, root creation, or view-owned objects.
- For view-bound rendering and roots, follow
src/view/ExcalidrawView.tsand useview.packages.reactandview.packages.reactDOMthrough the package-manager flow. - For view-owned React objects such as refs or runtime-created elements, follow neighboring patterns such as
src/view/components/menu/ToolsPanel.tsxandsrc/view/components/CustomEmbeddable.tsx, which intentionally use the package-managed React instance. - Do not introduce a new direct
ReactDOM.createRoot()path outside the package-manager model unless you have verified popout-window safety. - Before adding or refactoring a React file, inspect the nearest existing file in that area and match its import/runtime pattern.
Use this routing guide before editing.
- Startup, lifecycle, readiness, plugin-wide state:
src/core/main.ts - Public/library API surface:
src/core/index.ts - Settings schema/defaults/UI:
src/core/settings.ts - Commands and command registration:
src/core/managers/CommandManager.ts - Vault or workspace event handling:
src/core/managers/EventManager.tsandsrc/core/managers/FileManager.ts - Markdown rendering or markdown embeds:
src/core/managers/MarkdownPostProcessor.ts - Package/runtime loading across windows:
src/core/managers/PackageManager.ts - Styling setup and style injection:
src/core/managers/StylesManager.ts,styles.css,src/utils/dynamicStyling.ts - Main canvas/editor behavior:
src/view/ExcalidrawView.ts - Sidepanel behavior:
src/view/sidepanel/ - Scripting and automation API:
src/shared/ExcalidrawAutomate.tsandsrc/shared/Scripts.ts - Dialogs and UI support components:
src/shared/Dialogs/ - Release notes and next-version change messages:
src/shared/Dialogs/Messages.tsandsrc/shared/Dialogs/ReleaseNotes.ts - Export/PDF/image workflows:
src/utils/exportUtils.ts,src/utils/PDFUtils.ts,src/shared/ImageCache.ts - Localization:
src/lang/locale/en.ts, then the maintained localesru.ts,es.ts,zh-cn.ts, andzh-tw.ts, then build/runtime localization helpers if needed - Build output, bundle shape, and injected payloads:
rollup.config.mjsandMathjaxToSVG/
If a task changes persisted settings, inspect all relevant pieces.
ExcalidrawSettingsinsrc/core/settings.tsDEFAULT_SETTINGSinsrc/core/settings.ts- settings UI in
src/core/settings.ts - loading and migration logic in
src/core/main.ts - any encryption or decryption logic for persisted keys
Settings changes are often incomplete if only one of these surfaces is updated.
The plugin supports Obsidian 1.8.7 while optionally using the declarative settings API introduced in Obsidian 1.13. Preserve both compatibility paths.
- Do not bump the
obsidiandependency orminAppVersionmerely to consume declarative settings. Gate the runtime path withrequireApiVersion("1.13.0")and keep conservative placeholder declarations for the newer API. getSettingDefinitions()must return the complete tree only when the runtime supports declarative settings and the restart-applied compatibility preference enables them. Returning an empty array is the intentional fallback to the legacy renderer.- The settings page model is the canonical hierarchy for declarative rendering, legacy single-page rendering, descriptions, search aliases, breadcrumbs, cross-page navigation, and Markdown export. Do not create separate setting lists or behavior implementations for the two layouts.
- Controls that change each other's options, visibility, or disabled state must share one integrated component or one shared configurator. Apply dependent state during initial render as well as after changes. Scope captured control references to one rendered definition tree; generating an export-only tree must never replace bindings used by the mounted UI.
- On Obsidian 1.13+, do not call
display()to refresh a declarative page. Update the mounted controls through their binding/configurator path or ask Obsidian to rebuild definitions only when the definition tree itself changed. - Declarative page navigation uses guarded unpublished Obsidian APIs. Prefer
openPagePath; retain the checkedfindTabById/navigateToPagefallback, derive localized paths from the canonical page model, and degrade to non-navigating text when the API is unavailable. - Route all settings writes through
PluginSettingsManagerand its serialized stable-snapshot writer. Save when values change; never make plugin shutdown or settings-tab closure the primary persistence boundary because Obsidian may not await asynchronous writes during termination. Avoid competing directsaveData()calls. - Missing and invalid
data.jsonstates are different. A first installation with no file is valid; a missing file with a recovery snapshot requires a restore-or-defaults choice; invalid startup data should restore a valid device-local snapshot or ask the user how to proceed; invalid data arriving during a running session must be rejected and repaired from the valid in-memory settings. - The last-known-good recovery snapshot is durable, device-local IndexedDB data owned by the main application window. Refresh it after every valid load or save, do not store large snapshots in
localStorage, and do not create independent recovery databases for popouts. - Preserve unknown persisted keys. Do not derive a sanitizer from the TypeScript settings interface or treat unfamiliar keys as corruption.
- Excalidraw Automate is a major public surface of this project.
src/shared/ExcalidrawAutomate.tsis large and high-impact.src/shared/Scripts.tsloads and manages vault-based scripts.- Example and user-facing docs live in
AutomateHowTo.md,docs/ExcalidrawScriptsEngine.md,docs/API/, andea-scripts/. - If you change public automation behavior, consider whether docs or the library build need updates.
Whenever a function is added to or changed in src/shared/ExcalidrawAutomate.ts, three additional files must be updated in the same change:
src/shared/Dialogs/SuggesterInfo.ts— Add or update the corresponding entry inEXCALIDRAW_AUTOMATE_INFO. Thefieldmust match the function or property name exactly. Thedescshould explain behavior clearly, including any important limitations or session-scoped constraints. Thecodeshould reflect the actual TypeScript signature.src/shared/Dialogs/Messages.ts— Document the new or changed function under the upcoming release version key. Include a brief user-facing description and the TypeScript signature in a fenced code block.src/lang/locale/en.ts(and maintained locales) — Only required if the change introduces new user-visible strings. Follow the existing locale workflow described in the User-Facing Change Workflow section.
These areas require extra care:
rollup.config.mjs: payload injection, localization, manifest/versioning, CSS bundlingsrc/core/main.ts: lifecycle order, settings migration, startup initializationsrc/view/ExcalidrawView.ts: very large, stateful, performance-sensitive, and central to user behaviorsrc/core/managers/PackageManager.ts: cross-window package loading and runtime evaluationsrc/lang/helpers.ts: build-token compatibility for compressed locales- AI/provider settings and persisted credentials handling
- PDF/export code paths and Electron/Obsidian-specific integrations
If a task touches any of the above, read adjacent code first and validate more carefully than usual.
There is no standard unit-test suite wired into package.json.
Repo-wide ESLint currently reports a large backlog of pre-existing issues, so it is not yet a blocking pass/fail gate for every task.
Primary validation commands:
npm run code
npm run buildAdditional useful commands:
npm run lib
npm run build:mathjax
npm run build:all
npm run madge
npm run docValidation guidance:
- After every code modification, run
npm run buildbefore starting the next checkpoint. Treat new build errors or warnings relative to the recorded baseline as blockers and report relevant existing warnings accurately. - Treat
eslint.config.cjsas the quality bar for all new and modified code. - Use lint results to avoid introducing new violations in touched files, even if repo-wide lint still fails because of unrelated backlog.
npm run codeis useful for visibility, but a failing repo-wide run does not by itself mean your change is invalid if the failures are pre-existing and unrelated.- Run
npm run buildfor anything that can affect bundle integrity, runtime injection, or production output. - Run
npm run libif you touch the public/library API surface. - Run
npm run build:mathjaxornpm run build:allif you editMathjaxToSVG/. - Run
npm run madgeafter structural import changes or when touching shared architecture. - Compare Madge and Rollup circular-dependency results only with their own baselines. Madge enumerates overlapping elementary paths and can include type-only imports, while Rollup reports runtime bundle cycles; their raw counts are not directly comparable.
- When the customized Excalidraw source changes, run its
yarn build:obsidian, refresh the four local package artifacts, and then run this repository's production and relevant development builds. A plugin build against the old installed artifact does not validate the component change. - For host-boundary changes, run the fork's focused adapter tests without Obsidian, then validate plugin registration and teardown through cold startup, plugin reload, the main window, a new and restored popout, and window removal. Confirm adapter methods observe settings changed after registration.
- After React/package-loading changes, validate cold startup, plugin reload, the main window, new and restored popouts, and moving a leaf between windows. Confirm that no
window.Reactorwindow.ReactDOMglobal was introduced. - After Radix/portal changes, validate visibility, positioning, stacking, click-outside, and Escape handling in both the main window and a popout; include mobile when viewport collision behavior can differ.
- Record
dist/main.jsbyte size after packaging changes and report remaining headroom under the release limit. - Prefer targeted diagnostics for the files you touched when repo-wide lint noise obscures signal.
- Prefer
npm run buildplus targeted file diagnostics over rawtsc --noEmitas the primary gate. Standalonetsccan surface large volumes of dependency-typing noise unrelated to touched files. - Do not treat
dist/output edits as source fixes.
All changes must consider the full codebase, not just the immediate file or local context.
- Before making or validating any change, agents must proactively search for all dependencies, references, and affected code across the repository. This includes:
- Searching for all usages, imports, and related patterns (e.g., property access, type assertions, function calls, etc.)
- Considering both direct and indirect consumers of the changed code or types
- Reviewing all files that may be impacted by a type, interface, or API change
- Never assume a change is local unless you have verified, by search or analysis, that no other code is affected.
- After making a change, validate the relevant build and compare errors and warnings with the recorded baseline. Touched files must not introduce new diagnostics.
- Prefer minimal, local changes when possible, but never at the expense of breaking global correctness or introducing subtle bugs elsewhere.
- Avoid reformatting large files unless necessary.
- Do not edit generated
dist/orlib/outputs by hand. - Assume undocumented behavior may still be intentional.
- For new code, follow the target naming conventions even if nearby legacy files do not yet.
- When a change looks odd, search for the constraint that explains it before removing it.
- When in doubt, preserve startup performance, popout support, and existing vault compatibility.
- Treat user requests like "tiny follow-up" or "do this consolidation" as end-to-end tasks: complete extraction, replace all known duplicates, and remove local leftovers in the same pass.
- When a type is used in more than one module, prefer a shared definition in
src/types/and import it everywhere instead of repeating local aliases. - For scope-local inline types (for example inside a processor or callback), quickly verify whether the shape already exists elsewhere before keeping or adding a local declaration.
- Example: if
RemoteDirectoryInfois used in bothsrc/utils/utils.tsandsrc/core/main.ts, define it once insrc/types/githubTypes.ts, update both imports, and delete both local aliases in the same change. - After type-only consolidations, run targeted diagnostics on touched files plus
npm run build, and confirm no runtime behavior was intentionally changed.
Summary:
Agents must always consider the full codebase impact of any change, proactively search for dependencies and affected code, and validate correctness globally—not just locally—before considering a task complete.
Replacing any types is a precision task requiring understanding of the codebase's type architecture and constraints.
When replacing any types:
- Functional equivalence is non-negotiable: Code must remain 100% functionally identical. Type changes are only for TypeScript type checking, never for runtime behavior changes.
- Never invent local types: If a type can be inferred from existing usage, Excalidraw types, or Obsidian APIs, use it. Do not create ad-hoc interface definitions.
- Do not replace with
unknownby default:unknownis stricter thananyand often requires guards/assertions elsewhere, which can break functional equivalence. - Exception for bridge casts: In rare generic conditional return scenarios where TypeScript cannot express a provably equivalent return value, a narrow
as unknown as ...bridge cast is acceptable. Keep it local, document why, and do not use it to bypass real type mismatches. - Use existing infrastructure: Extend
src/types/types.d.tsfor Obsidian unpublished APIs, create new files insrc/types/that build on existing conventions, and reference Excalidraw types directly.
Type replacements that appear to be "type-only" can introduce subtle runtime behavior changes. You must proactively detect and flag these before finalizing any change.
When scanning code that uses a value currently typed as any, watch for these patterns that can be affected by a type change:
-
Falsy/Truthy checks:
if (!value),if (value),value ? ... : ...- Risk: If the original code treats
0,"",false,null,undefined, or[]as falsy, and you infer a type that allows these values, behavior changes. - Example: Old code
if (!offset)rejectsoffset === 0. New typeoffset: numberallows0, fundamentally changing behavior for edge cases. - Action: If the code has falsy checks, document that behavior explicitly in your change notes to the user.
- Risk: If the original code treats
-
Existence checks:
if (value === undefined),if (value === null),if (value)- Risk: Changing from accepting
any(including undefined) to a narrower type may exclude valid edge cases. - Action: Verify all call sites provide values matching the new type constraint.
- Risk: Changing from accepting
-
Optional chaining with falsy fallbacks:
value?.prop ?? default,value?.prop ?? fallback- Risk: The fallback behavior may change if the inferred type rules out falsy intermediate values.
- Action: Test that fallback behavior is identical.
-
Conditional logic on properties:
if (obj.prop),switch (obj.type), loops overobj- Risk: Narrowing from
anyto a specific object shape might exclude properties the runtime code depends on. - Action: Verify the inferred type shape includes all properties actually accessed.
- Risk: Narrowing from
Before replacing an any type:
- Scan all usages of the value (especially the entire function or component containing it).
- Identify falsy/truthy checks or conditional logic that depends on the value.
- Compare semantics: Does the old behavior permit
0,"",false,null, orundefinedin a way the new type might not? - If a behavior change is introduced: Explicitly document it in a comment in the code AND flag it to the user BEFORE considering the task complete.
- Document edge cases that the type change affects.
INCORRECT (behavioral change, not flagged):
// Old: const value: any = ...; if (!value) return null;
// New: const value: number = ...; if (typeof value !== "number") return null;
// Problem: Old code rejects 0, new code accepts 0. Behavior changed silently.CORRECT (behavioral change, explicitly flagged):
// Old: const offset: any = ...; if (!offset) return null;
// New: const offset: number = ...; if (typeof offset !== "number") return null;
// Flagged: "Note: This change now allows offset=0 (previously rejected as falsy).
// This is intentional and fixes a bug where block IDs could not be inserted at file start."CORRECT (no behavioral change):
// Old: const frame: any = ...; if (frame.type === "frame" && !frame.isDeleted) ...
// New: const frame: { type: string; isDeleted?: boolean } = ...; if (frame.type === "frame" && !frame.isDeleted) ...
// OK: Inferred type supports all operations, no falsy checks introduced, behavior identical.src/types/types.d.ts: Ambient module declarations and Obsidian unpublished API types. This is the standard location for extending Obsidian's type system and for global type declarations. Use the existing patterns (interfaces extendingobsidianmodule interfaces) consistently.src/types/excalidrawLib.d.ts: Ambient declarations for the evaluatedwindow.ExcalidrawLibruntime. Derive declarations from published fork types where possible, and keep the file lowerCamelCase in line with grouped type-module naming.- Type files in subsystem directories: Files like
src/shared/ExcalidrawAutomate.tsmay carry substantial type definitions and exports alongside implementation. Do not move these without evaluating the impact on the public API surface. - Leverage existing type files: Consult
src/types/excalidrawLib.d.tsand the installed fork declarations for the current Excalidraw type model before adding new Excalidraw-derived types.
- Do not invent wrapper types for Excalidraw entities. Reference the customized
@zsviczian/excalidrawtypes directly. - Before defining a plugin-local union or interface for a fork concept, search the published fork declarations and import or alias the canonical type. Ambient runtime bridges should reuse those types rather than duplicate their structure.
- Build on existing type extensions in the codebase (e.g.,
src/types/types.d.tsmay already extend Excalidraw types). - When a type depends on Excalidraw internals, document the dependency clearly so future changes to the fork are visible.
- Obsidian unpublished API types often interact with Excalidraw components; model these intersections carefully in
src/types/types.d.ts.
To identify the correct type for an any reference:
- Scan usage: Identify all sites where the value is used. Determine what properties, methods, or operations are performed on it.
- Check existing types: Search
src/types/, the Excalidraw type definitions (via node_modules), and Obsidian API typings for matching types. - Check upstream patterns: Look at similar usage patterns elsewhere in the codebase. How are comparable values typed?
- Intersect constraints: The correct type must support all observed operations. If multiple possible types exist, choose the one that is most specific without inventing new constraints.
- Test narrowing: If the type was
any, code may not have type guards. Ensure that replacinganywith a more specific type does not require adding guards or assertions that change behavior.
- DOM elements and jQuery-like objects: Often
anywhen they should beHTMLElement,HTMLDivElement,Element, etc. Checksrc/utils/and view components for examples. - Obsidian unpublished APIs: Frequently
anybecause the official typings are incomplete. Add tosrc/types/types.d.tsto model the actual shape based on Obsidian source or runtime inspection. - Excalidraw component state and config objects: Usually
anybut should referenceExcalidrawProps,AppState, or similar exported by@zsviczian/excalidraw. - Event handler parameters and callbacks: Often
anybut should be typed based on what the callback receives. Check invocation sites. - Imported worker or third-party runtime objects: May be
anyif the package lacks types. Create a minimal type stub insrc/types/if needed, or useas constto infer from a known shape. - Scene boundary typing (
getScene()vs persistence sync): Scene producers may return readonly/non-deleted element arrays while persistence code mutates scene internals. Prefer broader input types at boundary methods (e.g., sync/update entry points), then narrow/cast internally where mutation is required. - AppState strict vs partial contracts: Many helpers accept
AppStatewhile repository data paths often carryPartial<AppState>. Prefer widening helper signatures only when behavior is unchanged and call sites are truly partial; otherwise use narrow local assertions at call sites instead of reshaping runtime objects. - Legacy appState compatibility keys: Preserve compatibility for legacy keys used during migrations or cross-version loads (e.g.,
currentItemLinearStrokeSharpness,currentItemStrokeSharpness) when tightening types.
When documenting an unpublished Obsidian API in src/types/types.d.ts:
- Use module declaration patterns already in the file (
declare module "obsidian" { interface App { ... } }). - Include a brief comment explaining the API or linking to the Obsidian source, if known.
- Only add properties and methods that are actually used in the codebase. Avoid speculative extensions.
- Be conservative: unpublished APIs can change; document the version or observation date if possible.
- Do not duplicate types; if Obsidian's types already define something, extend or refine, not redefine.
When replacing any:
- No new runtime errors: Run the plugin in Obsidian after the change. Verify that all observed functionality works identically.
- TypeScript checking: The change should reduce or eliminate TypeScript errors, not introduce new ones.
- No new guards or assertions: If code previously worked with
any, replacing it with a specific type should not require newifchecks,ascasts, or optional chaining that wasn't there before. If it does, the type choice is too strict. - Side-effect parity check: When replacing index-based mutation code with object transforms (e.g., sanitize/copy patterns), explicitly verify whether the original code intentionally mutated shared objects. Preserve side effects unless a behavior change is explicitly approved.
- Build passes:
npm run buildmust succeed. Type changes can affect bundle outcome if they affect build-time inference. - Lint cleanliness: The changed file should not gain new lint violations. Use
npm run code -- src/path/to/file.tsto check the specific file.
In some cases, @typescript-eslint/no-explicit-any or @typescript-eslint/no-unnecessary-type-assertion warnings are justified and reflect legitimate constraints rather than type-safety failures. When suppressing these rules, follow strict guidelines:
-
Provider-specific dynamic payloads: AI providers, image APIs, and other external services return schemas that vary by provider. Normalizing these requires accepting
anyproperties or using type assertions on theitemparameter to access provider-specific fields.- Example:
(item: Record<string, any>) => item.image?.url || item.image?.b64_jsonnormalizes images from different providers into a common schema.
- Example:
-
Mutation-path type casts: When updating scene elements or bound references, Excalidraw type definitions may return readonly or union types, but the mutation path requires the mutable variant. The assertion is necessary and doesn't bypass a real type mismatch.
- Example:
sceneElements.find(...) as unknown as Mutable<ExcalidrawElement>during ID migration where the lookup guarantees the mutable variant exists.
- Example:
-
Legacy or compatibility code: When bridging serialized data, migrations, or undocumented Obsidian APIs where the runtime shape is known but the type system cannot express it without inventing local stubs.
Always use eslint-disable-next-line (not file-wide disables) and include a comment explaining:
- Which rule is being suppressed and why
- What constraint makes the suppression necessary (provider variability, mutation path, etc.)
- Why a stricter type is not feasible without breaking functional equivalence
Format:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- <rule name>: <1-2 sentence explanation of the constraint>Examples:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- image provider payload schemas vary and are normalized in this function.
const normalizedData = rawItems.flatMap((item: Record<string, any>) => {
// ... normalize item.url, item.b64_json, item.image.url, etc.
});// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- scene lookup returns union; mutation path requires mutable element.
const containerEl = sceneElements.find(
(el) => el.id === textElement.containerId,
) as unknown as Mutable<ExcalidrawElement>;- Lazy typing: Do not suppress
no-explicit-anyto avoid refactoring a complex function. Fix the type instead. - Overly broad assertions: Do not use
as anyoras unknown as anyto bypass unrelated type mismatches. - Undocumented suppressions: Every suppression must include a clear comment. Suppressions without explanation are a code review red flag.
- If a set of related types will be used across multiple modules and are not Obsidian or Excalidraw types, create a new file in
src/types/. - Name it to reflect its domain (e.g.,
canvasTypes.ts,aiProviderTypes.ts). - Document the file's purpose and scope at the top with a brief module-level comment.
- Export types, not implementation. Do not put logic in type files.
- If the file re-exports types from Excalidraw or elsewhere, document the origin.
When you are asked to modify this repository:
- assume the current behavior exists for a reason
- favor root-cause fixes over superficial patches
- keep scope tight
- maintain backwards compatibility unless the task explicitly authorizes a breaking change
- validate with build plus lint-aware checks before declaring success
- do not introduce new lint violations in touched code, even if the full repo lint command still fails
- document user-visible changes in
src/shared/Dialogs/Messages.ts - add new language keys in
src/lang/locale/en.tsand updateru.ts,es.ts,zh-cn.ts, andzh-tw.tsin the same change - be especially careful around startup, build plumbing, localization tokenization, settings migration, and popout-window behavior
- All types and helpers for Excalidraw element
customData(extensible metadata on elements, e.g., for LaTeX, PDF, image, or plugin-specific keys) are centralized insrc/utils/elementCustomDataUtils.ts. - If you need to add a new
customDatakey, type, or helper, always add it to this file and import from here in all consumers. This avoids type duplication and ensures discoverability for future maintainers and agents. - See
ExcalidrawCustomData,ExcalidrawCustomDataPatch,ExcalidrawPDFCustomData,ExcalidrawLatexCustomData, andaddAppendUpdateCustomDatain that file for canonical patterns.