fix: better performance with code blocks

This commit is contained in:
Simon 2026-07-03 14:01:13 +02:00
parent f64812c8af
commit 52b40f22f6
8 changed files with 2513 additions and 20 deletions

51
CLAUDE.md Normal file
View file

@ -0,0 +1,51 @@
# Obsidian plugin submission warnings
Obsidian's plugin review tooling (source code + CSS lint + dependency scan) flags
warnings on this repo. Track known ones here so we don't waste time
re-investigating warnings that are either already fixed or accepted as
unavoidable trade-offs.
**Update this list every time a warning is fixed or a new one appears** —
remove fixed entries, add new ones with the same reasoning format.
## CSS lint — accepted, not fixable without changing behavior
- `display: contents` (`src/mdxeditor.css`: `.rte-blocktype-select`,
`.callout-title-host`, `.callout-toolbar-icon`) — "only partially supported
by Obsidian 1.4.5". Required to make these wrapper elements layout-transparent
(their children need to act as direct flex children of the surrounding
toolbar/callout). Removing it breaks the layout.
- `text-decoration` multi-value usages (`_underlineStrikethrough_1tncs_38`,
`.table-formula`) — "only partially supported by Obsidian 1.4.5". Needed for
combined underline+strikethrough text and the dotted underline on table
formulas. Single-keyword usages (`underline`, `none`, `line-through`) are not
flagged, only the multi-value ones.
- `!important` (`src/mdxeditor.css`, mobile link-popover positioning) —
overrides an inline `transform` style Radix sets at runtime; specificity
alone can't beat an inline style.
- `:has()` (`src/mdxeditor.css`, same mobile link-popover rule) — scopes the
`!important` override to just the link popover, not other popups sharing
`.mdxeditor-popup-container`.
These four are documented inline in `src/mdxeditor.css` with the same
reasoning. Don't "fix" them by deleting the declarations.
## Dependency vulnerabilities
- `diff` (jsdiff DoS in `parsePatch`/`applyPatch`, GHSA-73rr-hh4g-fpgx) —
fixed via `npm audit fix` (non-breaking, transitive dep of `@mdxeditor/editor`
via `unidiff`/`uvu`).
- `js-yaml` (quadratic-complexity DoS via merge keys, GHSA-h67p-54hq-rp68) —
**not fixed**. Only resolvable by upgrading `@mdxeditor/editor` to `4.0.4`,
which is a breaking major-version change to the plugin's core editor
dependency. Needs a deliberate upgrade + regression pass, not a drive-by fix.
## Source code warnings
Fixed:
- Unnecessary type assertions in `main.ts` (`editorCheckCallback`/
`checkCallback` `.call()` results) — removed; the callback types already
produce `boolean | void` without the `as` assertion.
- `'+' operation on unknown` in `src/RichTextOverlay.tsx` (Notice message
built from a caught `e`) — narrowed with
`e instanceof Error ? e.message : String(e)`.

View file

