@bendyline/squisq-editor-react 1.5.2 → 1.6.0
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/dist/index.d.ts +757 -20
- package/dist/index.js +16910 -6844
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +143 -0
- package/src/EditorShell.tsx +200 -120
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +38 -3
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +180 -8
- package/src/RawEditor.tsx +216 -29
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +528 -174
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +61 -31
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +24 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +77 -0
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -2
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1615 -203
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +4 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +105 -0
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
package/src/RawEditor.tsx
CHANGED
|
@@ -7,29 +7,36 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useRef, useCallback, useEffect } from 'react';
|
|
10
|
-
import Editor, {
|
|
11
|
-
|
|
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';
|
|
15
|
+
import { BLOCK_META_KEY_DESCRIPTORS, tokenizeAttrTokens } from '@bendyline/squisq/markdown';
|
|
20
16
|
import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
import { useMonacoLoader } from './useMonacoLoader';
|
|
18
|
+
|
|
19
|
+
// Monaco is loaded lazily through `useMonacoLoader` (see the hook for the
|
|
20
|
+
// rationale). The type-only `import type * as monaco from 'monaco-editor'`
|
|
21
|
+
// above gives us `monaco.editor.IStandaloneCodeEditor`, `monaco.Range`,
|
|
22
|
+
// etc. for typing without pulling the package into the static module
|
|
23
|
+
// graph — which is the whole point: a consumer importing `JsonEditor` or
|
|
24
|
+
// a type from the package barrel no longer drags ~9MB of language
|
|
25
|
+
// services into the resolver.
|
|
23
26
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
+
// Consumers that *do* want the raw editor can still slim the bundle by
|
|
28
|
+
// aliasing `monaco-editor` to a custom entry in their bundler config.
|
|
29
|
+
// For example with Vite:
|
|
27
30
|
//
|
|
28
31
|
// resolve: { alias: [{ find: /^monaco-editor$/, replacement: './monaco-slim.ts' }] }
|
|
29
32
|
//
|
|
30
|
-
// Where monaco-slim.ts re-exports 'monaco-editor/esm/vs/editor/editor.api'
|
|
31
|
-
// only the language contributions needed
|
|
32
|
-
|
|
33
|
+
// Where monaco-slim.ts re-exports 'monaco-editor/esm/vs/editor/editor.api'
|
|
34
|
+
// plus only the language contributions actually needed
|
|
35
|
+
// (`basic-languages/monaco.contribution` for the broad TM grammars,
|
|
36
|
+
// and any of `language/{css,html,json,typescript}/monaco.contribution`
|
|
37
|
+
// for the rich language services). Skipping the language contributions
|
|
38
|
+
// entirely means `defaultLanguage` becomes inert — no tokenizer
|
|
39
|
+
// registered, so every file renders as plain foreground text.
|
|
33
40
|
|
|
34
41
|
// Squisq Monaco themes: same syntax highlighting as vs / vs-dark, but with
|
|
35
42
|
// Monaco's internal gutter (line numbers + folding margin) and overview
|
|
@@ -81,13 +88,15 @@ export function RawEditor({
|
|
|
81
88
|
submitOnEnter,
|
|
82
89
|
readOnly = false,
|
|
83
90
|
}: RawEditorProps) {
|
|
84
|
-
const {
|
|
91
|
+
const { editorSource, setEditorSource, setMonacoEditor, language, mentionProvider, doc } =
|
|
85
92
|
useEditorContext();
|
|
93
|
+
const { monaco: monacoNs, ready: monacoReady } = useMonacoLoader();
|
|
86
94
|
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
|
87
95
|
const isExternalUpdate = useRef(false);
|
|
88
96
|
const completionDisposable = useRef<monaco.IDisposable | null>(null);
|
|
89
97
|
const mentionCompletionDisposable = useRef<monaco.IDisposable | null>(null);
|
|
90
98
|
const iconCompletionDisposable = useRef<monaco.IDisposable | null>(null);
|
|
99
|
+
const attrCompletionDisposable = useRef<monaco.IDisposable | null>(null);
|
|
91
100
|
const iconGlyphDecorations = useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
|
|
92
101
|
const dropCleanupRef = useRef<(() => void) | null>(null);
|
|
93
102
|
const keyDisposable = useRef<monaco.IDisposable | null>(null);
|
|
@@ -103,6 +112,35 @@ export function RawEditor({
|
|
|
103
112
|
mentionProviderRef.current = mentionProvider;
|
|
104
113
|
}, [mentionProvider]);
|
|
105
114
|
|
|
115
|
+
// Template completion list: built-in registry names plus any custom
|
|
116
|
+
// templates inlined in the active doc's frontmatter (`doc.customTemplates`
|
|
117
|
+
// — the same set that actually renders via `{[name]}`). Held in a ref
|
|
118
|
+
// (like mentionProvider above) so the once-registered provider always
|
|
119
|
+
// reads the latest set — custom templates created after mount must show
|
|
120
|
+
// up without re-mounting Monaco.
|
|
121
|
+
const docCustomTemplates = doc?.customTemplates;
|
|
122
|
+
const templateEntriesRef = useRef<{ name: string; detail: string }[] | null>(null);
|
|
123
|
+
if (templateEntriesRef.current === null) {
|
|
124
|
+
templateEntriesRef.current = getAvailableTemplates().map((name) => ({
|
|
125
|
+
name,
|
|
126
|
+
detail: 'Block template',
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const builtIns = getAvailableTemplates().map((name) => ({
|
|
131
|
+
name,
|
|
132
|
+
detail: 'Block template',
|
|
133
|
+
}));
|
|
134
|
+
const seen = new Set(builtIns.map((b) => b.name));
|
|
135
|
+
const custom = (docCustomTemplates ?? [])
|
|
136
|
+
.filter((t) => !seen.has(t.name))
|
|
137
|
+
.map((t) => ({
|
|
138
|
+
name: t.name,
|
|
139
|
+
detail: t.label ? `Custom — ${t.label}` : 'Custom template',
|
|
140
|
+
}));
|
|
141
|
+
templateEntriesRef.current = [...builtIns, ...custom];
|
|
142
|
+
}, [docCustomTemplates]);
|
|
143
|
+
|
|
106
144
|
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
|
|
107
145
|
monaco.editor.defineTheme('squisq-light', {
|
|
108
146
|
base: 'vs',
|
|
@@ -141,11 +179,12 @@ export function RawEditor({
|
|
|
141
179
|
mentionCompletionDisposable.current = null;
|
|
142
180
|
iconCompletionDisposable.current?.dispose();
|
|
143
181
|
iconCompletionDisposable.current = null;
|
|
182
|
+
attrCompletionDisposable.current?.dispose();
|
|
183
|
+
attrCompletionDisposable.current = null;
|
|
144
184
|
|
|
145
185
|
// Register the `{[template]}` completion provider only for markdown
|
|
146
186
|
// files — it's meaningless for TypeScript, JSON, Python, etc.
|
|
147
187
|
if (language === 'markdown') {
|
|
148
|
-
const templates = getAvailableTemplates();
|
|
149
188
|
completionDisposable.current = monaco.languages.registerCompletionItemProvider('markdown', {
|
|
150
189
|
triggerCharacters: ['['],
|
|
151
190
|
provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) {
|
|
@@ -159,6 +198,12 @@ export function RawEditor({
|
|
|
159
198
|
const bracketIdx = textBeforeCursor.lastIndexOf('{[');
|
|
160
199
|
if (bracketIdx === -1) return { suggestions: [] };
|
|
161
200
|
|
|
201
|
+
// Template names are the FIRST token after `{[`. Once a space
|
|
202
|
+
// follows the name we're in the `key=value` attribute region —
|
|
203
|
+
// the attribute provider handles that, so bail here to avoid
|
|
204
|
+
// offering template names mid-attribute.
|
|
205
|
+
if (/\s/.test(textBeforeCursor.slice(bracketIdx + 2))) return { suggestions: [] };
|
|
206
|
+
|
|
162
207
|
// When Monaco's bracket auto-pair has already produced the
|
|
163
208
|
// closing `]}` we just leave it in place and skip the
|
|
164
209
|
// suffix — otherwise accepting `sectionHeader` on
|
|
@@ -174,13 +219,13 @@ export function RawEditor({
|
|
|
174
219
|
position.column,
|
|
175
220
|
);
|
|
176
221
|
|
|
177
|
-
const suggestions =
|
|
222
|
+
const suggestions = (templateEntriesRef.current ?? []).map(({ name, detail }) => ({
|
|
178
223
|
label: name,
|
|
179
224
|
filterText: name,
|
|
180
225
|
kind: monaco.languages.CompletionItemKind.Value,
|
|
181
226
|
insertText: name + suffix,
|
|
182
227
|
range,
|
|
183
|
-
detail
|
|
228
|
+
detail,
|
|
184
229
|
sortText: name,
|
|
185
230
|
}));
|
|
186
231
|
|
|
@@ -322,6 +367,110 @@ export function RawEditor({
|
|
|
322
367
|
},
|
|
323
368
|
},
|
|
324
369
|
);
|
|
370
|
+
|
|
371
|
+
// `{[name key=value]}` block-meta attribute completion. Fires on
|
|
372
|
+
// heading lines once the cursor is past the template name (the
|
|
373
|
+
// first token), where attributes like `transition=` apply to the
|
|
374
|
+
// block. Two modes:
|
|
375
|
+
// - bare token → suggest attribute KEYS (insert `key=`)
|
|
376
|
+
// - after `key=` → suggest that key's VALUES, when it has a
|
|
377
|
+
// closed set (e.g. transition / transitionDirection)
|
|
378
|
+
// The descriptor list comes from core so keys and value enums stay
|
|
379
|
+
// in lockstep with what `coerceAnnotationValues` actually accepts.
|
|
380
|
+
attrCompletionDisposable.current = monaco.languages.registerCompletionItemProvider(
|
|
381
|
+
'markdown',
|
|
382
|
+
{
|
|
383
|
+
triggerCharacters: ['=', ' '],
|
|
384
|
+
provideCompletionItems(model, position) {
|
|
385
|
+
const lineContent = model.getLineContent(position.lineNumber);
|
|
386
|
+
// Block-meta attributes only mean something on a heading's
|
|
387
|
+
// template annotation — same gate as the template provider.
|
|
388
|
+
if (!/^#{1,6}\s/.test(lineContent)) return { suggestions: [] };
|
|
389
|
+
|
|
390
|
+
const textBeforeCursor = lineContent.substring(0, position.column - 1);
|
|
391
|
+
const bracketIdx = textBeforeCursor.lastIndexOf('{[');
|
|
392
|
+
if (bracketIdx === -1) return { suggestions: [] };
|
|
393
|
+
|
|
394
|
+
// Bail if a `]` already closed the annotation before the
|
|
395
|
+
// cursor — we'd be past it, not inside the attribute list.
|
|
396
|
+
const innerBefore = textBeforeCursor.slice(bracketIdx + 2);
|
|
397
|
+
if (innerBefore.includes(']')) return { suggestions: [] };
|
|
398
|
+
// The first token is the template name; attributes only begin
|
|
399
|
+
// after a whitespace separator. No space yet → still the name.
|
|
400
|
+
if (!/\s/.test(innerBefore)) return { suggestions: [] };
|
|
401
|
+
|
|
402
|
+
// Current token = run of non-whitespace ending at the cursor.
|
|
403
|
+
const lastWsMatch = innerBefore.match(/\s(\S*)$/);
|
|
404
|
+
const currentToken = lastWsMatch ? lastWsMatch[1] : '';
|
|
405
|
+
const tokenStartCol = position.column - currentToken.length;
|
|
406
|
+
|
|
407
|
+
const eqIdx = currentToken.indexOf('=');
|
|
408
|
+
if (eqIdx >= 0) {
|
|
409
|
+
// ── Value mode: `key=<partial>` ──
|
|
410
|
+
const key = currentToken.slice(0, eqIdx);
|
|
411
|
+
const descriptor = BLOCK_META_KEY_DESCRIPTORS.find((d) => d.key === key);
|
|
412
|
+
if (!descriptor?.values) return { suggestions: [] };
|
|
413
|
+
const range = new monaco.Range(
|
|
414
|
+
position.lineNumber,
|
|
415
|
+
tokenStartCol + eqIdx + 1, // after `=`
|
|
416
|
+
position.lineNumber,
|
|
417
|
+
position.column,
|
|
418
|
+
);
|
|
419
|
+
return {
|
|
420
|
+
suggestions: descriptor.values.map((value, i) => ({
|
|
421
|
+
label: value,
|
|
422
|
+
filterText: value,
|
|
423
|
+
kind: monaco.languages.CompletionItemKind.EnumMember,
|
|
424
|
+
insertText: value,
|
|
425
|
+
range,
|
|
426
|
+
detail: descriptor.description,
|
|
427
|
+
sortText: String(i).padStart(4, '0'),
|
|
428
|
+
})),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Key mode: suggest attribute names not already present ──
|
|
433
|
+
// Collect keys already set anywhere in the annotation so we
|
|
434
|
+
// don't re-offer them; keep the key under the cursor eligible
|
|
435
|
+
// so re-editing an existing `key=` still suggests it.
|
|
436
|
+
const afterBracket = lineContent.slice(bracketIdx + 2);
|
|
437
|
+
const closeIdx = afterBracket.indexOf(']}');
|
|
438
|
+
const fullInner = closeIdx === -1 ? afterBracket : afterBracket.slice(0, closeIdx);
|
|
439
|
+
const present = new Set(
|
|
440
|
+
tokenizeAttrTokens(fullInner)
|
|
441
|
+
.map((t) => {
|
|
442
|
+
const i = t.indexOf('=');
|
|
443
|
+
return i > 0 ? t.slice(0, i) : null;
|
|
444
|
+
})
|
|
445
|
+
.filter((k): k is string => k != null),
|
|
446
|
+
);
|
|
447
|
+
present.delete(currentToken);
|
|
448
|
+
|
|
449
|
+
const range = new monaco.Range(
|
|
450
|
+
position.lineNumber,
|
|
451
|
+
tokenStartCol,
|
|
452
|
+
position.lineNumber,
|
|
453
|
+
position.column,
|
|
454
|
+
);
|
|
455
|
+
const suggestions = BLOCK_META_KEY_DESCRIPTORS.filter((d) => !present.has(d.key)).map(
|
|
456
|
+
(d, i) => ({
|
|
457
|
+
label: d.key,
|
|
458
|
+
filterText: d.key,
|
|
459
|
+
kind: monaco.languages.CompletionItemKind.Property,
|
|
460
|
+
insertText: `${d.key}=`,
|
|
461
|
+
range,
|
|
462
|
+
detail: d.values ? d.description : `${d.description} — ${d.valueHint}`,
|
|
463
|
+
sortText: String(i).padStart(4, '0'),
|
|
464
|
+
// Closed-set keys chain straight into their value list.
|
|
465
|
+
...(d.values
|
|
466
|
+
? { command: { id: 'editor.action.triggerSuggest', title: '' } }
|
|
467
|
+
: {}),
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
return { suggestions };
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
);
|
|
325
474
|
}
|
|
326
475
|
|
|
327
476
|
// Chat-composer mode: intercept Enter before Monaco inserts a newline.
|
|
@@ -399,6 +548,8 @@ export function RawEditor({
|
|
|
399
548
|
mentionCompletionDisposable.current = null;
|
|
400
549
|
iconCompletionDisposable.current?.dispose();
|
|
401
550
|
iconCompletionDisposable.current = null;
|
|
551
|
+
attrCompletionDisposable.current?.dispose();
|
|
552
|
+
attrCompletionDisposable.current = null;
|
|
402
553
|
iconGlyphDecorations.current?.clear();
|
|
403
554
|
iconGlyphDecorations.current = null;
|
|
404
555
|
dropCleanupRef.current?.();
|
|
@@ -412,24 +563,31 @@ export function RawEditor({
|
|
|
412
563
|
(value) => {
|
|
413
564
|
if (isExternalUpdate.current) return;
|
|
414
565
|
if (value !== undefined) {
|
|
415
|
-
|
|
566
|
+
setEditorSource(value);
|
|
416
567
|
}
|
|
417
568
|
},
|
|
418
|
-
[
|
|
569
|
+
[setEditorSource],
|
|
419
570
|
);
|
|
420
571
|
|
|
421
|
-
// When external changes happen (e.g. from WYSIWYG
|
|
572
|
+
// When external changes happen (e.g. from WYSIWYG, or navigating to another
|
|
573
|
+
// block in block-at-a-time mode), update Monaco. In block mode `editorSource`
|
|
574
|
+
// is the active block's slice, so this swaps Monaco's content on navigation.
|
|
422
575
|
useEffect(() => {
|
|
423
576
|
const editor = editorRef.current;
|
|
424
577
|
if (editor) {
|
|
425
578
|
const currentValue = editor.getValue();
|
|
426
|
-
|
|
579
|
+
// Ignore trailing-whitespace-only differences from block-mode splice
|
|
580
|
+
// normalization — calling setValue for those resets the Monaco cursor.
|
|
581
|
+
if (
|
|
582
|
+
currentValue !== editorSource &&
|
|
583
|
+
currentValue.replace(/\s+$/, '') !== editorSource.replace(/\s+$/, '')
|
|
584
|
+
) {
|
|
427
585
|
isExternalUpdate.current = true;
|
|
428
|
-
editor.setValue(
|
|
586
|
+
editor.setValue(editorSource);
|
|
429
587
|
isExternalUpdate.current = false;
|
|
430
588
|
}
|
|
431
589
|
}
|
|
432
|
-
}, [
|
|
590
|
+
}, [editorSource]);
|
|
433
591
|
|
|
434
592
|
// ── Inline FontAwesome glyph decorations ────────────
|
|
435
593
|
// Walk the markdown source on every change, find each resolvable
|
|
@@ -439,7 +597,10 @@ export function RawEditor({
|
|
|
439
597
|
// and weight; the codepoint character is the decoration's content.
|
|
440
598
|
useEffect(() => {
|
|
441
599
|
const editor = editorRef.current;
|
|
442
|
-
|
|
600
|
+
// `monacoNs` is read from the lazy loader's state rather than a
|
|
601
|
+
// top-level import. Re-run when it transitions from null → loaded
|
|
602
|
+
// so decorations show up the moment monaco is in hand.
|
|
603
|
+
if (!editor || !monacoNs) return;
|
|
443
604
|
if (language !== 'markdown') return;
|
|
444
605
|
const model = editor.getModel();
|
|
445
606
|
if (!model) return;
|
|
@@ -461,7 +622,7 @@ export function RawEditor({
|
|
|
461
622
|
// glyph as content prepended visually to that position.
|
|
462
623
|
const col = match.index + 1; // Monaco columns are 1-based
|
|
463
624
|
decorations.push({
|
|
464
|
-
range: new
|
|
625
|
+
range: new monacoNs.Range(line, col, line, col),
|
|
465
626
|
options: {
|
|
466
627
|
before: {
|
|
467
628
|
content: glyph,
|
|
@@ -477,15 +638,41 @@ export function RawEditor({
|
|
|
477
638
|
} else {
|
|
478
639
|
iconGlyphDecorations.current.set(decorations);
|
|
479
640
|
}
|
|
480
|
-
}, [
|
|
641
|
+
}, [editorSource, language, monacoNs]);
|
|
481
642
|
|
|
482
643
|
const effectiveTheme = SQUISQ_THEMES[theme] ?? theme;
|
|
483
644
|
|
|
645
|
+
// Wait for the lazy monaco namespace + `loader.config()` to settle
|
|
646
|
+
// before mounting `<Editor>`. Without this gate, the @monaco-editor/
|
|
647
|
+
// react singleton loader would fall back to its built-in CDN fetch
|
|
648
|
+
// for any consumer that hasn't aliased monaco-editor — which is the
|
|
649
|
+
// exact regression the lazy-loading move is meant to avoid.
|
|
650
|
+
if (!monacoReady) {
|
|
651
|
+
return (
|
|
652
|
+
<div
|
|
653
|
+
className={className}
|
|
654
|
+
style={{
|
|
655
|
+
width: '100%',
|
|
656
|
+
height: '100%',
|
|
657
|
+
display: 'flex',
|
|
658
|
+
alignItems: 'center',
|
|
659
|
+
justifyContent: 'center',
|
|
660
|
+
color: 'var(--squisq-editor-muted-foreground, #6a6258)',
|
|
661
|
+
fontSize: 13,
|
|
662
|
+
}}
|
|
663
|
+
data-testid="raw-editor"
|
|
664
|
+
data-monaco-loading
|
|
665
|
+
>
|
|
666
|
+
Loading editor…
|
|
667
|
+
</div>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
484
671
|
return (
|
|
485
672
|
<div className={className} style={{ width: '100%', height: '100%' }} data-testid="raw-editor">
|
|
486
673
|
<Editor
|
|
487
674
|
defaultLanguage={language}
|
|
488
|
-
value={
|
|
675
|
+
value={editorSource}
|
|
489
676
|
theme={effectiveTheme}
|
|
490
677
|
beforeMount={handleBeforeMount}
|
|
491
678
|
onMount={handleMount}
|
package/src/RecorderEntry.tsx
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { useCallback } from 'react';
|
|
25
25
|
import { RecorderPanel } from './recorder/RecorderPanel.js';
|
|
26
26
|
import type { RecorderSaveResult } from './recorder/RecorderModal.js';
|
|
27
|
+
import { insertMediaBlock } from './recorder/insertMediaBlock.js';
|
|
27
28
|
import { useEditorContext } from './EditorContext';
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -98,14 +99,10 @@ export function RecorderEntry() {
|
|
|
98
99
|
}
|
|
99
100
|
const audioTag = `<audio src="${result.relativePath}" controls></audio>`;
|
|
100
101
|
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
101
|
-
tiptapEditor
|
|
102
|
-
|
|
103
|
-
.
|
|
104
|
-
|
|
105
|
-
type: 'audio',
|
|
106
|
-
attrs: { src: result.relativePath, controls: true },
|
|
107
|
-
})
|
|
108
|
-
.run();
|
|
102
|
+
insertMediaBlock(tiptapEditor, {
|
|
103
|
+
type: 'audio',
|
|
104
|
+
attrs: { src: result.relativePath, controls: true },
|
|
105
|
+
});
|
|
109
106
|
return;
|
|
110
107
|
}
|
|
111
108
|
if (activeView === 'raw' && monacoEditor) {
|
|
@@ -124,14 +121,10 @@ export function RecorderEntry() {
|
|
|
124
121
|
// aspect ratio is preserved regardless of source dimensions.
|
|
125
122
|
const videoTag = `<video src="${result.relativePath}" controls width="480"></video>`;
|
|
126
123
|
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
127
|
-
tiptapEditor
|
|
128
|
-
|
|
129
|
-
.
|
|
130
|
-
|
|
131
|
-
type: 'video',
|
|
132
|
-
attrs: { src: result.relativePath, controls: true, width: 480 },
|
|
133
|
-
})
|
|
134
|
-
.run();
|
|
124
|
+
insertMediaBlock(tiptapEditor, {
|
|
125
|
+
type: 'video',
|
|
126
|
+
attrs: { src: result.relativePath, controls: true, width: 480 },
|
|
127
|
+
});
|
|
135
128
|
return;
|
|
136
129
|
}
|
|
137
130
|
if (activeView === 'raw' && monacoEditor) {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import Heading from '@tiptap/extension-heading';
|
|
17
17
|
import { templateLabel } from './TemplatePicker';
|
|
18
|
+
import { summarizeBlockProps } from './blockProperties';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* HeadingWithTemplate — drop-in replacement for Tiptap's Heading that
|
|
@@ -40,6 +41,14 @@ export const HeadingWithTemplate = Heading.extend({
|
|
|
40
41
|
return { 'data-template-params': attributes.dataTemplateParams };
|
|
41
42
|
},
|
|
42
43
|
},
|
|
44
|
+
dataBlockAttrs: {
|
|
45
|
+
default: null,
|
|
46
|
+
parseHTML: (element: HTMLElement) => element.getAttribute('data-block-attrs') || null,
|
|
47
|
+
renderHTML: (attributes: Record<string, unknown>) => {
|
|
48
|
+
if (!attributes.dataBlockAttrs) return {};
|
|
49
|
+
return { 'data-block-attrs': attributes.dataBlockAttrs };
|
|
50
|
+
},
|
|
51
|
+
},
|
|
43
52
|
};
|
|
44
53
|
},
|
|
45
54
|
|
|
@@ -47,6 +56,12 @@ export const HeadingWithTemplate = Heading.extend({
|
|
|
47
56
|
const level = node.attrs.level;
|
|
48
57
|
const tag = `h${level}`;
|
|
49
58
|
const templateName = HTMLAttributes['data-template'];
|
|
59
|
+
// Concise summary of authored block properties (transition / timing),
|
|
60
|
+
// painted on the props badge so the canvas shows what's set at a glance.
|
|
61
|
+
const propsSummary = summarizeBlockProps(
|
|
62
|
+
(HTMLAttributes['data-block-attrs'] as string | undefined) ?? null,
|
|
63
|
+
(HTMLAttributes['data-template-params'] as string | undefined) ?? null,
|
|
64
|
+
);
|
|
50
65
|
|
|
51
66
|
// Render heading with a trailing badge span. The badge has no text
|
|
52
67
|
// content — its label is painted via CSS `content: attr(data-template-label)`
|
|
@@ -76,6 +91,7 @@ export const HeadingWithTemplate = Heading.extend({
|
|
|
76
91
|
'data-template-label': templateLabel(templateName),
|
|
77
92
|
},
|
|
78
93
|
],
|
|
94
|
+
propsBadgeSpec(propsSummary),
|
|
79
95
|
];
|
|
80
96
|
}
|
|
81
97
|
|
|
@@ -94,6 +110,34 @@ export const HeadingWithTemplate = Heading.extend({
|
|
|
94
110
|
title: 'Choose block template',
|
|
95
111
|
},
|
|
96
112
|
],
|
|
113
|
+
propsBadgeSpec(propsSummary),
|
|
97
114
|
];
|
|
98
115
|
},
|
|
99
116
|
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The block-properties chip rendered beside the template badge — the on-canvas
|
|
120
|
+
* entry point to the {@link BlockPropertiesPopover} (transition, timing, …).
|
|
121
|
+
*
|
|
122
|
+
* Like the template badge, this is an EMPTY inert span: the sliders glyph and
|
|
123
|
+
* the optional `summary` text are painted via CSS (`::before` / `::after` with
|
|
124
|
+
* `content: attr(data-props-summary)`, using the Font Awesome font the editor
|
|
125
|
+
* already loads). Keeping it childless is deliberate — `tiptapToMarkdown`
|
|
126
|
+
* serializes the rendered heading HTML, so any real child node here would risk
|
|
127
|
+
* leaking into the markdown; a `data-*` attribute is stripped with the span.
|
|
128
|
+
* Clicks are delegated in `WysiwygEditor`.
|
|
129
|
+
*/
|
|
130
|
+
function propsBadgeSpec(summary: string): [string, Record<string, string>] {
|
|
131
|
+
const attrs: Record<string, string> = {
|
|
132
|
+
class: 'squisq-props-badge',
|
|
133
|
+
contenteditable: 'false',
|
|
134
|
+
role: 'button',
|
|
135
|
+
tabindex: '0',
|
|
136
|
+
'aria-haspopup': 'dialog',
|
|
137
|
+
title: summary ? `Block properties — ${summary}` : 'Block properties',
|
|
138
|
+
};
|
|
139
|
+
// Only set the attribute when there's something to show, so the CSS
|
|
140
|
+
// `[data-props-summary]` selector cleanly distinguishes "has properties".
|
|
141
|
+
if (summary) attrs['data-props-summary'] = summary;
|
|
142
|
+
return ['span', attrs];
|
|
143
|
+
}
|