@bendyline/squisq-editor-react 1.5.2 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-editor-react",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "React editor shell with raw/WYSIWYG/preview modes for Squisq documents",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
package/src/RawEditor.tsx CHANGED
@@ -7,29 +7,30 @@
7
7
  */
8
8
 
9
9
  import { useRef, useCallback, useEffect } from 'react';
10
- import Editor, {
11
- loader,
12
- type OnMount,
13
- type OnChange,
14
- type BeforeMount,
15
- } from '@monaco-editor/react';
16
- import * as monaco from 'monaco-editor';
10
+ import Editor, { type OnMount, type OnChange, type BeforeMount } from '@monaco-editor/react';
11
+ import type * as monaco from 'monaco-editor';
17
12
  import { useEditorContext } from './EditorContext';
18
13
  import { getAvailableTemplates } from '@bendyline/squisq/doc';
19
14
  import { suggestIcons, resolveIcon, iconGlyph } from '@bendyline/squisq/icons';
20
15
  import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
21
-
22
- // Use locally installed monaco-editor instead of CDN.
16
+ import { useMonacoLoader } from './useMonacoLoader';
17
+
18
+ // Monaco is loaded lazily through `useMonacoLoader` (see the hook for the
19
+ // rationale). The type-only `import type * as monaco from 'monaco-editor'`
20
+ // above gives us `monaco.editor.IStandaloneCodeEditor`, `monaco.Range`,
21
+ // etc. for typing without pulling the package into the static module
22
+ // graph — which is the whole point: a consumer importing `JsonEditor` or
23
+ // a type from the package barrel no longer drags ~9MB of language
24
+ // services into the resolver.
23
25
  //
24
- // NOTE: By default this imports the full monaco-editor with all 80+ languages
25
- // and workers (~9MB). Consumers can dramatically reduce bundle size by aliasing
26
- // 'monaco-editor' to a slim entry in their bundler config. For example with Vite:
26
+ // Consumers that *do* want the raw editor can still slim the bundle by
27
+ // aliasing `monaco-editor` to a custom entry in their bundler config.
28
+ // For example with Vite:
27
29
  //
28
30
  // resolve: { alias: [{ find: /^monaco-editor$/, replacement: './monaco-slim.ts' }] }
29
31
  //
30
- // Where monaco-slim.ts re-exports 'monaco-editor/esm/vs/editor/editor.api' plus
31
- // only the language contributions needed (e.g. markdown, javascript, etc.).
32
- loader.config({ monaco });
32
+ // Where monaco-slim.ts re-exports 'monaco-editor/esm/vs/editor/editor.api'
33
+ // plus only the language contributions needed (e.g. markdown, javascript).
33
34
 
34
35
  // Squisq Monaco themes: same syntax highlighting as vs / vs-dark, but with
35
36
  // Monaco's internal gutter (line numbers + folding margin) and overview