@ -69,6 +69,10 @@ const context = await esbuild.context({
],
format: "cjs",
target: "es2018",
// Use production React even in dev builds: the dev variant's render
// instrumentation (logComponentRender, createTask, runWithFiberInDEV)
// dominates performance profiles and hides the plugin's real costs.
define: { "process.env.NODE_ENV": '"production"' },
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,

View file

@ -218,14 +218,12 @@ export default class RichTextPlugin extends Plugin {
// behaviour so the standard editor search still works.
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (original.editorCheckCallback && view) {
// `.call` is typed `any` here (strictBindCallApply is off), so
// annotate the result to keep the return type sound.
const res = original.editorCheckCallback.call(
cmd,
checking,
view.editor,
view,
) as boolean | void;
);
return checking ? (res ?? false) : true;
}
if (original.editorCallback && view) {
@ -235,9 +233,7 @@ export default class RichTextPlugin extends Plugin {
return true;
}
if (original.checkCallback) {
const res = original.checkCallback.call(cmd, checking) as
| boolean
| void;
const res = original.checkCallback.call(cmd, checking);
return checking ? (res ?? false) : true;
}
if (original.callback) {

19
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@lexical/link": "^0.35.0",
"@lexical/list": "^0.35.0",
"@lexical/react": "^0.35.0",
"@lexical/utils": "^0.35.0",
"@mdxeditor/editor": "^3.47.0",
"@mdxeditor/gurx": "^1.2.4",
@ -1619,9 +1620,9 @@
"license": "MIT"
},
"node_modules/@mdxeditor/editor": {
"version": "3.52.3",
"resolved": "https://registry.npmjs.org/@mdxeditor/editor/-/editor-3.52.3.tgz",
"integrity": "sha512-hKPsNPx7cjxXPeiir0LjU2ZN5bWn7dWVxV8CaCddLztGc36e921ETNpu1AXo0+K7pxc7eBt/ExSw0WDOOk2Ibw==",
"version": "3.55.0",
"resolved": "https://registry.npmjs.org/@mdxeditor/editor/-/editor-3.55.0.tgz",
"integrity": "sha512-ziJY12OQVrrdAcWAwG8njV7gPy8orEIodGdFTXpQVlIOjdV/FOIHMcETMc5XXfON3hw3uO1CcHDb3TZQQSgfqg==",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.2.4",
@ -3691,9 +3692,9 @@
}
},
"node_modules/diff": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@ -7716,9 +7717,9 @@
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {

View file

@ -1,13 +1,24 @@
// ReactView.tsx
import { oneDark } from "@codemirror/theme-one-dark";
import { LanguageDescription } from "@codemirror/language";
import {
defaultHighlightStyle,
foldGutter,
LanguageDescription,
syntaxHighlighting,
} from "@codemirror/language";
import { languages as codeLanguageData } from "@codemirror/language-data";
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView, lineNumbers } from "@codemirror/view";
import { basicLight } from "cm6-theme-basic-light";
import { csharp } from "@replit/codemirror-lang-csharp";
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
codeBlockPlugin,
CodeBlockEditorDescriptor,
CodeBlockEditorProps,
codeMirrorPlugin,
CodeMirrorEditor,
CodeToggle,
CreateLink,
directivesPlugin,
@ -108,6 +119,118 @@ const CODE_BLOCK_LABEL_TO_ID = new Map(
Object.entries(CODE_BLOCK_LANGUAGES).map(([id, label]) => [label, id]),
);
// Mounting MDXEditor's full CodeMirror editor per code block dominates load
// time on documents with many blocks: its basicSetup pulls in keymaps,
// history, autocompletion, search, and update listeners for every instance.
// Until the user clicks into a block (or keyboard-navigates into it, which
// fires the focusEmitter), show a read-only CodeMirror view instead — same
// themes, gutters, and language grammar, so it looks identical, but with none
// of the editing machinery.
function LazyCodeBlockEditor(props: CodeBlockEditorProps) {
// Empty blocks are freshly inserted ones — mount eagerly so typing works
// right away (the insert's focus event fires before we could subscribe).
const [active, setActive] = useState(props.code === "");
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
props.focusEmitter.subscribe(() => setActive(true));
}, [props.focusEmitter]);
// The real editor builds its EditorView asynchronously (it awaits the
// language grammar), so poll a few frames for the content element.
useEffect(() => {
if (!active) return;
let tries = 0;
const tryFocus = () => {
const content =
wrapperRef.current?.querySelector<HTMLElement>(".cm-content");
if (content) {
content.focus();
} else if (++tries < 20) {
requestAnimationFrame(tryFocus);
}
};
requestAnimationFrame(tryFocus);
}, [active]);
useEffect(() => {
if (active) return;
const el = wrapperRef.current;
if (!el) return;
let view: EditorView | null = null;
let cancelled = false;
void (async () => {
// Mirror the extension order of MDXEditor's CodeMirrorEditor
// (codeMirrorExtensions first, then basicLight) so themes resolve
// with the same precedence and the swap on click is seamless.
const extensions: Extension[] = [
...(activeDocument.body.classList.contains("theme-dark")
? [oneDark]
: []),
basicLight,
lineNumbers(),
foldGutter(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping,
EditorView.editable.of(false),
EditorState.readOnly.of(true),
];
if (props.language !== "") {
const languageData = codeLanguageData.find(
(l) =>
l.name === props.language ||
l.alias.includes(props.language) ||
l.extensions.includes(props.language),
);
if (languageData) {
try {
extensions.push(
(await languageData.load()).extension,
);
} catch {
// Grammar failed to load; render as plain text.
}
}
}
if (cancelled) return;
view = new EditorView({
parent: el,
state: EditorState.create({ doc: props.code, extensions }),
});
})();
return () => {
cancelled = true;
view?.destroy();
};
}, [active, props.code, props.language]);
if (active) {
return (
<div ref={wrapperRef} style={{ display: "contents" }}>
<CodeMirrorEditor {...props} />
</div>
);
}
return (
<div
ref={wrapperRef}
className="rte-lazy-code-block"
data-code-lang={props.language || undefined}
onClick={() => setActive(true)}
/>
);
}
const lazyCodeBlockDescriptor: CodeBlockEditorDescriptor = {
// codeMirrorPlugin registers its descriptor at priority 1; win the match.
priority: 2,
// Same match as the CodeMirror descriptor, so exactly the blocks it would
// claim get the lazy treatment.
match: (language) => (language ?? "") in CODE_BLOCK_LANGUAGES,
Editor: LazyCodeBlockEditor,
};
// MDXEditor resolves a code block's syntax highlighting by matching the fence
// language against @codemirror/language-data's `languages` array and calling
// `.load()` on the match. C#'s built-in entry resolves to a legacy
@ -1341,7 +1464,12 @@ export const RichTextEditor = forwardRef<RichTextEditorRef, Props>(
linkPlugin(),
tagLinkPlugin(),
linkDialogPlugin({ showLinkTitleField: false }),
codeBlockPlugin({ defaultCodeBlockLanguage: "js" }),
codeBlockPlugin({
defaultCodeBlockLanguage: "js",
codeBlockEditorDescriptors: [
lazyCodeBlockDescriptor,
],
}),
codeMirrorPlugin({
codeBlockLanguages: CODE_BLOCK_LANGUAGES,

View file

@ -705,7 +705,10 @@ export class RichTextOverlay {
);
return true;
} catch (e) {
new Notice("Rename failed: " + e);
new Notice(
"Rename failed: " +
(e instanceof Error ? e.message : String(e)),
);
return false;
}
};

View file

@ -224,6 +224,14 @@ div[class*="_dialogContent_"] input {
border-radius: 6px;
}
/* Read-only stand-in for a not-yet-mounted CodeMirror editor (see
LazyCodeBlockEditor). It hosts a real .cm-editor, so the usual CodeMirror
rules apply; this only anchors the language flair. */
.mdxeditor .rte-lazy-code-block {
position: relative;
cursor: text;
}
/** Diff viewer */
.mdxeditor .cm-mergeView .cm-scroller,
@ -753,7 +761,8 @@ mark {
/* Non-interactive language flair, shown in the corner of every code block
(mirrors Obsidian's `code-block-flair`). The `data-code-lang` attribute is
set in RichTextEditor.tsx from the block's selected language. */
[class*="_codeMirrorWrapper_"][data-code-lang]::before {
[class*="_codeMirrorWrapper_"][data-code-lang]::before,
.rte-lazy-code-block[data-code-lang]::before {
content: attr(data-code-lang);
position: absolute;
top: 0;

2303
styles.css

File diff suppressed because one or more lines are too long