@bendyline/squisq-editor-react 1.6.0 → 1.6.2

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.
Files changed (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. package/src/tiptapBridge.ts +60 -33
@@ -0,0 +1,149 @@
1
+ import type { editor as MonacoEditorNs } from 'monaco-editor';
2
+ import { type ZoneSpec, diffContextSections } from './diffContextSections';
3
+
4
+ type MonacoEditor = MonacoEditorNs.IStandaloneCodeEditor;
5
+
6
+ /**
7
+ * Owns the Monaco view zones behind the code-context sections. React-free —
8
+ * the React layer portals content into the zone dom nodes it hands out and
9
+ * feeds measured heights back via {@link setHeight}.
10
+ *
11
+ * Zone mechanics this leans on (monaco 0.50):
12
+ * - `changeViewZones` batches adds/removes/layouts into one whitespace
13
+ * re-layout.
14
+ * - `layoutZone(id)` re-reads the zone delegate, so mutating our own
15
+ * delegate's `afterLineNumber`/`heightInPx` then calling layoutZone both
16
+ * moves and resizes without remove/re-add churn (expanded DOM survives).
17
+ * - Zone dom nodes live in `.view-zones` (absolutely positioned, offscreen
18
+ * ones display:none'd by Monaco). They sit BELOW `.view-lines` in hit-test
19
+ * order, so the stylesheet raises `.squisq-ccx-zone` with a z-index and
20
+ * `pointer-events: auto` to make section content clickable.
21
+ * - Model swaps invalidate zone ids; the owner listens to onDidChangeModel
22
+ * and rebuilds.
23
+ */
24
+ export class CodeContextZoneManager {
25
+ private readonly editor: MonacoEditor;
26
+ private entries = new Map<
27
+ string,
28
+ {
29
+ zoneId: string;
30
+ delegate: MonacoEditorNs.IViewZone;
31
+ domNode: HTMLDivElement;
32
+ spec: ZoneSpec;
33
+ }
34
+ >();
35
+ private listeners = new Set<() => void>();
36
+ private modelListener: { dispose(): void } | null = null;
37
+ private disposed = false;
38
+
39
+ /** Initial height estimate for a strip; the ResizeObserver corrects it. */
40
+ static readonly INITIAL_HEIGHT_PX = 28;
41
+
42
+ constructor(editor: MonacoEditor) {
43
+ this.editor = editor;
44
+ // A model swap (file change / external reset) invalidates every zone id.
45
+ // Drop our bookkeeping; the next sync() recreates zones against the new
46
+ // model.
47
+ this.modelListener = editor.onDidChangeModel(() => {
48
+ this.entries.clear();
49
+ this.emit();
50
+ });
51
+ }
52
+
53
+ /** Reconcile the live zones against `specs` in one changeViewZones batch. */
54
+ sync(specs: ZoneSpec[]): void {
55
+ if (this.disposed) return;
56
+ const prev = [...this.entries.values()].map((e) => e.spec);
57
+ const { add, remove, move } = diffContextSections(prev, specs);
58
+ if (add.length === 0 && remove.length === 0 && move.length === 0) return;
59
+
60
+ this.editor.changeViewZones((accessor) => {
61
+ for (const id of remove) {
62
+ const entry = this.entries.get(id);
63
+ if (!entry) continue;
64
+ accessor.removeZone(entry.zoneId);
65
+ this.entries.delete(id);
66
+ }
67
+ for (const spec of move) {
68
+ const entry = this.entries.get(spec.id);
69
+ if (!entry) continue;
70
+ entry.spec = spec;
71
+ entry.delegate.afterLineNumber = Math.max(spec.line - 1, 0);
72
+ setOrdinal(entry.delegate, spec.ordinal);
73
+ accessor.layoutZone(entry.zoneId);
74
+ }
75
+ for (const spec of add) {
76
+ const domNode = document.createElement('div');
77
+ domNode.className = 'squisq-ccx-zone';
78
+ domNode.dataset.sectionId = spec.id;
79
+ const delegate: MonacoEditorNs.IViewZone = {
80
+ afterLineNumber: Math.max(spec.line - 1, 0),
81
+ heightInPx: CodeContextZoneManager.INITIAL_HEIGHT_PX,
82
+ domNode,
83
+ suppressMouseDown: true,
84
+ };
85
+ setOrdinal(delegate, spec.ordinal);
86
+ const zoneId = accessor.addZone(delegate);
87
+ this.entries.set(spec.id, { zoneId, delegate, domNode, spec });
88
+ }
89
+ });
90
+ this.emit();
91
+ }
92
+
93
+ /** Measured-content feedback: resize the zone to fit its rendered DOM. */
94
+ setHeight(id: string, px: number): void {
95
+ if (this.disposed) return;
96
+ const entry = this.entries.get(id);
97
+ if (!entry) return;
98
+ const height = Math.max(Math.ceil(px), 1);
99
+ if (Math.abs((entry.delegate.heightInPx ?? 0) - height) < 1) return;
100
+ entry.delegate.heightInPx = height;
101
+ this.editor.changeViewZones((accessor) => {
102
+ accessor.layoutZone(entry.zoneId);
103
+ });
104
+ }
105
+
106
+ getDomNode(id: string): HTMLDivElement | undefined {
107
+ return this.entries.get(id)?.domNode;
108
+ }
109
+
110
+ liveIds(): string[] {
111
+ return [...this.entries.keys()];
112
+ }
113
+
114
+ /** Fires after sync/model-change so the React layer rebuilds its portals. */
115
+ onDidChangeZones(cb: () => void): () => void {
116
+ this.listeners.add(cb);
117
+ return () => this.listeners.delete(cb);
118
+ }
119
+
120
+ dispose(): void {
121
+ if (this.disposed) return;
122
+ this.disposed = true;
123
+ this.modelListener?.dispose();
124
+ this.modelListener = null;
125
+ if (this.entries.size > 0) {
126
+ try {
127
+ this.editor.changeViewZones((accessor) => {
128
+ for (const entry of this.entries.values()) accessor.removeZone(entry.zoneId);
129
+ });
130
+ } catch {
131
+ // Editor already disposed — its zones died with it.
132
+ }
133
+ }
134
+ this.entries.clear();
135
+ this.listeners.clear();
136
+ }
137
+
138
+ private emit(): void {
139
+ for (const cb of [...this.listeners]) cb();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * `ordinal` orders zones sharing an afterLineNumber (monaco 0.50 supports it
145
+ * but the public IViewZone typing lags behind) — hence the structural write.
146
+ */
147
+ function setOrdinal(zone: MonacoEditorNs.IViewZone, ordinal: number): void {
148
+ (zone as MonacoEditorNs.IViewZone & { ordinal?: number }).ordinal = ordinal;
149
+ }
@@ -0,0 +1,121 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useEditorContext } from '../EditorContext';
4
+ import { CodeContextZoneManager } from './CodeContextZoneManager';
5
+ import { CodeContextSectionView } from './CodeContextSectionView';
6
+ import type { ZoneSpec } from './diffContextSections';
7
+ import type { CodeContext, CodeContextSection } from './types';
8
+
9
+ /**
10
+ * Bridges a host-supplied {@link CodeContext} onto the live Monaco editor:
11
+ * owns a {@link CodeContextZoneManager} per editor instance, reconciles zones
12
+ * when the context prop changes, and portals a `CodeContextSectionView` into
13
+ * each zone's dom node. Mounted by EditorShell in code mode; also exported
14
+ * for hosts composing a custom shell around `RawEditor`.
15
+ */
16
+ export function CodeContextZones({ options }: { options: CodeContext }) {
17
+ const { monacoEditor } = useEditorContext();
18
+ const [manager, setManager] = useState<CodeContextZoneManager | null>(null);
19
+ // Bumped whenever the zone set changes so portals rebuild.
20
+ const [, setZonesVersion] = useState(0);
21
+ // Expansion state per section id: seeded from defaultExpanded the first
22
+ // time an id appears; the user's toggle wins afterwards.
23
+ const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
24
+ const seenIds = useRef(new Set<string>());
25
+
26
+ useEffect(() => {
27
+ if (!monacoEditor) return;
28
+ const mgr = new CodeContextZoneManager(monacoEditor);
29
+ const off = mgr.onDidChangeZones(() => setZonesVersion((v) => v + 1));
30
+ setManager(mgr);
31
+ return () => {
32
+ off();
33
+ mgr.dispose();
34
+ setManager(null);
35
+ };
36
+ }, [monacoEditor]);
37
+
38
+ const fileTop = options.fileTop;
39
+ const sections = options.sections;
40
+
41
+ // Resolved render list: fileTop pinned above line 1 at ordinal 0, then the
42
+ // host's sections in array order.
43
+ const resolved = useMemo(() => {
44
+ const out: Array<{ spec: ZoneSpec; section: Omit<CodeContextSection, 'line'> }> = [];
45
+ if (fileTop) {
46
+ out.push({ spec: { id: fileTop.id, line: 0, ordinal: 0 }, section: fileTop });
47
+ }
48
+ sections?.forEach((s, i) => {
49
+ out.push({ spec: { id: s.id, line: s.line, ordinal: i + 1 }, section: s });
50
+ });
51
+ return out;
52
+ }, [fileTop, sections]);
53
+
54
+ useEffect(() => {
55
+ if (!manager) return;
56
+ manager.sync(resolved.map((r) => r.spec));
57
+ // Seed expansion defaults for ids we haven't seen before (sections can
58
+ // arrive after mount).
59
+ setExpandedById((prev) => {
60
+ let next: Record<string, boolean> | null = null;
61
+ for (const { section } of resolved) {
62
+ if (seenIds.current.has(section.id)) continue;
63
+ seenIds.current.add(section.id);
64
+ if (section.defaultExpanded) {
65
+ next = next ?? { ...prev };
66
+ next[section.id] = true;
67
+ }
68
+ }
69
+ return next ?? prev;
70
+ });
71
+ }, [manager, resolved]);
72
+
73
+ const onToggleSection = options.onToggleSection;
74
+ const handleToggle = useCallback(
75
+ (id: string) => {
76
+ setExpandedById((prev) => {
77
+ const expanded = !prev[id];
78
+ onToggleSection?.(id, expanded);
79
+ return { ...prev, [id]: expanded };
80
+ });
81
+ },
82
+ [onToggleSection],
83
+ );
84
+
85
+ const handleMeasure = useCallback(
86
+ (id: string, px: number) => manager?.setHeight(id, px),
87
+ [manager],
88
+ );
89
+
90
+ const handleRevealLine = useCallback(
91
+ (line: number) => {
92
+ monacoEditor?.revealLineInCenter(line);
93
+ monacoEditor?.setPosition({ lineNumber: line, column: 1 });
94
+ },
95
+ [monacoEditor],
96
+ );
97
+
98
+ if (!manager) return null;
99
+ return (
100
+ <>
101
+ {resolved.map(({ section }) => {
102
+ const domNode = manager.getDomNode(section.id);
103
+ if (!domNode) return null;
104
+ return createPortal(
105
+ <CodeContextSectionView
106
+ key={section.id}
107
+ section={section}
108
+ expanded={!!expandedById[section.id]}
109
+ onToggle={handleToggle}
110
+ linkSchemes={options.linkSchemes}
111
+ onLinkClick={options.onLinkClick}
112
+ onRevealLine={handleRevealLine}
113
+ onMeasure={handleMeasure}
114
+ />,
115
+ domNode,
116
+ section.id,
117
+ );
118
+ })}
119
+ </>
120
+ );
121
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure reconciler for context-section zones. Diffs the previous resolved spec
3
+ * list against the next by section id, so the zone layer can apply one
4
+ * changeViewZones batch: create `add`, delete `remove`, and re-anchor `move`
5
+ * (same id, different line or ordinal). Content-only changes are not the zone
6
+ * layer's business — React re-renders the portal and the resize observer
7
+ * corrects the height.
8
+ */
9
+
10
+ export interface ZoneSpec {
11
+ id: string;
12
+ /** 1-based anchor line (0 = file-top, above line 1). */
13
+ line: number;
14
+ /** Stable order among zones sharing an anchor line. */
15
+ ordinal: number;
16
+ }
17
+
18
+ export interface ZoneDiff {
19
+ add: ZoneSpec[];
20
+ remove: string[];
21
+ move: ZoneSpec[];
22
+ }
23
+
24
+ export function diffContextSections(prev: ZoneSpec[], next: ZoneSpec[]): ZoneDiff {
25
+ const prevById = new Map(prev.map((z) => [z.id, z]));
26
+ const nextIds = new Set<string>();
27
+ const add: ZoneSpec[] = [];
28
+ const move: ZoneSpec[] = [];
29
+ for (const spec of next) {
30
+ if (nextIds.has(spec.id)) continue; // duplicate id — first occurrence wins
31
+ nextIds.add(spec.id);
32
+ const before = prevById.get(spec.id);
33
+ if (!before) add.push(spec);
34
+ else if (before.line !== spec.line || before.ordinal !== spec.ordinal) move.push(spec);
35
+ }
36
+ const remove = prev.filter((z) => !nextIds.has(z.id)).map((z) => z.id);
37
+ return { add, remove, move };
38
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Host-supplied context sections rendered INSIDE the Monaco (raw/code)
3
+ * surface: collapsible markdown blurbs injected above anchor lines, plus an
4
+ * optional file-top summary. Squisq stays host-agnostic — anchors are plain
5
+ * line numbers, ids are opaque strings, links are intercepted via callback.
6
+ *
7
+ * Accessibility note: Monaco marks its view-zone layer `aria-hidden`, so
8
+ * sections are invisible to screen readers in v1.
9
+ */
10
+
11
+ /**
12
+ * One host-supplied markdown blurb anchored above a line of the code buffer.
13
+ * Rendered as a compact one-line strip that expands in place to the full
14
+ * markdown body.
15
+ */
16
+ export interface CodeContextSection {
17
+ /**
18
+ * Stable identity used to reconcile zones across prop updates — e.g. a
19
+ * symbol id like 'resolveImportEdges@60'. Zones are diffed by id: same id +
20
+ * new line moves the zone; same id + new markdown re-renders in place; a
21
+ * new id creates a new zone.
22
+ */
23
+ id: string;
24
+ /**
25
+ * 1-based line the section renders ABOVE. Out-of-range values are clamped
26
+ * by Monaco. In editable buffers the zone rides Monaco's whitespace
27
+ * semantics — it shifts as lines are inserted/deleted above it and
28
+ * collapses onto the previous line if its anchor lines are deleted. Squisq
29
+ * never re-derives anchors from edits; the host re-supplies lines when it
30
+ * re-analyzes the file.
31
+ */
32
+ line: number;
33
+ /**
34
+ * Compact markdown for the collapsed strip. Rendered on one line (block
35
+ * structure flattened, overflow ellipsized). Links work here too and go
36
+ * through `onLinkClick`.
37
+ */
38
+ summaryMarkdown: string;
39
+ /**
40
+ * Full markdown body shown when expanded. Omit while still loading — the
41
+ * expanded view shows a muted loading row and fills in when a later prop
42
+ * update supplies it.
43
+ */
44
+ markdown?: string;
45
+ /** Start expanded. The user's toggle wins after first interaction. Default false. */
46
+ defaultExpanded?: boolean;
47
+ }
48
+
49
+ /** The full context dictionary passed to `EditorShell.codeContext`. */
50
+ export interface CodeContext {
51
+ /**
52
+ * Section pinned above line 1 (file summary). Rendered before any line-1
53
+ * `sections` entry. `line` is implicit.
54
+ */
55
+ fileTop?: Omit<CodeContextSection, 'line'>;
56
+ /** Line-anchored sections. Array order is preserved for equal lines. */
57
+ sections?: CodeContextSection[];
58
+ /**
59
+ * Extra URI schemes section links may use (e.g. `['gezel-nav']`).
60
+ * http/https/mailto/tel are always allowed; executable schemes
61
+ * (javascript:, data:) are never allowed regardless.
62
+ */
63
+ linkSchemes?: readonly string[];
64
+ /**
65
+ * Intercepts link clicks inside sections, receiving the href exactly as
66
+ * authored in the markdown. Return `false` to let the browser's default
67
+ * navigation proceed; any other return (or void) suppresses it. Fragment
68
+ * links of the form `#L<digits>` are handled natively by squisq (reveal
69
+ * that line in the editor) and never reach this callback. When omitted:
70
+ * http(s)/mailto links open normally, custom-scheme links do nothing.
71
+ */
72
+ onLinkClick?: (href: string, meta: { sectionId: string }) => boolean | undefined;
73
+ /** Notified on expand/collapse — lets hosts lazy-load bodies on first expand. */
74
+ onToggleSection?: (sectionId: string, expanded: boolean) => void;
75
+ }
@@ -18,6 +18,7 @@ import { DiagramMaximizedOverlay } from './DiagramMaximizedOverlay';
18
18
  import { useDiagramData } from './useDiagramData';
19
19
  import { findDiagramHeadingPos } from './DiagramExtension';
20
20
  import { SceneBlockToolbar, type SceneBlockAction } from '../scene/SceneBlockToolbar';
21
+ import { SceneSideToolbar } from '../scene/SceneSideToolbar';
21
22
  import { nodeIdFromCardLayerId, NODE_WIDTH, NODE_HEIGHT } from '../scene';
22
23
  import { Icon } from '../Icon';
23
24
  import {
@@ -226,8 +227,6 @@ export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: D
226
227
  />
227
228
  );
228
229
 
229
- const sideToolbar = <div className="squisq-scene-side-toolbar">{toolbar}</div>;
230
-
231
230
  if (maximized) {
232
231
  return (
233
232
  <div
@@ -235,9 +234,10 @@ export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: D
235
234
  style={effectiveHeight != null ? { height: effectiveHeight } : undefined}
236
235
  >
237
236
  <DiagramMaximizedOverlay host={host ?? null} onClose={() => setMaximized(false)}>
237
+ {/* Maximized has a full screen for a static right column — no collapse. */}
238
238
  <div className="squisq-scene-block-max">
239
239
  {canvas}
240
- {sideToolbar}
240
+ <div className="squisq-scene-side-toolbar">{toolbar}</div>
241
241
  </div>
242
242
  </DiagramMaximizedOverlay>
243
243
  </div>
@@ -246,6 +246,9 @@ export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: D
246
246
 
247
247
  return (
248
248
  <div className="squisq-scene-shell">
249
+ {/* Before the canvas so the narrow-width fallback bar sits above it; the
250
+ wide-width gutter column is absolute and unaffected by DOM order. */}
251
+ <SceneSideToolbar>{toolbar}</SceneSideToolbar>
249
252
  <div
250
253
  className="squisq-diagram-inline"
251
254
  ref={inlineRef}
@@ -262,7 +265,6 @@ export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: D
262
265
  title="Drag to resize · double-click to reset"
263
266
  />
264
267
  </div>
265
- {sideToolbar}
266
268
  </div>
267
269
  );
268
270
  }
@@ -6,16 +6,13 @@
6
6
  *
7
7
  * - Markdown (Monaco): operate on the raw heading line string.
8
8
  * - WYSIWYG (Tiptap): operate on the heading node's `dataBlockAttrs` string
9
- * (the inner of the Pandoc `{…}` block, no braces matching how
10
- * `tiptapBridge` stores and re-emits it).
9
+ * (Pandoc `{…}` inner) and `dataTemplateParams` string (the params inside
10
+ * the squisq-native `{[…]}` annotation).
11
11
  *
12
- * Transitions are stored in the Pandoc `{#id .class key=value}` attribute
13
- * block, NOT the `{[template …]}` annotation. That mirrors the canonical
14
- * serializer (`core/doc/docToMarkdown.ts` `ensureTransitionAttributes`,
15
- * which always emits the `{…}` form) and `diagram/diagramCommands.ts`, so a
16
- * value set here round-trips through a Doc render without being duplicated
17
- * or moved. Reads still look at the `{[…]}` params too, so a hand-typed
18
- * `{[title transition=fade]}` shows up in the picker.
12
+ * The editor writes transitions to the squisq-native `{[…]}` annotation by
13
+ * default. It still reads legacy Pandoc `{transition=…}` attributes and
14
+ * removes/migrates those keys when rewriting, so the two channels cannot drift
15
+ * after a toolbar edit.
19
16
  *
20
17
  * All the brace-matching / tokenizing / serializing is delegated to the
21
18
  * shared core helpers so this stays in lockstep with the parser by import
@@ -29,6 +26,7 @@ import {
29
26
  serializePandocAttributes,
30
27
  tokenizeAttrTokens,
31
28
  splitKeyValueToken,
29
+ quoteAttrValue,
32
30
  type HeadingAttributes,
33
31
  } from '@bendyline/squisq/markdown';
34
32
 
@@ -85,6 +83,14 @@ function applyFieldsToParams(
85
83
  return out;
86
84
  }
87
85
 
86
+ function removeFieldsFromAttrs(attrs: HeadingAttributes): HeadingAttributes {
87
+ if (!attrs.params) return attrs;
88
+ const params = applyFieldsToParams(attrs.params, EMPTY_TRANSITION);
89
+ if (Object.keys(params).length > 0) attrs.params = params;
90
+ else delete attrs.params;
91
+ return attrs;
92
+ }
93
+
88
94
  /** Parse a bare `key=value …` token string into a params map. */
89
95
  function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<string, string> {
90
96
  const tokens = tokenizeAttrTokens(input);
@@ -96,6 +102,39 @@ function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<s
96
102
  return params;
97
103
  }
98
104
 
105
+ function templatePartsFromInner(inner: string | null | undefined): {
106
+ template: string | undefined;
107
+ params: Record<string, string>;
108
+ } {
109
+ if (!inner) return { template: undefined, params: {} };
110
+ const tokens = tokenizeAttrTokens(inner);
111
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
112
+ const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
113
+ const startIdx = template ? 1 : 0;
114
+ const params: Record<string, string> = {};
115
+ for (const token of tokens.slice(startIdx)) {
116
+ const kv = splitKeyValueToken(token);
117
+ if (kv) params[kv.key] = kv.value;
118
+ }
119
+ return { template, params };
120
+ }
121
+
122
+ function paramsToInner(params: Record<string, string>): string | null {
123
+ const parts = Object.entries(params).map(([key, value]) => `${key}=${quoteAttrValue(value)}`);
124
+ return parts.length > 0 ? parts.join(' ') : null;
125
+ }
126
+
127
+ function templateAnnotationOrNull(
128
+ template: string | undefined,
129
+ params: Record<string, string>,
130
+ ): string | null {
131
+ const parts: string[] = [];
132
+ if (template) parts.push(template);
133
+ const paramInner = paramsToInner(params);
134
+ if (paramInner) parts.push(paramInner);
135
+ return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
136
+ }
137
+
99
138
  // ============================================
100
139
  // Heading line (Markdown / Monaco view)
101
140
  // ============================================
@@ -141,7 +180,7 @@ function splitHeadingLine(line: string): SplitHeadingLine | null {
141
180
 
142
181
  /**
143
182
  * Read the transition fields off a heading line. Looks in both the Pandoc
144
- * `{…}` block (canonical) and the `{[…]}` template params (hand-typed),
183
+ * `{…}` block (legacy) and the `{[…]}` template params (canonical),
145
184
  * with the Pandoc block taking precedence. Returns the empty transition for
146
185
  * non-heading lines.
147
186
  */
@@ -149,7 +188,7 @@ export function readHeadingLineTransition(line: string): TransitionFields {
149
188
  const split = splitHeadingLine(line);
150
189
  if (!split) return { ...EMPTY_TRANSITION };
151
190
  const fromTemplate = split.templateText
152
- ? paramsFromTokenString(stripTemplateBraces(split.templateText), true)
191
+ ? templatePartsFromInner(stripTemplateBraces(split.templateText)).params
153
192
  : {};
154
193
  const fromPandoc = split.pandocInner
155
194
  ? (parsePandocAttrTokens(split.pandocInner).params ?? {})
@@ -159,8 +198,9 @@ export function readHeadingLineTransition(line: string): TransitionFields {
159
198
 
160
199
  /**
161
200
  * Return `line` with its transition rewritten from `next`, writing into the
162
- * Pandoc `{…}` block and leaving the `{[…]}` template annotation untouched.
163
- * Non-heading lines are returned unchanged.
201
+ * squisq-native `{[]}` annotation. Legacy Pandoc transition keys are removed
202
+ * while preserving ids, classes, and other Pandoc params. Non-heading lines are
203
+ * returned unchanged.
164
204
  */
165
205
  export function setHeadingLineTransition(line: string, next: TransitionFields): string {
166
206
  const split = splitHeadingLine(line);
@@ -168,12 +208,18 @@ export function setHeadingLineTransition(line: string, next: TransitionFields):
168
208
  const attrs: HeadingAttributes = split.pandocInner
169
209
  ? parsePandocAttrTokens(split.pandocInner)
170
210
  : {};
171
- attrs.params = applyFieldsToParams(attrs.params ?? {}, next);
211
+ removeFieldsFromAttrs(attrs);
172
212
  const pandoc = pandocBlockOrNull(attrs);
173
213
 
214
+ const templateParts = templatePartsFromInner(
215
+ split.templateText ? stripTemplateBraces(split.templateText) : null,
216
+ );
217
+ const templateParams = applyFieldsToParams(templateParts.params, next);
218
+ const template = templateAnnotationOrNull(templateParts.template, templateParams);
219
+
174
220
  let out = split.prefix + split.text;
175
221
  if (pandoc) out += ` ${pandoc}`;
176
- if (split.templateText) out += ` ${split.templateText}`;
222
+ if (template) out += ` ${template}`;
177
223
  return out;
178
224
  }
179
225
 
@@ -188,8 +234,10 @@ function stripTemplateBraces(templateText: string): string {
188
234
  // ============================================
189
235
 
190
236
  /**
191
- * Read the transition fields from a heading node's `dataBlockAttrs` (Pandoc
192
- * inner) plus `dataTemplateParams` (the `{[…]}` params). Pandoc wins.
237
+ * Read the transition fields from a heading node's `dataBlockAttrs` (legacy
238
+ * Pandoc inner) plus `dataTemplateParams` (canonical `{[…]}` params). Pandoc
239
+ * wins so the picker mirrors the value that `markdownToDoc` will render when
240
+ * both channels are present.
193
241
  */
194
242
  export function readBlockAttrsTransition(
195
243
  blockAttrsInner: string | null | undefined,
@@ -200,11 +248,38 @@ export function readBlockAttrsTransition(
200
248
  return fieldsFromParams({ ...fromTemplate, ...fromPandoc });
201
249
  }
202
250
 
251
+ export interface HeadingTransitionAttrs {
252
+ /** Inner of the Pandoc `{…}` block, without braces. */
253
+ blockAttrsInner: string | null;
254
+ /** Param string inside the `{[…]}` annotation, without the template token. */
255
+ templateParams: string | null;
256
+ }
257
+
258
+ /**
259
+ * Rewrite a heading node's transition for Tiptap, writing the transition
260
+ * family into `dataTemplateParams` and removing any legacy transition keys
261
+ * from `dataBlockAttrs`.
262
+ */
263
+ export function setHeadingAttrsTransition(
264
+ blockAttrsInner: string | null | undefined,
265
+ templateParams: string | null | undefined,
266
+ next: TransitionFields,
267
+ ): HeadingTransitionAttrs {
268
+ const attrs: HeadingAttributes = blockAttrsInner ? parsePandocAttrTokens(blockAttrsInner) : {};
269
+ removeFieldsFromAttrs(attrs);
270
+ const pandoc = pandocBlockOrNull(attrs);
271
+
272
+ const params = applyFieldsToParams(paramsFromTokenString(templateParams ?? '', false), next);
273
+ return {
274
+ blockAttrsInner: pandoc ? pandoc.slice(1, -1) : null,
275
+ templateParams: paramsToInner(params),
276
+ };
277
+ }
278
+
203
279
  /**
204
- * Rewrite the transition in a heading node's `dataBlockAttrs` inner string.
205
- * Returns the new inner (no braces), or null when the block carries no
206
- * attributes at all matching how `tiptapBridge` stores `dataBlockAttrs`
207
- * (absent attribute → null, not `{}`).
280
+ * Legacy single-channel writer for callers that only own `dataBlockAttrs`.
281
+ * Internal editor surfaces should use {@link setHeadingAttrsTransition} so new
282
+ * transition edits land in the squisq-native `{[…]}` params.
208
283
  */
209
284
  export function setBlockAttrsTransition(
210
285
  blockAttrsInner: string | null | undefined,
package/src/index.ts CHANGED
@@ -19,7 +19,11 @@
19
19
 
20
20
  // Shell (top-level component)
21
21
  export { EditorShell } from './EditorShell.js';
22
- export type { EditorShellProps, EditorTheme } from './EditorShell.js';
22
+ export type { EditorShellProps, EditorColorScheme } from './EditorShell.js';
23
+
24
+ // Code-context sections (host-supplied markdown injected into the code surface)
25
+ export { CodeContextZones } from './codeContext/CodeContextZones.js';
26
+ export type { CodeContext, CodeContextSection } from './codeContext/types.js';
23
27
 
24
28
  // FolderView — standalone folder browser surface (companion to the shell)
25
29
  export { FolderView } from './FolderView.js';
@@ -100,6 +104,8 @@ export type { EmojiEntry, EmojiCategory } from './emojiData.js';
100
104
  export {
101
105
  PreviewSettingsProvider,
102
106
  PreviewToolbarControls,
107
+ PreviewModeSwitch,
108
+ PreviewFormatSwitch,
103
109
  usePreviewSettings,
104
110
  } from './PreviewControls.js';
105
111
  export type { PreviewSettings } from './PreviewControls.js';
@@ -135,10 +141,11 @@ export {
135
141
  readHeadingLineTransition,
136
142
  setHeadingLineTransition,
137
143
  readBlockAttrsTransition,
144
+ setHeadingAttrsTransition,
138
145
  setBlockAttrsTransition,
139
146
  EMPTY_TRANSITION,
140
147
  } from './headingTransition.js';
141
- export type { TransitionFields } from './headingTransition.js';
148
+ export type { HeadingTransitionAttrs, TransitionFields } from './headingTransition.js';
142
149
  export {
143
150
  readBlockAttrsParams,
144
151
  readBlockAttrsValue,
@@ -178,6 +185,31 @@ export {
178
185
  processTextFiles,
179
186
  } from './utils/dropUtils.js';
180
187
 
188
+ // Monaco lazy loader — exported so hosts embedding RawEditor-like surfaces
189
+ // can share the same load-once Monaco bootstrap.
190
+ export { useMonacoLoader } from './useMonacoLoader.js';
191
+ export type { UseMonacoLoaderResult } from './useMonacoLoader.js';
192
+
193
+ // Custom themes — provider stack (doc frontmatter + browser-local library)
194
+ export { CustomThemeProvider, useCustomThemes, useDocCustomThemes } from './customThemes/index.js';
195
+ export type {
196
+ CustomThemeContextValue,
197
+ CustomThemeProviderProps,
198
+ DocCustomThemes,
199
+ } from './customThemes/index.js';
200
+
201
+ // Custom templates — provider stack (doc frontmatter + browser-local library)
202
+ export {
203
+ CustomTemplateProvider,
204
+ useCustomTemplates,
205
+ useDocCustomTemplates,
206
+ } from './customTemplates/index.js';
207
+ export type {
208
+ CustomTemplateContextValue,
209
+ CustomTemplateProviderProps,
210
+ DocCustomTemplates,
211
+ } from './customTemplates/index.js';
212
+
181
213
  // Bridge utilities
182
214
  export { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge.js';
183
215