@@ -83,6 +84,7 @@ export function RawEditor({
83
84
  }: RawEditorProps) {
84
85
  const { markdownSource, setMarkdownSource, setMonacoEditor, language, mentionProvider } =
85
86
  useEditorContext();
87
+ const { monaco: monacoNs, ready: monacoReady } = useMonacoLoader();
86
88
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
87
89
  const isExternalUpdate = useRef(false);
88
90
  const completionDisposable = useRef<monaco.IDisposable | null>(null);
@@ -439,7 +441,10 @@ export function RawEditor({
439
441
  // and weight; the codepoint character is the decoration's content.
440
442
  useEffect(() => {
441
443
  const editor = editorRef.current;
442
- if (!editor) return;
444
+ // `monacoNs` is read from the lazy loader's state rather than a
445
+ // top-level import. Re-run when it transitions from null → loaded
446
+ // so decorations show up the moment monaco is in hand.
447
+ if (!editor || !monacoNs) return;
443
448
  if (language !== 'markdown') return;
444
449
  const model = editor.getModel();
445
450
  if (!model) return;
@@ -461,7 +466,7 @@ export function RawEditor({
461
466
  // glyph as content prepended visually to that position.
462
467
  const col = match.index + 1; // Monaco columns are 1-based
463
468
  decorations.push({
464
- range: new monaco.Range(line, col, line, col),
469
+ range: new monacoNs.Range(line, col, line, col),
465
470
  options: {
466
471
  before: {
467
472
  content: glyph,
@@ -477,10 +482,36 @@ export function RawEditor({
477
482
  } else {
478
483
  iconGlyphDecorations.current.set(decorations);
479
484
  }
480
- }, [markdownSource, language]);
485
+ }, [markdownSource, language, monacoNs]);
481
486
 
482
487
  const effectiveTheme = SQUISQ_THEMES[theme] ?? theme;
483
488
 
489
+ // Wait for the lazy monaco namespace + `loader.config()` to settle
490
+ // before mounting `<Editor>`. Without this gate, the @monaco-editor/
491
+ // react singleton loader would fall back to its built-in CDN fetch
492
+ // for any consumer that hasn't aliased monaco-editor — which is the
493
+ // exact regression the lazy-loading move is meant to avoid.
494
+ if (!monacoReady) {
495
+ return (
496
+ <div
497
+ className={className}
498
+ style={{
499
+ width: '100%',
500
+ height: '100%',
501
+ display: 'flex',
502
+ alignItems: 'center',
503
+ justifyContent: 'center',
504
+ color: 'var(--squisq-editor-muted-foreground, #6a6258)',
505
+ fontSize: 13,
506
+ }}
507
+ data-testid="raw-editor"
508
+ data-monaco-loading
509
+ >
510
+ Loading editor…
511
+ </div>
512
+ );
513
+ }
514
+
484
515
  return (
485
516
  <div className={className} style={{ width: '100%', height: '100%' }} data-testid="raw-editor">
486
517
  <Editor
@@ -11,10 +11,67 @@
11
11
  * a provider that has `allowVersioning` and a `container`.
12
12
  */
13
13
 
14
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
15
15
  import { DiffEditor } from '@monaco-editor/react';
16
16
  import type { Version } from '@bendyline/squisq/versions';
17
17
  import { useEditorContext } from './EditorContext';
18
+ import { useMonacoLoader } from './useMonacoLoader';
19
+
20
+ interface LazyDiffEditorProps {
21
+ original: string;
22
+ modified: string;
23
+ theme: 'vs' | 'vs-dark';
24
+ }
25
+
26
+ const lazyLoadingStyle: CSSProperties = {
27
+ display: 'flex',
28
+ alignItems: 'center',
29
+ justifyContent: 'center',
30
+ width: '100%',
31
+ height: '100%',
32
+ color: 'var(--squisq-editor-muted-foreground, #6a6258)',
33
+ fontSize: 12,
34
+ };
35
+
36
+ /**
37
+ * Defers the `<DiffEditor>` mount until the lazy monaco namespace +
38
+ * `loader.config()` are in place. Without the gate, the
39
+ * `@monaco-editor/react` singleton loader would fall back to its CDN
40
+ * default for any consumer that hasn't already mounted `<RawEditor>`.
41
+ *
42
+ * This wrapper is what makes `useMonacoLoader` worth using here —
43
+ * VersionHistoryPanel itself is always present in the toolbar, so
44
+ * subscribing at its level would defeat the lazy-load. The hook only
45
+ * fires when a snapshot is actually selected.
46
+ */
47
+ function LazyDiffEditor({ original, modified, theme }: LazyDiffEditorProps) {
48
+ const { ready } = useMonacoLoader();
49
+ if (!ready) {
50
+ return <div style={lazyLoadingStyle}>Loading diff…</div>;
51
+ }
52
+ return (
53
+ <DiffEditor
54
+ original={original}
55
+ modified={modified}
56
+ language="markdown"
57
+ theme={theme}
58
+ options={{
59
+ readOnly: true,
60
+ renderSideBySide: true,
61
+ minimap: { enabled: false },
62
+ scrollBeyondLastLine: false,
63
+ wordWrap: 'on',
64
+ automaticLayout: true,
65
+ fontSize: 12,
66
+ lineNumbers: 'off',
67
+ glyphMargin: false,
68
+ folding: false,
69
+ overviewRulerLanes: 0,
70
+ renderOverviewRuler: false,
71
+ }}
72
+ />
73
+ );
74
+ }
18
75
 
19
76
  interface PanelState {
20
77
  loading: boolean;
@@ -206,25 +263,10 @@ export function VersionHistoryPanel() {
206
263
  </span>
207
264
  </div>
208
265
  <div className="squisq-version-history-diff-body">
209
- <DiffEditor
266
+ <LazyDiffEditor
210
267
  original={state.selected.content}
211
268
  modified={markdownSource}
212
- language="markdown"
213
269
  theme={diffTheme}
214
- options={{
215
- readOnly: true,
216
- renderSideBySide: true,
217
- minimap: { enabled: false },
218
- scrollBeyondLastLine: false,
219
- wordWrap: 'on',
220
- automaticLayout: true,
221
- fontSize: 12,
222
- lineNumbers: 'off',
223
- glyphMargin: false,
224
- folding: false,
225
- overviewRulerLanes: 0,
226
- renderOverviewRuler: false,
227
- }}
228
270
  />
229
271
  </div>
230
272
  </div>
@@ -344,7 +344,15 @@ export function useMediaRecorder(options: UseMediaRecorderOptions): UseMediaReco
344
344
 
345
345
  // Final unmount cleanup — make sure we don't leak the camera light /
346
346
  // screen-capture indicator if the component disappears mid-recording.
347
+ //
348
+ // `stopResolversRef.current` is captured at effect-run time into a
349
+ // local. That ref is initialized once at construction and never
350
+ // reassigned, so the local handle stays a live view onto the same
351
+ // mutable array — entries pushed by later `stop()` calls still
352
+ // appear here on cleanup. Capturing keeps `react-hooks/exhaustive-deps`
353
+ // satisfied without changing the runtime behavior.
347
354
  useEffect(() => {
355
+ const pendingResolvers = stopResolversRef.current;
348
356
  return () => {
349
357
  const rec = recorderRef.current;
350
358
  if (rec && rec.state !== 'inactive') {
@@ -356,7 +364,7 @@ export function useMediaRecorder(options: UseMediaRecorderOptions): UseMediaReco
356
364
  }
357
365
  releaseStream();
358
366
  clearTicker();
359
- stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
367
+ pendingResolvers.splice(0).forEach((resolve) => resolve(null));
360
368
  };
361
369
  }, [releaseStream, clearTicker]);
362
370
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * useMonacoLoader
3
+ *
4
+ * Idempotently dynamic-imports `monaco-editor` and points the
5
+ * `@monaco-editor/react` singleton loader at the bundled copy. Replaces
6
+ * the historical top-of-module `import * as monaco from 'monaco-editor';
7
+ * loader.config({ monaco })` pattern, which forced every consumer of
8
+ * `@bendyline/squisq-editor-react` — including ones that only import
9
+ * `JsonEditor` or a type — to drag in monaco's ~9MB worth of language
10
+ * services and workers at module evaluation time.
11
+ *
12
+ * Hosts that want the smallest possible bundle can keep aliasing
13
+ * `monaco-editor` to a slim entry as before; the behavior is identical
14
+ * once the dynamic import settles.
15
+ *
16
+ * The promise is cached at module scope so the first subscriber
17
+ * anywhere in the app pays the import cost and every later subscriber
18
+ * reuses the same settled value.
19
+ */
20
+
21
+ import { useEffect, useState } from 'react';
22
+ import { loader } from '@monaco-editor/react';
23
+
24
+ /** In-flight (or settled) dynamic import shared across all callers. */
25
+ let monacoPromise: Promise<typeof import('monaco-editor')> | null = null;
26
+
27
+ /** Settled namespace once the promise resolves — read synchronously by mount-time consumers. */
28
+ let monacoNamespace: typeof import('monaco-editor') | null = null;
29
+
30
+ export interface UseMonacoLoaderResult {
31
+ /** The monaco namespace once loaded, or `null` while the import is in flight. */
32
+ monaco: typeof import('monaco-editor') | null;
33
+ /** Flips to `true` after the import settles. Gate `<Editor>` / `<DiffEditor>` renders on this. */
34
+ ready: boolean;
35
+ }
36
+
37
+ /**
38
+ * Subscribe to the lazy-loaded monaco namespace. The first caller
39
+ * triggers `import('monaco-editor')` and configures the
40
+ * `@monaco-editor/react` loader; subsequent callers receive the same
41
+ * cached value.
42
+ */
43
+ export function useMonacoLoader(): UseMonacoLoaderResult {
44
+ const [state, setState] = useState<UseMonacoLoaderResult>(() => ({
45
+ monaco: monacoNamespace,
46
+ ready: monacoNamespace !== null,
47
+ }));
48
+
49
+ useEffect(() => {
50
+ if (state.ready) return;
51
+ if (!monacoPromise) {
52
+ // Import the explicit ESM entry file rather than the bare
53
+ // package specifier `'monaco-editor'`. The package's
54
+ // `package.json` has no `main` / no `exports` (only `module`),
55
+ // which trips Vite's strict resolver on both static and
56
+ // dynamic imports of the bare name. Pointing at the file
57
+ // directly — the same path monaco's `module` field points at —
58
+ // turns this into a regular file-path resolve that doesn't
59
+ // care about the manifest's entry fields. Works identically
60
+ // in Vite dev / build, vitest's transform pipeline, and any
61
+ // bundler-using downstream consumer.
62
+ monacoPromise = (
63
+ import('monaco-editor/esm/vs/editor/editor.api.js') as unknown as Promise<
64
+ typeof import('monaco-editor')
65
+ >
66
+ ).then((m) => {
67
+ loader.config({ monaco: m });
68
+ monacoNamespace = m;
69
+ return m;
70
+ });
71
+ }
72
+ let cancelled = false;
73
+ void monacoPromise.then((m) => {
74
+ if (cancelled) return;
75
+ setState({ monaco: m, ready: true });
76
+ });
77
+ return () => {
78
+ cancelled = true;
79
+ };
80
+ }, [state.ready]);
81
+
82
+ return state;
83
+ }