Status: active plan for the 2.27.0 refactor, last updated 2026-08-09
This document is the working plan for reducing the size and coupling of
src/core/main.ts and src/view/ExcalidrawView.ts without destabilizing the
Obsidian integration. It is intentionally incremental. Each implementation
step should be small enough to review, build, and manually validate in
isolation.
Release target: 2.27.0.
Update this document in every refactor change. A change is not complete until its phase status and the action log reflect what was implemented, what was validated, and what remains uncertain.
| Phase | Status | Current outcome |
|---|---|---|
| Assessment and baseline design | Complete | Initial architecture, risks, sequencing, and validation matrix documented |
| Retire legacy AI settings and fallbacks | Complete | Removed the retired migration, schema/default fields, GPT reset, and AI runtime fallbacks without filtering unknown persisted keys; manual testing found no issues |
| Extract settings implementation service | Implemented; awaiting manual validation | PluginSettingsManager now owns persistence, default assembly, remaining migrations, and API-key obfuscation; plugin methods and startup readiness remain intact, while temporary autosave enablement is now explicit plugin-instance state rather than persisted settings state |
| Extract footer safe-area styling | Implemented; awaiting manual validation | FooterSafeAreaManager now owns device-specific stylesheet injection, open-document traversal, and unload cleanup; the plugin method remains as a settings-UI compatibility delegate |
| Extract font management | Implemented; awaiting manual validation | FontManager now owns CJK discovery/loading, custom-font registration, document stylesheets, readiness, and cleanup; plugin methods and the externally read fourthFontLoaded field remain intact |
| Extract startup instrumentation | Implemented; awaiting manual validation | StartupTimer now owns startup event history, delta tracking, and breakdown formatting; lifecycle calls, public methods, and the public loadTimestamp field remain intact |
Remove confirmed dead main.ts code |
Implemented; awaiting manual validation | Removed the uncalled cache-registration method and the never-assigned duplicate file-explorer observer field/cleanup; the active observer in ObserverManager remains unchanged |
| Extract view export pipeline | Implemented; awaiting manual validation | ViewExportManager now owns raw scene, SVG, PNG, clipboard, PDF, option-resolution, and alternate-theme embedded-file export behavior; all view methods and exportDialog compatibility remain intact |
Remove inert ExcalidrawView fragments |
Complete | Removed obsolete commented declarations, experiments, debug calls, and alternative implementations; no active code or workaround documentation changed |
| Extract view fullscreen handling | Implemented; awaiting manual validation | ViewFullscreenManager now owns the existing document-scoped fullscreen class and style mutations while ExcalidrawView retains its public compatibility methods |
| Extract view link navigation | Implemented; awaiting manual validation | ViewLinkNavigationManager now owns element-link resolution, hook dispatch, link prompting, modifier-aware navigation, and special image-link handling while all view methods remain compatibility delegates |
| Retire obsolete Draw.io integration | Implemented; awaiting manual validation | Removed special Diagram-plugin routing and retired the Create DrawIO file script from the maintained library and generated reference catalogs; user-installed vault scripts remain untouched |
| Extract Excalidraw extension rendering | Implemented; awaiting manual validation | ViewExcalidrawExtensionRenderer now owns text-to-diagram, diagram-to-code, welcome screen, custom main menu, and embeddable rendering while the view retains its existing render delegates and package-managed React runtime |
| Audit production bundle size | Complete | Ranked packaging, dependency, dead-data, and static-payload reductions; translation extraction remains a last resort |
| Use inflate-only Pako runtime | Complete | Replaced the full Pako distribution with its API-compatible inflate-only build; all existing Excalidraw and locale payloads retain their format and decompression path |
| Replace bundled YAML runtime | Implemented; awaiting manual validation | mergeMarkdownFiles() now uses Obsidian's public YAML APIs, and yaml is no longer a direct production dependency |
| Compress per-window React payload | Complete | React, ReactDOM, and the JSX shim are inflated before React participates in plugin bootstrap; the decompressed source remains available for popout-window package creation, and manual testing found no startup or runtime regression |
| Replace bundled OpenType metric reader | Complete | A bounds-checked SFNT reader now extracts only the TTF/OTF metrics consumed by Excalidraw; WOFF/WOFF2 fallback behavior is unchanged, opentype.js is no longer bundled, and manual testing found no font regression |
| Audit and consolidate duplicate logic | In progress | Consolidated updateFrontmatterInString(), arrayToMap(), wrapTextAtCharLength(), getLinkParts()/LinkParts, getBinaryFileFromDataURL(), svgToBase64(), getFontDataURL(), cropCanvas(), getImageSize(), promiseTry(), isVersionNewerThanOther(), repositionElementsToCursor(), the internal cloneElement(), and getBoundTextElementId(); continue one independently testable helper family at a time |
| Remaining view phases | In progress | Manually validate the extension renderer checkpoint before choosing between the higher-risk scene-file loader and a mechanical package-aware React-root extraction |
| Date | Action | Outcome | Validation |
|---|---|---|---|
| 2026-08-08 | Reviewed AGENTS.md, CONTRIBUTING.md, plugin lifecycle, view lifecycle, package loading, rendering, and Rollup packaging |
Established the hybrid Obsidian-host/React-child target and incremental implementation sequence | npm run build passed; existing circular dependency warnings recorded |
| 2026-08-08 | Refined scope for 2.27.0 | Legacy AI migration will be retired; generic top-level settings normalization, ExcalidrawAutomate compatibility, duplicate-logic review, and TSDoc are now explicit requirements | Settings and utility reference searches completed; documentation-only build passed |
| 2026-08-08 | Assessed automatic supported-key settings sanitization | Confirmed that removing stripLegacyAISettings() alone would re-persist legacy defaults; a shallow replacement is viable only with a cleaned runtime schema and forward-version protection |
TypeScript AST check confirmed 204 interface keys and 204 matching DEFAULT_SETTINGS keys; load/save, migration, encryption, and dynamic-settings writes reviewed |
| 2026-08-08 | Rejected automatic settings sanitization | Because supported settings change frequently, unknown-key removal creates unacceptable forward-version, downgrade, and mixed-device sync risk. Phase 1 is now limited to removing confirmed legacy AI code and fallback fields | Plan updated; no runtime code changed |
| 2026-08-08 | Completed Phase 1 legacy AI retirement | Removed the legacy migration and stripping helpers from main.ts, retired obsolete settings/default fields and the GPT one-off reset, and removed all legacy fallback reads from AIUtils.ts. Current provider profiles, model maps, multimodal default, and token settings remain intact. Unknown persisted keys are not sanitized and may round-trip inertly |
Repository-wide residue search found no legacy AI runtime references; ExcalidrawSettings and DEFAULT_SETTINGS each contain the same 183 keys; production build passed after every code slice with the existing 34 circular dependency warnings and no new warnings; dist/main.js is 5,097,728 bytes (8,657 bytes smaller than baseline) |
| 2026-08-08 | Closed the Phase 1 validation checkpoint | Manual testing of the legacy AI retirement found no regressions | User confirmed testing completed with no issues |
| 2026-08-08 | Consolidated updateFrontmatterInString() |
Kept the used implementation in sceneDataUtils.ts as canonical, added TSDoc describing its intentionally string-based behavior, and removed the identical unreferenced implementation from utils.ts |
Repository search confirms one definition and one consumer import; npm run build passed with the existing 34 circular dependency warnings; bundle size remains 5,097,728 bytes; targeted ESLint reports the existing backlog but no diagnostics on changed lines; git diff --check passed |
| 2026-08-08 | Consolidated arrayToMap() |
Extracted the identical implementations to side-effect-free collectionUtils.ts with TSDoc, then re-exported the same binding from utils.ts and sceneDataUtils.ts so all existing import paths and callers remain intact. Directly importing sceneDataUtils.ts from utils.ts was rejected because it would introduce a cycle through fileUtils, main, and utils |
Repository search confirms one implementation; npm run build passed with the same 34 circular dependency warnings; bundle size is 5,097,611 bytes, 117 bytes smaller than the preceding step; the new module passes targeted ESLint; npm run madge remains unavailable because madge is not installed; git diff --check passed |
| 2026-08-08 | Consolidated wrapTextAtCharLength() |
Extracted the identical pure wrapping logic to documented textUtils.ts and re-exported it from both existing modules, preserving ExcalidrawData and ExcalidrawAutomate imports and behavior |
Repository search confirms one implementation; targeted ESLint and npm run build passed; the existing 34 circular dependency warnings are unchanged; bundle size decreased by 623 bytes to 5,096,988 bytes |
| 2026-08-08 | Consolidated getLinkParts() and LinkParts |
Moved the duplicated parser and type to documented linkUtils.ts, preserving both existing value and type import paths through re-exports |
Repository search confirms one function and one type definition; targeted ESLint and npm run build passed; the existing 34 circular dependency warnings are unchanged; bundle size decreased by 395 bytes to 5,096,593 bytes |
| 2026-08-08 | Consolidated getBinaryFileFromDataURL() |
Moved the duplicated URL-fetch/base64 decoding helper into fileUtils.ts, its existing dependency owner, and preserved both old import paths through re-exports. Replaced the loosely typed first matchAll() result with equivalent typed match() capture handling |
Repository search confirms one implementation; targeted ESLint, npm run build, npm run lib, and git diff --check passed; the existing 34 circular dependency warnings are unchanged; bundle size decreased by 405 bytes to 5,096,188 bytes |
| 2026-08-08 | Closed the text, link, and binary helper validation checkpoint | Manual testing found no regressions in wrapping, link parsing, or binary image handling | User confirmed the recommended checks completed successfully |
| 2026-08-08 | Consolidated svgToBase64() |
Kept embeddedAssetUtils.ts as the canonical owner, added TSDoc for XML entity and UTF-8 handling, and preserved the utils.ts import surface through a re-export. Added explicit regex callback parameter types without changing encoding behavior |
Repository search confirms one implementation; npm run build passed with the unchanged 34 circular dependency warnings; bundle size decreased by 206 bytes to 5,095,988 bytes; targeted ESLint has no diagnostics on the consolidated helper and retains two unrelated backlog errors in embeddedAssetUtils.ts; git diff --check passed |
| 2026-08-08 | Consolidated getFontDataURL() |
Kept embeddedAssetUtils.ts as the canonical owner, added TSDoc for vault resolution, extension mapping, and empty-result behavior, and preserved the utils.ts export used by plugin startup |
Repository search confirms one implementation; npm run build passed with the unchanged 34 circular dependency warnings; bundle size decreased by 609 bytes to 5,095,379 bytes; targeted ESLint has no diagnostics on the consolidated helper and retains the same two unrelated backlog errors in embeddedAssetUtils.ts; git diff --check passed |
| 2026-08-08 | Closed the font helper validation checkpoint | Manual testing found no regressions in vault font loading | User confirmed getFontDataURL() works |
| 2026-08-08 | Consolidated cropCanvas() |
Kept embeddedAssetUtils.ts as the canonical owner, added TSDoc for source rectangles, output scaling, and intentionally unchanged browser canvas behavior, and preserved the utils.ts export |
Repository search confirms one implementation; npm run build passed with the unchanged 34 circular dependency warnings; bundle size remains 5,095,379 bytes because Rollup already removed the unused duplicate; targeted ESLint has no diagnostics on the consolidated helper and retains the same two unrelated backlog errors in embeddedAssetUtils.ts; git diff --check passed |
| 2026-08-08 | Consolidated getImageSize() and promiseTry() |
Kept the identical embedded-asset implementations as canonical, documented their native image-loading and promise-boundary semantics, and preserved the utils.ts import surface through re-exports. The former getImageSize() differed only in bracing and comments; both promiseTry() copies were textually identical. No caller used distinct behavior |
Repository search confirms one implementation of each helper; npm run build passed after each code change; targeted ESLint passed |
| 2026-08-08 | Consolidated isVersionNewerThanOther() |
Extracted the identical implementations to dependency-light versionUtils.ts and preserved both utils.ts and sceneDataUtils.ts import paths. Missing, malformed, prerelease, and numeric comparison behavior remains unchanged |
Repository search confirms one implementation; npm run build and targeted ESLint passed |
| 2026-08-08 | Consolidated element positioning, cloning, and bound-text helpers | Extracted estimateBounds(), repositionElementsToCursor(), the internal ID-preserving cloneElement(), and getBoundTextElementId() to documented excalidrawElementUtils.ts, preserving exports from both former owner modules. Repositioning and cloning were identical. The only bound-text difference was a redundant optional chain after an equivalent non-null/length guard, so callers could not observe it. The public ExcalidrawAutomate.cloneElement() API, which assigns a new ID, was not changed |
Repository search confirms one implementation of each helper; targeted ESLint, npm run build, npm run lib, and git diff --check passed; bundle size remains 5,095,379 bytes because Rollup had already eliminated the duplicate paths; npm run madge could not run because madge is not installed |
| 2026-08-08 | Extracted plugin settings implementation from main.ts |
Added documented PluginSettingsManager with a narrow host contract to own load/save, default assembly, existing library/Markdown-image/oEmbed/preview migrations, and API-key obfuscation. At this extraction checkpoint, retained plugin.settings, all public plugin method signatures, startup autosave behavior, settings readiness, settings-tab timing, external-change library invalidation, persisted format, and unknown-key behavior. After delegate TSDoc, main.ts decreased from 1,676 to 1,563 lines |
npm run build passed after each code change; npm run lib and a scoped git diff --check passed; the new manager and changed delegate lines have no ESLint diagnostics, while targeted lint still reports four unrelated existing any diagnostics in the startup-script block of main.ts; the initial extraction build was 5,094,122 bytes, 1,257 bytes below the preceding checkpoint. A concurrent unrelated nanoid dependency update changed the final workspace build to 5,094,522 bytes; npm run madge could not run because madge is not installed; manual settings validation remains pending |
| 2026-08-09 | Reviewed and retained reEnableAutosave |
An attempted removal was reversed after confirming that this is not migration code: it resets the session-scoped temporary disable/enable autosave commands when the plugin starts. Restored LoadSettingsOptions, startup { reEnableAutosave: true }, and the post-load in-memory assignment exactly as extracted. No autosave behavior change remains |
Repository search confirms the complete option flow is restored; npm run build, npm run lib, targeted manager ESLint, and scoped git diff --check passed; the existing 34 circular dependency warnings are unchanged; main.ts is again 1,563 lines and the bundle is again 5,094,522 bytes; manual settings validation remains pending |
| 2026-08-09 | Moved temporary autosave enablement out of persisted settings | A full runtime and history review confirmed that settings.autosave had no settings UI and served only as the global gate for the temporary enable/disable commands. Replaced it with documented plugin-instance state initialized to enabled, updated commands and view scheduling to use that state, removed autosave from ExcalidrawSettings and DEFAULT_SETTINGS, and removed LoadSettingsOptions/reEnableAutosave. Desktop and mobile interval settings remain unchanged. Existing persisted autosave keys are intentionally left inert under the no-sanitizer policy |
Repository search confirms there are no remaining supported-setting or runtime references to settings.autosave; the settings interface and defaults contain the same 182 keys; npm run build, npm run lib, and scoped git diff --check passed with the existing 34 circular dependency warnings. Broad lint reports only the existing backlog and no diagnostics on changed lines; bundle size decreased by 151 bytes to 5,094,371 bytes; manual session-command validation remains pending |
| 2026-08-09 | Extracted footer safe-area styling from main.ts |
Added documented FooterSafeAreaManager with a narrow host contract to own phone/tablet CSS injection, exact device-aware open-document traversal, setting-driven removal, and unload cleanup. Kept plugin.updateFooterSafeAreaPadding() and its layout-ready and settings UI call sites unchanged as delegates. Font document traversal remains in main.ts until the separate font extraction |
npm run build, targeted manager ESLint, and scoped git diff --check passed; main.ts decreased by 46 lines from 1,558 to 1,512; bundle size increased by 99 bytes to 5,094,470 bytes from manager/delegate overhead; npm run madge could not run because madge is not installed; phone/tablet manual validation remains pending |
| 2026-08-09 | Extracted font management from main.ts |
Added documented FontManager to own the existing CJK asset cache, vault reads, CJK/custom stylesheet lifecycle, custom font metrics and package registration, readiness state, and device-aware document traversal. Preserved every plugin-facing font method as a delegate, retained plugin.fourthFontLoaded for view compatibility, kept the initial readiness value and 100ms timer unchanged, and injected lazy package-map access without changing PackageManager construction or ownership |
npm run build, npm run lib, targeted manager ESLint, and scoped git diff --check passed; main.ts decreased by 130 lines from 1,512 to 1,382; bundle size increased by 379 bytes to 5,094,849 bytes from manager/facade overhead; npm run madge could not run because madge is not installed; desktop, mobile, CJK, custom-font, and popout manual validation remains pending |
| 2026-08-09 | Extracted startup timing instrumentation from main.ts |
Added documented StartupTimer to own the private event list, previous-event timestamp, total/delta formatting, and debug output. Kept every timing call in its original lifecycle position, retained logStartupEvent() and the misspelled printStarupBreakdown() as plugin delegates, preserved pre-layout events across the layout-ready baseline reset, and retained loadTimestamp as an own public field with unchanged assignment behavior |
npm run build, npm run lib, targeted manager ESLint, and scoped git diff --check passed; main.ts decreased by 6 lines from 1,382 to 1,376; the latest bundle is 5,095,747 bytes, 898 bytes above the preceding checkpoint due to the manager and compatibility facade; npm run madge could not run because madge is not installed; startup breakdown inspection remains pending |
| 2026-08-09 | Rejected a catch-all Markdown integration manager and removed proven dead code from main.ts |
Markdown post-processing, install-codeblock handling, observer setup, and rerender behavior have different lifecycle and ownership constraints, so they will remain explicit rather than being grouped under a weak abstraction. Removed the uncalled private registerEventListeners(), its MetadataCache import, the never-assigned main.ts fileExplorerObserver, and its inert unload check. The active file-explorer observer and teardown in ObserverManager were not changed |
Repository-wide reference searches confirmed both removed members had no callers or assignments and that PluginFileManager.initialize() owns the active initial cache walk; npm run build passed with the existing circular-dependency warnings; targeted lint reports only the same four pre-existing startup-script any diagnostics and none on changed lines; git diff --check passed; main.ts decreased by 29 lines from 1,376 to 1,347 and the bundle decreased by 381 bytes to 5,095,366 bytes; manual unload/reload validation remains pending |
| 2026-08-09 | Extracted the complete ExcalidrawView export pipeline |
Added documented ViewExportManager to own raw .excalidraw, SVG, PNG, clipboard, PDF, export-option resolution, autoexport file writing, and alternate-theme embedded-file loading. Retained every existing public method on ExcalidrawView as a documented delegate, kept the public exportDialog field and its persistence/teardown lifecycle on the view, and supplied existing runtime dependencies explicitly to avoid a new import cycle. Converted seven type-only imports and pointed exportUtils directly at canonical svgToBase64() to remove runtime back-edges without changing behavior |
npm run build passed after every code change; npm run lib, manager-targeted ESLint, repository reference searches, and git diff --check passed. Broader touched-file lint retains the existing backlog but reports no manager diagnostics. Circular dependency warnings decreased from 34 to 33; ExcalidrawView.ts decreased by 365 lines from 9,080 to 8,715; the bundle increased by 2,177 bytes to 5,097,543 bytes and remains below 5 MiB; manual export validation remains pending |
| 2026-08-09 | Removed inert commented-out fragments from ExcalidrawView |
Deleted the obsolete excalidrawRef declaration, disabled dimensions state/effect experiment and matching width/height props, old tooltip and paste alternatives, commented debug logging, and retired setLocalFont() calls. Retained comments that explain active semaphore, timer, observer, and sizing workarounds |
npm run build passed with the same 33 circular-dependency warnings; residue search found none of the targeted fragments; git diff --check passed; ExcalidrawView.ts decreased by 46 lines from 8,715 to 8,669; bundle size is unchanged at 5,097,543 bytes because comments were not emitted; no runtime testing is required beyond the export-manager checkpoint |
| 2026-08-09 | Extracted fullscreen DOM coordination from ExcalidrawView |
Added documented ViewFullscreenManager with a narrow structural host to own the existing enter, state-check, and exit algorithms. Preserved the public gotoFullscreen(), isFullscreen(), and exitFullscreen() methods as delegates, the owner-document scoping needed by popout windows, tools-panel state updates, leaf-change timeout cancellation, CSS class names, selectors, and mobile layout restoration |
npm run build, npm run lib, manager-targeted ESLint, repository call-site searches, and git diff --check passed. The same 33 circular-dependency warnings remain; ExcalidrawView.ts decreased by 105 lines from 8,669 to 8,564; the manager contains 166 documented lines; bundle size increased by 828 bytes to 5,098,371 bytes and remains below 5 MiB. Manual main-window, popout, mobile, and teardown validation remains pending, with cross-document DOM restoration the highest-risk area |
| 2026-08-09 | Extracted link navigation from ExcalidrawView |
Added documented ViewLinkNavigationManager to own tooltip removal, link-source resolution, public hook dispatch, modifier handling, link prompting, new-file behavior, embedded and Markdown image links, LaTeX/Mermaid/Draw.io branches, fullscreen exit, and pane navigation. Retained removeLinkTooltip(), handleLinkHookCall(), the private selection resolver, processLinkText(), linkClick(), and handleLinkClick() on the view as delegates so every existing caller and hook surface remains intact. Supplied cyclic runtime dependencies and four private view operations explicitly; the manager imports the concrete view only as a type. Reused canonical SelectedElementWithLink and SelectedImage interfaces from excalidrawViewTypes.ts and removed their local duplicates |
Final npm run build, npm run lib, manager-targeted ESLint, repository call-site searches, import-graph analysis, and git diff --check passed. The build retained the 33-warning circular-dependency baseline; static analysis found zero runtime import paths from the manager back to ExcalidrawView. The full-view lint retains its pre-existing type backlog but no new unused import. ExcalidrawView.ts decreased by 442 lines from 8,564 to 8,122; the manager contains 649 documented lines; bundle size increased by 2,478 bytes to 5,100,849 bytes and remains below 5 MiB. Manual link-routing validation remains pending, with modifier semantics and special image-link branches the highest-risk areas |
| 2026-08-09 | Retired Draw.io/Diagram plugin customization | Removed detection of drawio-obsidian, Draw.io SVG inspection, and routing to the external diagram-edit view from link navigation. Deleted the Create DrawIO file Automate script and icon from the maintained script library, its directory metadata and install catalog entries, and its generated script-library and AI-reference copies. Added a 2.27.0 release note. Historical release notes remain unchanged as an accurate record, and existing script files in user vaults are intentionally not deleted |
npm run build, npm run lib, manager-targeted ESLint, JSON parsing, repository residue searches, and git diff --check passed. Runtime and maintained catalog searches find no Draw.io plugin IDs, view types, or SVG markers; only the new retirement note, this action history, and historical release notes retain the name. Circular-dependency warnings remain at 33; ViewLinkNavigationManager.ts decreased by 25 lines from 649 to 624; the final bundle is 5,100,535 bytes, 314 bytes below the preceding checkpoint and still below 5 MiB. Manual validation should confirm ordinary SVG links now follow normal Obsidian navigation and that the script no longer appears in the downloadable catalog |
| 2026-08-09 | Extracted Excalidraw extension rendering from ExcalidrawView |
Added documented ViewExcalidrawExtensionRenderer to own ttdDialog(), diagramToCode(), ttdDialogTrigger(), renderWelcomeScreen(), renderCustomActionsMenu(), and renderEmbeddable(). Kept the six private view methods and all Excalidraw root call sites as delegates. The renderer uses view.packages.react and view.packages.excalidrawLib; back-edge-prone runtime modules and private dialog actions are constructor-injected, and the concrete view import is type-only. renderEmbeddableMenu(), renderToolsPanel(), and renderTopRightUI() remain in the view because they own refs, live menu instances, or view lifecycle state |
Production builds passed after every code edit, npm run lib passed, renderer-targeted ESLint and git diff --check passed, and full-view lint showed only the established type backlog after extraction-specific unused imports were removed. The build retained the 33-warning circular-dependency baseline. ExcalidrawView.ts decreased by 358 lines from 8,122 to 7,764; the renderer contains 479 documented lines; the bundle increased by 1,746 bytes to 5,102,281 bytes and remains below 5 MiB. Manual testing should prioritize a popout window, then the main-window welcome/menu/embeddable paths, then ExcaliAI text-to-diagram and diagram-to-code; repeat core rendering on mobile, with popout React isolation the highest-risk regression |
| 2026-08-09 | Audited production main.js size |
The fresh 5,103,913-byte bundle has 138,967 bytes of headroom below 5 MiB. The first recommended batch is packaging-only: use Pako's inflate-only build (about 25 KB gross saving) and store the per-window React payload deflated (about 78 KB gross saving). The next source-level candidate is replacing the bundled yaml parser used only by mergeMarkdownFiles() with Obsidian's external parseYaml()/stringifyYaml() APIs after compatibility tests. Larger later candidates are a focused TTF/OTF metadata reader instead of opentype.js, build-time compaction of CJK metadata and static help/startup payloads, and pruning release-note entries that the current ten-item display cap makes unreachable. Translation extraction and changes to the embedded Excalidraw runtime remain last-resort work |
Generated a Rollup module-composition report, measured injected payloads and standalone dependency costs, searched all imports/callers, ran npm run code:unused with no unused-variable findings, verified that only pako.inflate() is called and that pako_inflate.min.js decodes the current payload format, and confirmed that 13 of 23 release-note entries are unreachable under the current .slice(0, 10) behavior. The documentation-only production build passed with the existing 33 circular-dependency warnings; no runtime source was changed |
| 2026-08-09 | Replaced full Pako with its inflate-only distribution | Changed only the Rollup build input from pako.min.js to pako_inflate.min.js. The existing CommonJS wrapper, pako.inflate() call, global unpackBase64Deflate() compatibility surface, compressed payload format, and per-window package architecture remain unchanged. Obsidian YAML replacement is the next checkpoint. React compression is deferred because a previous attempt prevented main.js from completing bootstrap before the inflater could run |
npm run build passed with the existing 33 circular-dependency warnings; node --check dist/main.js passed; the inflate-only and full builds produced byte-for-byte identical output for all five emitted Excalidraw and locale payloads; git diff --check passed. main.js decreased exactly 25,380 bytes, from 5,103,913 to 5,078,533 bytes, leaving 164,347 bytes below 5 MiB. Manual validation should prioritize cold startup and locale switching on mobile, then a desktop popout; a decompression failure during initialization is the highest-impact risk |
| 2026-08-09 | Replaced the bundled YAML runtime with Obsidian's public YAML APIs | Updated the sole runtime consumer, mergeMarkdownFiles(), to use parseYaml() and stringifyYaml() from obsidian, added TSDoc for its precedence and array-merge contract, and removed yaml as a direct production dependency. Existing target frontmatter remains text-preserved. Obsidian serialization may represent nulls as empty values and keep long scalars on one line; these forms parse to the same values as the previous output |
Repository search confirms no source imports from yaml; targeted ESLint and npm run lib passed; production builds before and after dependency cleanup passed with the existing 33 circular-dependency warnings; the CRLF-aware whitespace check passed. main.js decreased 104,707 bytes, from 5,078,533 to 4,973,826 bytes, leaving 269,054 bytes below 5 MiB. Manual validation should prioritize template/target array merging and missing keys through Convert note to Excalidraw and ExcalidrawAutomate.create(), then null, long-text, date, alias, multiline, quoted-value, desktop, and mobile cases |
| 2026-08-09 | Compressed the per-window React runtime payload | Deflated the minified React, ReactDOM, and JSX-shim source at build time. In the emitted bootstrap, inflate-only Pako and the dependency-free unpackBase64Deflate() helper are initialized first; only then is REACT_PACKAGES inflated and evaluated. The decompressed string remains alive for PackageManager.getPackage() to evaluate in each popout window. Removed the now-unused direct jsesc development dependency. This ordering specifically addresses the prior attempt that failed before React decompression could run |
npm run build passed with the existing 33 circular-dependency warnings; node --check dist/main.js passed; emitted-order inspection confirms the inflater precedes React decompression; an isolated bootstrap smoke test initialized React 18.3.1 and ReactDOM, then successfully evaluated the retained package source a second time to simulate a popout. main.js decreased 78,540 bytes, from 4,973,826 to 4,895,286 bytes, leaving 347,594 bytes below 5 MiB. Manual validation is mandatory because the historical failure occurred during Obsidian startup: test desktop and mobile cold starts first, then main-window rendering, a new and restored popout, moving a leaf between windows, plugin disable/re-enable, and the extension-renderer paths |
| 2026-08-09 | Closed the compressed React validation checkpoint | Manual testing found no startup or runtime regressions with the bootstrap-safe compressed React payload | User confirmed the implementation works; the checkpoint is ready to commit |
| 2026-08-09 | Replaced opentype.js with a focused SFNT metric reader |
Added documented, bounds-checked parsing of head.unitsPerEm, hhea.ascender, and hhea.descender, preserving exact raw values and the established line-height calculation. The already-read vault buffer is reused instead of decoding the generated data URL. Glyph outlines, shaping, rendering, and embedding remain owned by the browser and Excalidraw. Removed unused font-family-name parsing plus the opentype.js runtime and type dependencies. WOFF/WOFF2 continue to use the same fallback metrics as before |
The new reader produced exact metric parity with opentype.js across all 21 installed TTF/OTF fixtures, including a CFF OTF, and safely rejected three malformed/truncated fixtures. Repository search found no remaining runtime dependency references. Targeted ESLint passed for the new reader and FontManager; node --check dist/main.js and production builds passed with the existing 33 circular-dependency warnings; npm run madge remains unavailable because madge is not installed. main.js decreased 186,132 bytes, from 4,895,286 to 4,709,154 bytes, leaving 533,726 bytes below 5 MiB. Manual validation should first compare existing and newly created local-font text using representative TTF and OTF files on desktop, including wrapping, baselines, bound text, reload, SVG/PNG/PDF export, and a popout; then repeat core text creation/reload on mobile and smoke-test WOFF/WOFF2. The highest-risk regression is different layout for an unusual TTF/OTF whose metrics fall back because its table structure is malformed or unsupported |
| 2026-08-09 | Closed the focused SFNT reader validation checkpoint | Manual testing found no regressions in local-font loading or behavior after removing opentype.js |
User confirmed the implementation works well; the checkpoint is ready to commit |
Do not convert ExcalidrawView wholesale into a React component.
ExcalidrawView must remain an Obsidian TextFileView because Obsidian owns
its file/view lifecycle, leaf identity, serialization hooks, window migration,
and teardown. React is a child runtime mounted inside that host. The viable
target is therefore a hybrid:
- Keep
ExcalidrawViewas a thin Obsidian adapter and stable compatibility facade. - Move cohesive, non-rendering behavior into view-scoped controllers under
src/view/managers/. - Move the current
excalidrawRootElement()function component and its render wiring into a package-aware component module. - Use React state only for state that determines rendered UI.
- Move save/reload/unload coordination toward explicit non-React coordinators. Do not replace these flags with React state.
Two compatibility requirements apply to every phase:
- The ExcalidrawAutomate API, hook behavior, defaults, signatures, and its
access to
ExcalidrawViewmust remain intact for scripts and companion plugins. - Extracted modules, exported functions, public methods, compatibility shims, and non-obvious lifecycle code must receive high-signal TSDoc. TSDoc is the TypeScript equivalent intended by “JavaDoc-like documentation” in this plan.
For main.ts, keep the lifecycle ordering visible in the plugin class while
moving implementation details into focused managers. A short explicit startup
sequence is safer here than a generic dependency-injection or plugin framework.
At the time of this assessment:
| File | Lines | Primary responsibilities currently mixed together |
|---|---|---|
src/core/main.ts |
2,102 | Entry point, settings migration and persistence, startup choreography, teardown, fonts, styles, post processors, script startup, compatibility facades |
src/view/ExcalidrawView.ts |
9,080 | Obsidian view lifecycle, persistence, sync, scene loading, exports, link handling, input handling, embeddables, menus, React root, Excalidraw callbacks |
src/core/managers/PackageManager.ts |
235 | Per-window React, ReactDOM, and Excalidraw runtime creation and cleanup |
rollup.config.mjs |
294 | Single-bundle construction, runtime injection, compression, CSS, locales, and build-time compatibility workarounds |
A repository search finds approximately 62 source modules importing the
concrete plugin class and 46 importing ExcalidrawView. These counts do not
include unknown user scripts or companion plugins. Both classes are therefore
compatibility surfaces even where TypeScript marks a member as an internal
implementation detail.
The production dist/main.js built during this assessment is 5,106,385 bytes.
That is only 136,495 bytes, or 2.60%, below a 5 MiB limit. Bundle size
must be checked after every refactor. Source-file splitting will improve source
architecture but will not create runtime chunks because Rollup uses
inlineDynamicImports: true and deliberately emits one CommonJS main.js.
After the view extractions, a fresh production bundle is 5,103,913 bytes. The 5 MiB ceiling is 5,242,880 bytes, leaving 138,967 bytes (about 136 KiB or 2.65%) of headroom. The large injected sections explain why ordinary source cleanup has limited impact:
The first implemented reduction, switching to inflate-only Pako, reduced the
bundle exactly 25,380 bytes to 5,078,533 bytes. Current headroom is 164,347
bytes (about 160 KiB or 3.13%). Replacing the bundled YAML runtime then reduced
the bundle another 104,707 bytes to 4,973,826 bytes. Current headroom is
269,054 bytes (about 263 KiB or 5.13%). Compressing the React package source
then reduced the bundle another 78,540 bytes to 4,895,286 bytes. Replacing
opentype.js with the focused SFNT reader reduced it another 186,132 bytes to
4,709,154 bytes. Current headroom is 533,726 bytes (about 521 KiB or 10.18%).
| Injected section | Approximate production characters | Share of main.js |
|---|---|---|
| Deflated/base64 Excalidraw runtime | 2,786,756 | 54.6% |
| Minified Rollup application bundle | 1,941,603 | 38.1% |
| Four compressed non-English locales | 188,668 | 3.7% |
| Uncompressed per-window React/ReactDOM payload | 138,628 | 2.7% |
| Inflate-only Pako runtime | 21,479 | 0.4% |
The recent structural extractions increased the application section slightly, but they did not duplicate React or Excalidraw. The best first savings are therefore packaging and dependency substitutions rather than reversing the new module boundaries.
Recommended order:
- Completed: replaced
pako.min.jswithpako_inflate.min.js. Repository-wide search found onlypako.inflate()at runtime, and the smaller distribution decodes the same zlib payloads. The source files are 46,859 and 21,479 bytes respectively; the production bundle decreased exactly 25,380 bytes. - Implemented; awaiting manual validation: replaced the
yamlpackage used only bymergeMarkdownFiles()with Obsidian's externalparseYaml()andstringifyYaml()functions. The production bundle decreased 104,707 bytes. Parsing remains equivalent; Obsidian's frontmatter serializer can format nulls and long lines differently while preserving their parsed values. - Implemented; awaiting manual validation: deflated the React/ReactDOM/JSX-shim source at build time. Unlike the prior failed attempt, the emitted bootstrap initializes Pako and the inflater before decompressing or evaluating React. The production bundle decreased 78,540 bytes, and the decompressed package source remains available for popouts. This still requires real Obsidian startup testing because that is where the earlier approach failed.
- Remove or archive release-note entries that cannot be rendered. The dialog
always slices the assembled notes to ten entries;
Messages.tscurrently has 23 top-level entries, so2.24.1and the 12 older entries are unreachable even when the user manually requests all notes. Those entries occupy about 18 KB of source string data. Preserve their history outside the runtime import graph. - Implemented; awaiting manual validation: replaced
opentype.jswith a focused SFNT reader for the three metrics actually consumed by Excalidraw. English family-name parsing was removed because it had no consumer. The reader matched all 21 available TTF/OTF fixtures exactly and retains the existing fallback for invalid files and all WOFF/WOFF2 fonts. The production bundle decreased 186,132 bytes.
Secondary static-payload candidates, after the above checkpoints:
CJKLoader.tscontains about 122 KB of generated font filenames and Unicode ranges. Build-time serialization plus the already available inflater can compact this substantially without moving the data to Excalidraw Extras.SuggesterInfo.tscontains about 80 KB of static ExcalidrawAutomate help metadata. Lazy inflation could reduce its stored representation, but its synchronous public help and suggester paths must retain the same object shape and timing.- The startup-script template is stored as 18,912 base64 characters; deflating the decoded template produces about 5,096 base64 characters. This is a modest, localized later win.
- The empty-drawing placeholder is about 23 KB of static scene data and the embedded Excalidraw Mastery settings logo is about 20 KB. Compacting them or changing the image format is possible, but both are user-visible and should follow the non-visual reductions.
chroma-jsis used only for applying SVG color alpha while ColorMaster is already bundled. Consolidation may remove another dependency, but named colors and exact output syntax must be compared before assuming the two implementations are interchangeable.
Do not prioritize the following:
- Moving translations to Excalidraw Extras. The four compressed locales total about 189 KB, but the safer first two packaging changes alone should recover roughly 103 KB, and the dependency substitutions offer more headroom.
- Removing
polybooljs,lz-string, or compatibility facades solely because they appear large. They participate in the public ExcalidrawAutomate or persisted-scene surface. - Replacing Popper or CodeMirror CSS parsing without a dedicated behavioral project. Their collision handling and editor behavior carry more risk than their likely saving justifies.
- Changing the embedded Excalidraw runtime or its base64 codec as an early step. It dominates bundle size, but it is also the most startup-sensitive and popout-sensitive payload.
Every bundle reduction must record both gross source expectation and actual
dist/main.js delta. Packaging changes require startup tests in the main
window and a popout on desktop, plus a mobile cold start. Font, YAML, SVG, and
static-data changes require the feature-specific tests described above rather
than relying on build success alone.
The plugin class is doing three different jobs:
- It is the required Rollup and Obsidian entry point.
- It is the ordered composition root for plugin-wide services.
- It is a widely used facade over settings, file, event, observer, command, stencil, package, and view operations.
The first roughly 580 lines also contain pure or mostly pure settings compatibility logic, especially the legacy AI-setting migration. That compatibility has reached its retirement point and can be removed as the first substantial reduction of the entry module.
Several existing managers already own meaningful behavior, but main.ts
retains delegate methods such as openDrawing(), isExcalidrawFile(),
modifyEventHandler(), getPackage(), and observer operations. Those delegates
should remain during early refactors. Removing them would turn a structural
change into a repository-wide and potentially external API change.
The ordering inside onload() and onloadOnLayoutReady() is behavioral:
- The Markdown post processor must be registered during
onload(). PluginFileManagermust exist early enough for Markdown processing.- Settings and Excalidraw Automate are needed before layout-ready work.
- Per-window packages, observers, monkey patches, styles, scripts, fonts, and image cache have a deliberate sequence.
- Loading views are switched only after the required runtime is ready.
- Some work is intentionally deferred until after real Excalidraw views exist.
The refactor must preserve this sequence, including which calls are awaited, which are launched without awaiting, and the current error isolation around individual startup steps.
There is currently no generic supported-key normalization, and this refactor
will not add one. Obsidian stores the plugin's persisted settings in
data.json; loadSettings() merges all loaded top-level properties into
plugin.settings, and saveSettings() can write those properties back.
Before Phase 1, ExcalidrawSettings and DEFAULT_SETTINGS contained the same 204
top-level keys. This is useful, but an interface is erased by TypeScript and
cannot be inspected at runtime. DEFAULT_SETTINGS or a generated/explicit key
set must be the runtime representation of the specification.
After the targeted legacy AI removal, both contain the same 183 keys. No
runtime supported-key filter was introduced.
The legacy AI compatibility path is spread across more than the migration function:
- migration and stripping helpers in
main.ts; - legacy properties in
ExcalidrawSettingsandDEFAULT_SETTINGS; - fallback reads in
AIUtils.ts; - a one-off GPT reset during startup.
The existing LEGACY_AI_SETTING_KEYS list also mixes genuinely obsolete keys
with aiDefaultMultimodalModel, which is still used by the current settings UI
and AI model selection. The list must not simply be deleted wholesale. The
first implementation must define the current supported AI schema, preserve
current profile/model/default fields, and remove only the retired compatibility
surface.
Removing stripLegacyAISettings() is safe only after legacy members are also
removed from DEFAULT_SETTINGS. Otherwise
Object.assign({}, DEFAULT_SETTINGS, loaded) reintroduces every legacy default
into memory and an unfiltered saveSettings() persists them again.
The chosen 2.27.0 policy is deliberately non-destructive: remove confirmed
legacy AI members from the current TypeScript settings contract, defaults,
migration, and runtime fallback logic, but do not delete arbitrary persisted
keys. A stale legacy key already present in data.json may continue to
round-trip as an inert unknown property. This is preferable to risking deletion
of settings introduced by a newer plugin version or stored by another device.
The view class currently combines several distinct state domains:
| State domain | Examples | Appropriate owner |
|---|---|---|
| Obsidian lifecycle | loaded, unloading, popout unload, window migration, leaf close | ExcalidrawView plus a lifecycle helper |
| Persistence and synchronization | dirty path, saving, autosaving, force-saving, prevent reload, just loaded | Plain TypeScript coordinator, not React |
| Excalidraw runtime | imperative API, scene, files, Excalidraw AppState |
Excalidraw itself plus view adapters |
| Rendered plugin UI | tools panel visibility, theme, fullscreen indicator, preview mode | React component state or a small UI store |
| Transient interaction throttles | hover sleep, wheel timeout, text-edit resize guard, imported-image timer | Focused controller or lifecycle-owned timers |
The current ViewSemaphores object mixes all five domains. Its name obscures
the fact that some values are lifecycle state, some are mutex-like guards, some
are one-shot event suppression, and some are timer state.
This does not mean the flags can be removed mechanically. They coordinate
events from independent owners: Obsidian file events, React/Excalidraw
callbacks, autosave timers, sync, view unload, leaf detach monkey patches,
embeddable editors, mobile keyboards, and popout teardown. The current file has
46 window.setTimeout() calls, 22 window.clearTimeout() calls, two
ResizeObserver constructions, and both native and debugging mutation
observer paths. Changes to timing, scheduling, or cleanup are behavioral
changes even when the resulting code looks simpler.
The view's public identity must also remain stable. ExcalidrawAutomate uses
instanceof ExcalidrawView, stores it as targetView, and exposes it through
hooks. Dialogs, managers, utilities, components, scripts, and companion-plugin
behavior access many view members directly.
An initial exact-name scan confirmed several consolidation candidates:
utils.tsandsceneDataUtils.tsboth contain helpers includingwrapTextAtCharLength,getBinaryFileFromDataURL,getLinkParts,arrayToMap, andupdateFrontmatterInString.utils.tsandembeddedAssetUtils.tsboth contain helpers includinggetFontDataURL,svgToBase64, export-setting helpers, andcropCanvas.excalidrawAutomateUtils.tsandexcalidrawViewHelpers.tsboth containcloneElementandgetBoundTextElementId.
Some pairs are textually identical; others have already diverged in types, null handling, safe-frontmatter access, or naming. This is evidence for an audit, not permission for bulk deletion. Overload declarations, intentionally isolated build subprojects, domain-specific variants, and compatibility re-exports must not be misclassified as accidental duplication.
The current root renderer is already a React function component in substance:
excalidrawRootElement() calls hooks and is mounted with createRoot(). It is
embedded as a class method and written with React.createElement() rather than
living in a component module.
The unusual runtime binding is essential:
- Rollup embeds React, ReactDOM, and compressed Excalidraw payloads.
PackageManagerevaluates a fresh package set in each Obsidian window.ExcalidrawView.onload()resolves packages forownerWindow.- The root, hooks, refs, and elements use
this.packages.reactandthis.packages.reactDOM. - The package is removed when the last relevant leaf in a window closes.
An extracted component must preserve that binding. A normal module-level
import React from "react" followed by global ReactDOM.createRoot() is not a
safe replacement. JSX is possible, but only after proving that every emitted
element and hook call is bound to the view's package-managed React instance.
The intended dependency direction is:
Obsidian
|
v
ExcalidrawPlugin (entry point and stable facade)
|
+--> explicitly ordered plugin managers/services
|
+--> PackageManager -- one Packages instance per Window
|
v
ExcalidrawView (Obsidian TextFileView and stable facade)
|
+--> view-scoped controllers
|
+--> package-aware React root adapter
|
v
Excalidraw and plugin React UI
The target is not a strict line-count goal. It is successful when ownership is clear, lifecycle edges are explicit, and behavior can be changed in one subsystem without reading 9,000 lines first.
Suggested eventual source boundaries are:
src/core/
main.ts Obsidian entry, ordered lifecycle, facades
managers/
PluginSettingsManager.ts Load/save/encryption/default assembly
FontManager.ts Per-document fonts and package registration
ViewportStyleManager.ts Phone/tablet safe-area behavior
src/view/
ExcalidrawView.ts Obsidian host, public facade, composition
components/
ExcalidrawRoot.tsx Package-aware render tree, eventually TSX
managers/
ViewExcalidrawExtensionRenderer.ts
ViewExportManager.ts
ViewLinkNavigationManager.ts
ViewSceneFileManager.ts
ViewPersistenceCoordinator.ts
ViewInteractionController.ts
ViewLifecycleResources.ts
These names are provisional. Create a module only when a specific extraction is being implemented; do not scaffold empty abstractions in advance.
Every step in this plan should follow these constraints:
- One responsibility per change. Do not combine extraction, renaming, type tightening, timer changes, and behavior changes.
- Keep old public methods as forwarding facades until all internal and known external consumers have a migration path.
- Preserve command IDs, settings keys, frontmatter, scene serialization, hooks, class identity, import paths, and method signatures.
- Preserve startup and teardown order exactly unless a separate change is specifically testing an order change.
- Preserve
await, fire-and-forget, timeout, debounce, and retry semantics during code moves. - Preserve the choice of
window,ownerWindow,mainDocument, andownerDocument. Do not normalize them during extraction. - Do not add a state-management library. It would add bundle weight and a second abstraction before the state domains are separated.
- Do not upgrade React, Excalidraw, Rollup, TypeScript, or Obsidian typings in the same change as a structural refactor.
- Do not change the custom Excalidraw fork as part of this source-structure effort unless a later, isolated requirement proves it necessary.
- Run
npm run buildimmediately after every code change. Use targeted lint on touched files and runnpm run codefor visibility when appropriate. - Preserve the complete ExcalidrawAutomate public surface. An internal move
must retain forwarding members on
ExcalidrawViewor the plugin wherever scripts or plugins may rely on them. - Add TSDoc to every new exported class/function and public method. Add
module-level documentation and
@remarksfor ordering constraints, ownership, timers, observers, undocumented Obsidian behavior, popout runtime binding, and compatibility decisions. Avoid comments that merely restate the code. - Search for exact and semantic duplicates before adding a helper. When consolidating existing duplicates, map every import and caller, select one canonical owner, preserve aliases where compatibility requires them, and validate behavior before deleting the alternatives.
- At the end of every refactor step, provide a risk-ranked test recommendation. Identify the behavior most likely to break, give concrete checks in priority order, state whether desktop, mobile, tablet, or popout coverage is warranted, and call out any high-risk path that was not tested.
Each numbered sub-step should normally be its own commit or pull request.
Before moving runtime code:
- Record production
main.jsbyte size and the startup breakdown emitted by the existing startup instrumentation. - Create a repeatable manual smoke-test vault or checklist covering the validation matrix later in this document.
- Record which tests are possible on desktop, mobile, and popout windows in the current development environment.
- Capture known warnings from
npm run buildso a later change can be judged on whether it introduced a new warning. - For synchronization work, capture debug traces for open, edit, autosave, external modify, reload, leaf close, last-popout-leaf close, and plugin unload. This becomes the characterization oracle for later coordinator work.
No production behavior should change in this phase.
This is the recommended first implementation change.
- Define and document the current supported AI settings. Keep the profile and
model maps, current default model IDs—including
aiDefaultMultimodalModel—and current token limits. - Remove
migrateLegacyAISettings(), its URL/provider/capability helpers, and legacy-only types/constants frommain.ts. - Remove retired AI properties from
ExcalidrawSettingsandDEFAULT_SETTINGS, and remove fallback reads fromAIUtils.ts. Search the full repository before deciding that a field is legacy. - Preserve current fields even if they appear in the old legacy-key list.
aiDefaultMultimodalModelis a confirmed example. - Remove
stripLegacyAISettings()andLEGACY_AI_SETTING_KEYSonly after the legacy defaults and runtime fallbacks are gone. - Retire the GPT-specific one-off startup reset if its only purpose is the old AI schema. Treat the unrelated compression reset separately.
- Do not introduce generic supported-key filtering, a settings schema version, or unknown-key deletion. Preserve unrelated persisted properties.
- Add fixtures covering a current profile/model configuration, encrypted current API keys, arbitrary script settings, and raw legacy AI fields. Confirm that legacy fields no longer influence resolved AI configuration and that unrelated properties remain untouched.
- Verify that loading and saving current settings remains behaviorally identical and that current AI defaults are still persisted.
- Add a concise 2.27.0 entry to
src/shared/Dialogs/Messages.tsif the settings cleanup is observable to users. Do not add new UI copy merely to announce an internal refactor.
This is a deliberate retirement, not a compatibility migration: users who use ExcalidrawAI have already migrated, and obsolete AI-only values may be dropped. The implementation must nevertheless preserve every field in the current AI schema.
Implementation completed on 2026-08-08. The retired keys were removed from
the TypeScript contract and defaults, and they no longer participate in AI
configuration resolution. Existing unknown properties in data.json are not
deleted; this intentionally preserves the no-sanitizer decision.
After Phase 1 is stable:
- Create
PluginSettingsManagerto own load, default assembly, remaining migrations, encryption/decryption, save, and external-settings reload. - Keep
plugin.settingsas the canonical externally visible settings object. - Keep
ExcalidrawPlugin.loadSettings(),saveSettings(), andonExternalSettingsChange()as delegates with their current signatures. - Keep the one-off startup update and settings-tab readiness in the explicit startup flow until their timing is separately characterized.
Do not change persisted data format in this phase.
Perform the audit in small families rather than a repository-wide rewrite:
- Generate an inventory of duplicate exported names and textual clone
candidates across
.tsand.tsxfiles. - For each candidate, compare implementation, types, null/falsy behavior, side effects, environment assumptions, and every import/call site.
- Classify it as exact duplicate, intentional variant, overload/re-export, or uncertain. Record the classification in the action log.
- Choose the canonical module using the repository placement rules. Scene
data helpers belong in a scene-focused module; embedded asset helpers belong
in an asset-focused module; lifecycle state does not belong in
utils. - Consolidate one helper family per change. Redirect imports first, retain a forwarding export when an import path may be public, then delete the old implementation only after validation.
- Add focused characterization tests for helpers whose copies have diverged.
- Check bundle byte impact: consolidation should normally be neutral or smaller, but Rollup tree shaking and circular imports can produce surprising results.
- Repeat the audit after the major file extractions so refactoring does not create new near-duplicates.
The initial candidates listed in the assessment are the starting queue, not a pre-approved deletion list.
Do this in separate changes:
- Extract font discovery, CJK loading, per-document style installation,
per-window Excalidraw font registration, and cleanup into
FontManager. - Preserve plugin delegates used by settings and popout-view initialization:
initializeFonts(),loadFontFromFile(), and related accessors. - Extract phone/tablet footer safe-area style management into a small
ViewportStyleManageror extendStylesManagerif its ownership and teardown model match. - Preserve the current document enumeration and deliberate style-element creation path.
Font initialization crosses PackageManager, open documents, settings, and
popout creation, so it should not be combined with package-manager changes.
Candidates should be extracted one at a time:
- Keep Markdown post-processing, install-codeblock registration, observer
setup, and rerender behavior explicit in their current owners. A proposed
MarkdownIntegrationManagerwas rejected because these responsibilities do not form a cohesive lifecycle unit. - Move startup-script execution behind a focused runner owned by the script subsystem.
- Completed: startup timing storage/formatting now belongs to
StartupTimer, with the plugin methods retained as compatibility delegates. - Group initialization and cleanup of managers, but keep a readable ordered
list in
onloadOnLayoutReady()andonunload(). - Completed: after repository-wide reference searches, removed the unused
private
registerEventListeners()and the never-assignedfileExplorerObserverfield and unload check frommain.ts. The active observer owned byObserverManagerremains intact.
Do not hide lifecycle ordering inside a generic service container. The desired
main.ts is a readable composition root, not an empty forwarding shell.
Start with cohesive operations that do not own mount/unmount ordering or save
mutual exclusion. For every extraction, retain the current method on
ExcalidrawView as a delegate.
Recommended order:
- Completed:
ViewExportManagerowns export preference resolution, file collection, SVG, PNG, PDF, clipboard, and save-to-file operations while the view retains compatibility delegates and dialog lifecycle. - Implemented:
ViewFullscreenManagerowns fullscreen DOM class and style coordination while the view retains compatibility delegates. Validate the unchanged selectors in the main window, popouts, mobile layouts, screenshot capture, link-driven exit, and view teardown before closing this checkpoint. - Implemented:
ViewLinkNavigationManagerowns link-source resolution, hook invocation, prompting, modifier handling, special embedded-image branches, and pane navigation while the view retains compatibility delegates. - Implemented:
ViewExcalidrawExtensionRendererowns the plugin-specific Excalidraw render slots for TTD, diagram-to-code, the welcome screen, custom actions, and embeddables. The view retains its render delegates and supplies all elements through its window-scoped React and Excalidraw packages. ViewSceneFileManager: active/next/deferred embedded-file loaders and deferred validation scheduling.MarkdownImageController: deletion queue, edit handoff, conversion, and local-source operations.ViewInteractionController: hover preview and pointer/key interaction only after the earlier controllers establish a working host-interface pattern.
Each manager should depend on the smallest host interface practical rather
than importing the concrete view if that can be done without a large rewrite.
Avoid inventing a broad IExcalidrawView containing most of the current class;
that merely duplicates the monolith as an interface.
The first renderer extraction should be a mechanical move, not a state rewrite or JSX conversion.
- Define a narrow render-host contract containing runtime packages, refs, callbacks, and render slots required by the root.
- Move the body of
excalidrawRootElement()to a component module. - Pass the
Packagesinstance resolved forview.ownerWindowinto that component. - Inside the component, call hooks and
createElement()from that package'sreactobject. - Continue creating the root with that package's
reactDOM.createRoot(). - Keep API assignment, initialization callbacks, cleanup, and ref handoff behavior identical.
- Test main window, existing popout, moving a leaf to a popout, restored workspace popout, and closing the last popout leaf.
A safe conceptual shape is:
const runtime = view.packages;
const Root = createExcalidrawRootComponent(renderHost, runtime.react);
view.excalidrawRoot = runtime.reactDOM.createRoot(view.contentEl);
view.excalidrawRoot.render(runtime.react.createElement(Root));The exact API can differ, but it must not fall back to a module-global React or ReactDOM instance.
Only after Phase 7 is stable should syntax change:
- Rename the renderer to
.tsxand convert a small render fragment at a time. - Ensure the configured classic JSX transform resolves
React.createElementto a lexically scoped, package-managed React object. - Use module-level React imports for types only where possible.
- Do not combine JSX conversion with callback, prop, state, or menu changes.
- Compare behavior and bundle size after each fragment conversion.
TSX will improve readability, but it is not itself a state-management architecture. The package-runtime constraint matters more than the file extension.
Introduce React state only where it eliminates imperative UI synchronization.
- Define a small
ViewUiSnapshotfor values actually rendered by plugin UI, such as dirty indicator, fullscreen, preview mode, view mode, theme, and selected UI mode. - If multiple components need the snapshot, implement a tiny plain TypeScript observable store owned by the view.
- Subscribe with the package-managed React instance, potentially through
useSyncExternalStore()after verifying availability in every loaded runtime. - Migrate one existing imperative
ToolsPanel.setState()pathway at a time. - Avoid copying Excalidraw
AppStateinto another store. Read it from the API or consume the relevant callback value.
Do not put the following in React state:
- saving, autosaving, or force-saving;
- reload suppression;
- dirty file identity used during file switching;
- unload or popout-unload state;
- timers, observers, or loader ownership;
- data needed after the React root has started unmounting.
React updates are asynchronous and the component can disappear while Obsidian still requires a final save. Those concerns must remain available independently of the mounted tree.
Do not replace the entire object in one change. First classify and encapsulate one cluster while preserving the existing storage and timing.
Suggested clusters:
| Coordinator | Initial fields/behavior |
|---|---|
ViewPersistenceCoordinator |
saving, autosaving, forceSaving, dirty, save wait behavior |
ViewReloadGuard |
preventReload, embeddableIsEditingSelf, their reset timers |
ViewLoadState |
justLoaded, preventAutozoom, initial onChange suppression |
ViewLifecycleState |
viewloaded, viewunload, popoutUnload, scriptsReady |
ViewInteractionThrottle |
hoverSleep, wheelTimeout, isEditingText, imported-image guard |
For each cluster:
- Add named query and transition methods while still using the current fields.
- Replace direct writes in one call path at a time.
- Add debug assertions for impossible transitions without changing production recovery behavior.
- Compare characterization traces.
- Only then move storage into the coordinator.
- Keep compatibility accessors for known external reads or writes until their consumers are migrated.
Potential future improvements such as a single-flight save promise, abortable waits, or replacing polling loops are behavioral projects. They should follow, not accompany, the structural extraction. Existing watchdogs and sleeps must remain until device and Obsidian behavior has been tested against a replacement.
After the major boundaries are stable:
- Replace concrete plugin/view imports with small type-only capability contracts in newly extracted modules where this materially reduces cycles.
- Move repeated shared shapes to
src/types/; do not create duplicate local aliases. - Use deprecation comments and forwarding accessors before removing a facade used by scripts or companion plugins.
- Run
npm run madgeonce its tooling is available and address new cycles introduced by the refactor. The command was not available in the installed dependencies during this assessment. - Perform naming cleanup only in a dedicated compatibility-aware pass.
npm run build is mandatory after every code modification. In addition, each
phase should select the relevant manual cases below. Every step's handoff must
turn that selection into a risk-ranked test recommendation: lead with the
highest-probability or highest-impact failure, name the required platforms and
window modes, and distinguish automated validation from untested manual risk.
- Production build completes with no new errors or warnings.
- Touched files pass targeted ESLint diagnostics.
npm run codeis reviewed for newly introduced findings despite the existing repository backlog.npm run libis run when the public/library API is touched.- When a change can affect ExcalidrawAutomate, compare the generated library
declarations/API docs with the baseline and run
npm run docas applicable. npm run madgeis run after structural import changes once available.dist/main.jsstays below 5 MiB and its byte delta is recorded.- No generated
dist/orlib/file is edited manually.
- Fresh plugin enable with no open drawing.
- Restored workspace with one or multiple drawings.
- Main-window view creation before and after plugin readiness.
- Existing popout, restored popout, and leaf moved between windows.
- Close normal leaf, close last popout leaf, close popout window, disable plugin, and reload plugin.
- Confirm package cleanup does not break another leaf in the same window.
- Confirm startup breakdown has no material regression.
- Manual save and command-triggered force save.
- Desktop and mobile autosave intervals.
- Close while clean, dirty, already saving, and editing text.
- External file modification and sync-style reload.
- Same drawing open as Excalidraw and Markdown.
- Same drawing open in multiple leaves/windows.
- Embeddable or Markdown-image editor updates its owning drawing.
- Initial
onChangedoes not cause a false save or sync ping-pong. - Container-size update after load does not mark the scene incorrectly.
- Desktop, phone, and tablet layouts.
- Mobile on-screen keyboard open/close during text editing.
- Resize, sliding panes, hover editor, canvas node, fullscreen, and popout.
- Hover preview while scrolling/zooming.
- Link click modifier combinations and view mode.
- Tools panel, selected-element actions, embeddable menu, welcome screen, context menu, and custom actions.
- Theme, canvas color, grid, handedness, UI mode, pinch zoom, and wheel zoom.
- Large drawing and many embedded files.
- SVG, PNG, clipboard, PDF, and
.excalidrawexport. - Local images, Markdown images, PDFs, LaTeX, Mermaid, web embeds, and YouTube.
- Excalidraw Automate calls and all view hooks.
- Existing ExcalidrawAutomate function/property names, signatures, defaults,
return values, hook timing,
targetViewbehavior, andinstanceof ExcalidrawViewbehavior remain unchanged. - Script engine startup, pinned scripts, and install-codeblock flow.
- ExcaliBrain or another companion integration if available.
Before merging any refactor step, answer all of the following:
- What ownership boundary became clearer?
- Which public methods and fields were preserved as facades?
- Were all repository references searched before and after the change?
- Did any
await, timer, retry count, observer target, event target, or cleanup order change? - Did any use of
window/ownerWindowormainDocument/ownerDocumentchange? - Which manual lifecycle cases were exercised?
- Did the production build pass, and what was the
main.jsbyte delta? - Were any new warnings or circular dependencies introduced?
- Were duplicate-name and semantic-clone searches performed before adding a new helper, and were intentional variants kept distinct?
- Do all new modules, exports, public methods, compatibility shims, and non-obvious lifecycle constraints have useful TSDoc?
- If the view or plugin facade moved, was ExcalidrawAutomate API and hook compatibility verified with library declarations and representative scripts?
- What is the highest-risk regression, which concrete tests are recommended in priority order, and which devices, platforms, or popout configurations are necessary for this particular change?
- Is the change independently reversible?
- Retire the complete legacy AI migration, fields, defaults, reset, and
AIUtilsfallback path without sanitizing unrelated persisted keys. This is the first 2.27.0 implementation target. - Complete the duplicate inventory, classify the confirmed utility candidates, and consolidate one exact, low-risk family after mapping all callers. Record the decision and validation in the action log.
- Completed: extracted
ViewExportManagerwhile keeping all currentExcalidrawViewexport methods as delegates. This establishes the view-controller pattern away from lifecycle and synchronization.
The current checkpoint is manual validation of
ViewExcalidrawExtensionRenderer. First test a popout window because a
cross-window React mismatch is the highest-impact risk, then verify the welcome
screen, custom menu actions, web and Obsidian embeddables, text-to-diagram, and
diagram-to-code in the main window. Repeat basic rendering and embeddables on a
mobile device. The next controller candidate remains ViewSceneFileManager,
but its loader queue, retry timers, cache validation, and lifecycle teardown
make it a higher-risk extraction. A mechanical package-aware React-root move
is now also viable, but it must keep createElement() and current state timing
unchanged; any TSX or state-management conversion belongs in a later step.
After those steps, reassess coupling, bundle size, and manual-test coverage before committing to the next phase. The plan is deliberately a sequence of checkpoints rather than a promise to execute every proposed module unchanged.