@bendyline/squisq-editor-react 2.1.0 → 2.2.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/LICENSE +21 -0
- package/NOTICE.md +40 -0
- package/dist/index.d.ts +188 -55
- package/dist/index.js +5094 -2948
- package/dist/monaco.js +0 -1
- package/dist/styles/index.css +234 -145
- package/package.json +9 -6
- package/dist/index.js.map +0 -1
- package/dist/monaco.js.map +0 -1
- package/src/BlockCardView.tsx +0 -121
- package/src/BlockPreviewPanel.tsx +0 -69
- package/src/BlockPropertiesPopover.tsx +0 -222
- package/src/DocumentSettingsDialog.tsx +0 -272
- package/src/DropZoneOverlay.tsx +0 -137
- package/src/EditorContext.tsx +0 -1274
- package/src/EditorShell.tsx +0 -1358
- package/src/EmojiPicker.tsx +0 -332
- package/src/FolderView.tsx +0 -131
- package/src/Icon.tsx +0 -26
- package/src/ImageEditor.tsx +0 -395
- package/src/ImageNodeView.tsx +0 -284
- package/src/ImageViewer.tsx +0 -221
- package/src/InlineIcon.ts +0 -84
- package/src/InlinePreviewGutter.tsx +0 -590
- package/src/LinkDialog.tsx +0 -276
- package/src/MediaBin.tsx +0 -500
- package/src/MentionExtension.tsx +0 -261
- package/src/OutlinePanel.tsx +0 -547
- package/src/PlainHtmlPreview.tsx +0 -277
- package/src/PreviewControls.tsx +0 -1265
- package/src/PreviewPanel.tsx +0 -426
- package/src/RawEditor.tsx +0 -743
- package/src/RecorderEntry.tsx +0 -162
- package/src/StatusBar.tsx +0 -51
- package/src/TemplateAnnotation.ts +0 -167
- package/src/TemplateContentPreview.tsx +0 -56
- package/src/TemplatePicker.tsx +0 -1314
- package/src/ThemeCustomizerPanel.tsx +0 -304
- package/src/ThemePicker.tsx +0 -433
- package/src/TimelineBlockPreview.tsx +0 -37
- package/src/TimelineTrack.tsx +0 -671
- package/src/Toolbar.tsx +0 -2885
- package/src/Tooltip.tsx +0 -142
- package/src/TransitionPicker.tsx +0 -390
- package/src/VersionHistoryPanel.tsx +0 -359
- package/src/ViewMenuPanel.tsx +0 -189
- package/src/ViewSwitcher.tsx +0 -56
- package/src/WysiwygEditor.tsx +0 -806
- package/src/__tests__/blockProperties.test.ts +0 -92
- package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +0 -33
- package/src/__tests__/blockRange.test.ts +0 -105
- package/src/__tests__/blockTagActivity.test.ts +0 -183
- package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +0 -75
- package/src/__tests__/buildPreviewDocContent.test.ts +0 -65
- package/src/__tests__/buildPreviewDocTransition.test.ts +0 -92
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +0 -60
- package/src/__tests__/codeContextSectionView.test.tsx +0 -97
- package/src/__tests__/codeContextZoneManager.test.ts +0 -127
- package/src/__tests__/createShapeLayer.test.ts +0 -46
- package/src/__tests__/customLayoutManagerTheme.test.tsx +0 -34
- package/src/__tests__/detectMarkdown.test.ts +0 -73
- package/src/__tests__/diffContextSections.test.ts +0 -39
- package/src/__tests__/documentSettingsDialog.test.tsx +0 -197
- package/src/__tests__/drawingShapeRoundTrip.test.ts +0 -49
- package/src/__tests__/editorScrollHandoff.test.tsx +0 -102
- package/src/__tests__/editorScrollSync.test.ts +0 -65
- package/src/__tests__/editorShellCodeContext.test.tsx +0 -86
- package/src/__tests__/editorShellProps.test.tsx +0 -550
- package/src/__tests__/embeddedMedia.test.ts +0 -48
- package/src/__tests__/emojiPicker.test.tsx +0 -93
- package/src/__tests__/fileKind.test.ts +0 -112
- package/src/__tests__/findMode.test.tsx +0 -104
- package/src/__tests__/findModel.test.ts +0 -55
- package/src/__tests__/headingTransition.test.ts +0 -176
- package/src/__tests__/imageEditAffordance.test.tsx +0 -272
- package/src/__tests__/imageEditModalContract.test.tsx +0 -119
- package/src/__tests__/imageEditorShell.test.tsx +0 -94
- package/src/__tests__/imageEditorState.test.ts +0 -171
- package/src/__tests__/inlinePreviewGutter.test.tsx +0 -62
- package/src/__tests__/inlinePreviewGutterAllBlocks.test.tsx +0 -103
- package/src/__tests__/jsonEditor.test.tsx +0 -223
- package/src/__tests__/layersPanel.test.tsx +0 -167
- package/src/__tests__/layoutChildRoundTrip.test.ts +0 -71
- package/src/__tests__/linkDialogDocPicker.test.tsx +0 -137
- package/src/__tests__/majorApiTypes.test.ts +0 -26
- package/src/__tests__/markdownCodeFence.test.ts +0 -45
- package/src/__tests__/mediaAttachmentFlow.test.ts +0 -110
- package/src/__tests__/mediaBinDrop.test.tsx +0 -90
- package/src/__tests__/mediaReferences.test.ts +0 -97
- package/src/__tests__/narrationSave.test.ts +0 -148
- package/src/__tests__/outlinePanel.test.tsx +0 -169
- package/src/__tests__/outlineSource.test.ts +0 -217
- package/src/__tests__/plainHtmlPreview.test.tsx +0 -125
- package/src/__tests__/presentationMode.test.tsx +0 -290
- package/src/__tests__/previewControls.test.tsx +0 -571
- package/src/__tests__/previewPanelAudioFailure.test.tsx +0 -46
- package/src/__tests__/previewPanelPresentation.test.tsx +0 -126
- package/src/__tests__/previewPanelTransforms.test.tsx +0 -117
- package/src/__tests__/propertiesPanel.test.tsx +0 -69
- package/src/__tests__/rawEditorModelIsolation.test.ts +0 -19
- package/src/__tests__/recorderFormats.test.ts +0 -146
- package/src/__tests__/recorderMediaInsert.test.ts +0 -86
- package/src/__tests__/recorderTheme.test.tsx +0 -42
- package/src/__tests__/recorderTimingJson.test.ts +0 -41
- package/src/__tests__/resolveBlockVisual.test.ts +0 -43
- package/src/__tests__/selectionConversions.test.ts +0 -80
- package/src/__tests__/statusBar.test.tsx +0 -27
- package/src/__tests__/teleprompter.test.tsx +0 -190
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +0 -99
- package/src/__tests__/templateContentPreview.test.ts +0 -101
- package/src/__tests__/templatePickerMetadata.test.ts +0 -32
- package/src/__tests__/templatePickerPortal.test.tsx +0 -62
- package/src/__tests__/timelineSource.test.ts +0 -134
- package/src/__tests__/tiptapBridge.test.ts +0 -575
- package/src/__tests__/tiptapBridgeConformance.test.ts +0 -47
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +0 -109
- package/src/__tests__/tiptapImageRoundTrip.test.ts +0 -73
- package/src/__tests__/toolbarSelectionConversion.test.tsx +0 -190
- package/src/__tests__/tooltip.test.tsx +0 -72
- package/src/__tests__/transformStyleId.test.ts +0 -13
- package/src/__tests__/transitionCatalog.test.ts +0 -64
- package/src/__tests__/useBlockNavigator.test.tsx +0 -67
- package/src/__tests__/useImageEditor.test.tsx +0 -226
- package/src/__tests__/useJsonEditorTokens.test.ts +0 -59
- package/src/__tests__/useMediaRecorder.test.ts +0 -302
- package/src/__tests__/useTimelineClock.test.ts +0 -21
- package/src/__tests__/versionHistory.test.tsx +0 -197
- package/src/__tests__/viewMenuPanel.test.tsx +0 -130
- package/src/__tests__/writeCanvasSettings.test.ts +0 -15
- package/src/__tests__/wysiwygImageUpload.test.ts +0 -69
- package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +0 -56
- package/src/asciiDiagram/AsciiDiagramExtension.ts +0 -343
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +0 -254
- package/src/asciiDiagram/RepairableDiagramExtension.ts +0 -200
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +0 -164
- package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +0 -159
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +0 -284
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +0 -114
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +0 -43
- package/src/asciiDiagram/asciiDiagramCommands.ts +0 -174
- package/src/asciiDiagram/asciiDiagramData.ts +0 -149
- package/src/asciiDiagram/asciiDiagramOps.ts +0 -238
- package/src/asciiDiagram/asciiPaste.ts +0 -19
- package/src/blockProperties.ts +0 -88
- package/src/blockRange.ts +0 -132
- package/src/blockSlice.ts +0 -75
- package/src/blockTagActivity.ts +0 -233
- package/src/buildDocumentPreviewMarkdown.ts +0 -168
- package/src/buildPreviewDoc.ts +0 -407
- package/src/codeContext/CodeContextSectionView.tsx +0 -124
- package/src/codeContext/CodeContextZoneManager.ts +0 -149
- package/src/codeContext/CodeContextZones.tsx +0 -121
- package/src/codeContext/diffContextSections.ts +0 -38
- package/src/codeContext/types.ts +0 -75
- package/src/codeSnippet/CodeSnippetExtension.ts +0 -205
- package/src/codeSnippet/CodeSnippetWidget.tsx +0 -109
- package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +0 -95
- package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +0 -50
- package/src/codeSnippet/codeSnippetCommands.ts +0 -21
- package/src/codeSnippet/codeSnippetData.ts +0 -43
- package/src/codeSnippet/codeSnippetLanguages.ts +0 -216
- package/src/customTemplates/AddBin.tsx +0 -126
- package/src/customTemplates/CustomLayoutManager.tsx +0 -234
- package/src/customTemplates/CustomTemplateContext.tsx +0 -188
- package/src/customTemplates/LayerToolbar.tsx +0 -580
- package/src/customTemplates/ShapeGlyph.tsx +0 -47
- package/src/customTemplates/TemplateDesigner.tsx +0 -433
- package/src/customTemplates/__tests__/library.test.ts +0 -96
- package/src/customTemplates/__tests__/normalizePositions.test.ts +0 -109
- package/src/customTemplates/__tests__/shapeDefs.test.ts +0 -49
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +0 -108
- package/src/customTemplates/designer.css +0 -732
- package/src/customTemplates/index.ts +0 -31
- package/src/customTemplates/library.ts +0 -110
- package/src/customTemplates/normalizePositions.ts +0 -75
- package/src/customTemplates/shapeDefs.ts +0 -131
- package/src/customTemplates/thumbnail.tsx +0 -63
- package/src/customTemplates/tokenDefs.ts +0 -60
- package/src/customTemplates/useDocCustomTemplates.ts +0 -52
- package/src/customTemplates/useMemoryLayerAdapter.ts +0 -129
- package/src/customThemes/CustomThemeContext.tsx +0 -185
- package/src/customThemes/CustomThemeDialog.tsx +0 -316
- package/src/customThemes/ImportThemeSection.tsx +0 -178
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +0 -64
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +0 -47
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +0 -60
- package/src/customThemes/__tests__/importThemeSection.test.tsx +0 -192
- package/src/customThemes/customThemeLibrary.ts +0 -100
- package/src/customThemes/index.ts +0 -37
- package/src/customThemes/themeControls.tsx +0 -229
- package/src/customThemes/themeDraft.ts +0 -288
- package/src/customThemes/useDocCustomThemes.ts +0 -49
- package/src/detectMarkdown.ts +0 -62
- package/src/diagram/DiagramCanvas.tsx +0 -260
- package/src/diagram/DiagramMaximizedOverlay.tsx +0 -46
- package/src/diagram/diagramCommands.ts +0 -604
- package/src/diagram/diagramConstants.ts +0 -17
- package/src/diagram/types.ts +0 -35
- package/src/editorScrollSync.ts +0 -73
- package/src/embeddedMedia.ts +0 -78
- package/src/emojiData.ts +0 -1317
- package/src/fileKind.ts +0 -158
- package/src/find/FindHighlightExtension.ts +0 -81
- package/src/find/FindToolbar.tsx +0 -314
- package/src/find/findModel.ts +0 -77
- package/src/frontmatter.ts +0 -29
- package/src/frontmatterSettings.ts +0 -23
- package/src/headingTransition.ts +0 -291
- package/src/hooks/useFileDrop.ts +0 -266
- package/src/imageEditor/CanvasSurface.tsx +0 -703
- package/src/imageEditor/ImageVersionHistoryDropdown.tsx +0 -396
- package/src/imageEditor/LayersPanel.tsx +0 -218
- package/src/imageEditor/PropertiesPanel.tsx +0 -474
- package/src/imageEditor/Toolbar.tsx +0 -459
- package/src/imageEditor/createShapeLayer.ts +0 -280
- package/src/imageEditor/icons.tsx +0 -68
- package/src/imageEditor/image-editor.css +0 -574
- package/src/imageEditor/layers/EditorImageLayer.tsx +0 -45
- package/src/imageEditor/layers/EditorShapeLayer.tsx +0 -62
- package/src/imageEditor/layers/EditorTextLayer.tsx +0 -45
- package/src/imageEditor/layers/SelectionHandles.tsx +0 -86
- package/src/imageEditor/state.ts +0 -173
- package/src/imageEditor/useImageEditor.ts +0 -385
- package/src/imageEditor/useImageEditorTokens.ts +0 -70
- package/src/index.ts +0 -559
- package/src/jsonEditor/EmbeddedRichTextField.tsx +0 -81
- package/src/jsonEditor/JsonEditor.tsx +0 -81
- package/src/jsonEditor/JsonEditorContext.tsx +0 -81
- package/src/jsonEditor/RenderNode.tsx +0 -87
- package/src/jsonEditor/editors.tsx +0 -745
- package/src/jsonEditor/index.ts +0 -2
- package/src/jsonEditor/json-editor.css +0 -463
- package/src/jsonEditor/useJsonEditorTokens.ts +0 -33
- package/src/markdownCodeFence.ts +0 -72
- package/src/mediaDragMime.ts +0 -32
- package/src/mediaEntries.ts +0 -12
- package/src/mediaReferences.ts +0 -301
- package/src/mermaid/MermaidDiagramCanvas.tsx +0 -693
- package/src/mermaid/MermaidDiagramExtension.ts +0 -243
- package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +0 -184
- package/src/mermaid/MermaidDiagramWidget.tsx +0 -653
- package/src/mermaid/MermaidShapePalette.tsx +0 -245
- package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +0 -361
- package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +0 -35
- package/src/mermaid/__tests__/mermaidRenderer.test.ts +0 -49
- package/src/mermaid/__tests__/mermaidSourceOps.test.ts +0 -213
- package/src/mermaid/__tests__/mermaidSyntax.test.ts +0 -71
- package/src/mermaid/mermaidCommands.ts +0 -34
- package/src/mermaid/mermaidData.ts +0 -31
- package/src/mermaid/mermaidDiagramTypes.ts +0 -325
- package/src/mermaid/mermaidModel.ts +0 -31
- package/src/mermaid/mermaidRenderer.ts +0 -181
- package/src/mermaid/mermaidShapes.ts +0 -113
- package/src/mermaid/mermaidSourceOps.ts +0 -454
- package/src/monaco.ts +0 -35
- package/src/monacoWorkers.ts +0 -89
- package/src/outlineSource.ts +0 -171
- package/src/presentation/PresentationMode.tsx +0 -596
- package/src/rawEditorIsolation.ts +0 -18
- package/src/recorder/RecorderButton.tsx +0 -80
- package/src/recorder/RecorderModal.tsx +0 -706
- package/src/recorder/RecorderPanel.tsx +0 -77
- package/src/recorder/formats.ts +0 -159
- package/src/recorder/hooks/useMediaRecorder.ts +0 -436
- package/src/recorder/hooks/useStreamPreview.ts +0 -47
- package/src/recorder/insertMediaBlock.ts +0 -30
- package/src/recorder/sources/cameraStream.ts +0 -32
- package/src/recorder/sources/micStream.ts +0 -25
- package/src/recorder/sources/screenStream.ts +0 -162
- package/src/recorder/timingJson.ts +0 -66
- package/src/resolveBlockVisual.ts +0 -39
- package/src/scene/Scene.tsx +0 -647
- package/src/scene/SceneBlockExtension.ts +0 -205
- package/src/scene/SceneBlockToolbar.tsx +0 -216
- package/src/scene/SceneBlockWidget.tsx +0 -441
- package/src/scene/ScenePropsBar.tsx +0 -85
- package/src/scene/SceneSelection.tsx +0 -111
- package/src/scene/SceneSideToolbar.tsx +0 -89
- package/src/scene/SceneViewControls.tsx +0 -43
- package/src/scene/SceneViewport.tsx +0 -106
- package/src/scene/ShapePalette.tsx +0 -196
- package/src/scene/__tests__/DiagramAdapter.test.ts +0 -142
- package/src/scene/__tests__/SceneBlockExtension.test.ts +0 -33
- package/src/scene/__tests__/bezierEdit.test.ts +0 -85
- package/src/scene/__tests__/blockLayers.test.ts +0 -57
- package/src/scene/__tests__/fitScale.test.ts +0 -19
- package/src/scene/__tests__/sceneIsolation.test.tsx +0 -119
- package/src/scene/__tests__/shapeLayers.test.ts +0 -106
- package/src/scene/__tests__/useSceneHitTest.test.ts +0 -90
- package/src/scene/__tests__/useScenePanZoom.test.ts +0 -130
- package/src/scene/adapters/DiagramAdapter.ts +0 -79
- package/src/scene/adapters/DrawingAdapter.ts +0 -420
- package/src/scene/adapters/LayoutAdapter.ts +0 -313
- package/src/scene/adapters/blockLayers.ts +0 -159
- package/src/scene/commands/SceneCommand.ts +0 -84
- package/src/scene/commands/drawingCommands.ts +0 -318
- package/src/scene/commands/layoutCommands.ts +0 -301
- package/src/scene/fitScale.ts +0 -17
- package/src/scene/hooks/useSceneHitTest.ts +0 -105
- package/src/scene/hooks/useScenePanZoom.ts +0 -154
- package/src/scene/hooks/useSceneSelection.ts +0 -62
- package/src/scene/index.ts +0 -90
- package/src/scene/layers/DiagramEdges.tsx +0 -142
- package/src/scene/layers/edgeGeometry.ts +0 -104
- package/src/scene/layers/nodeCard.tsx +0 -164
- package/src/scene/layers/renderLayer.tsx +0 -70
- package/src/scene/layers/shapeLayers.ts +0 -201
- package/src/scene/paths/bezierEdit.ts +0 -208
- package/src/scene/scene.css +0 -874
- package/src/scene/text/SceneTextOverlay.tsx +0 -161
- package/src/scene/text/sceneTextChannel.ts +0 -46
- package/src/scene/text/sceneTextConfig.ts +0 -31
- package/src/scene/text/sceneTiptap.ts +0 -36
- package/src/scene/text/useSceneTextEditing.ts +0 -39
- package/src/scene/tools/ConnectTool.ts +0 -209
- package/src/scene/tools/DrawingConnectTool.ts +0 -231
- package/src/scene/tools/PathTool.ts +0 -158
- package/src/scene/tools/PlaceTool.ts +0 -47
- package/src/scene/tools/SceneTool.ts +0 -85
- package/src/scene/tools/SelectTool.ts +0 -300
- package/src/scene/tools/ShapeTool.ts +0 -143
- package/src/scene/tools/TextTool.ts +0 -72
- package/src/scene/tools/TokenTool.ts +0 -95
- package/src/scene/tools/createDrawShapeTool.ts +0 -82
- package/src/selectionConversions.ts +0 -155
- package/src/styles/ascii-diagram.css +0 -79
- package/src/styles/ascii-timeline.css +0 -596
- package/src/styles/code-context.css +0 -155
- package/src/styles/code-snippet.css +0 -76
- package/src/styles/diagram.css +0 -183
- package/src/styles/editor.css +0 -7131
- package/src/styles/folder-view.css +0 -210
- package/src/styles/image-edit-affordance.css +0 -201
- package/src/styles/index.css +0 -20
- package/src/styles/mermaid-diagram.css +0 -487
- package/src/styles/tree-view.css +0 -189
- package/src/teleprompter/TeleprompterControls.tsx +0 -218
- package/src/teleprompter/TeleprompterSelfView.tsx +0 -22
- package/src/teleprompter/TeleprompterSurface.tsx +0 -267
- package/src/teleprompter/TeleprompterView.tsx +0 -338
- package/src/teleprompter/canvasRenderer.ts +0 -161
- package/src/teleprompter/floatingWindow.ts +0 -352
- package/src/teleprompter/index.ts +0 -61
- package/src/teleprompter/pcmWorklet.ts +0 -62
- package/src/teleprompter/recording/insertPreamble.ts +0 -80
- package/src/teleprompter/recording/narrationSave.ts +0 -134
- package/src/teleprompter/recording/useNarrationRecorder.ts +0 -367
- package/src/teleprompter/scrollModel.ts +0 -85
- package/src/teleprompter/teleprompterTheme.ts +0 -303
- package/src/teleprompter/types.ts +0 -38
- package/src/teleprompter/useFloatingWindow.ts +0 -74
- package/src/teleprompter/useMicAnalysis.ts +0 -211
- package/src/teleprompter/useTeleprompter.ts +0 -421
- package/src/templateContentPreviewResolver.ts +0 -360
- package/src/timeline/TimelineEditorWidget.tsx +0 -861
- package/src/timeline/TimelineViewExtension.ts +0 -252
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +0 -414
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +0 -163
- package/src/timeline/__tests__/timelineCommands.test.ts +0 -359
- package/src/timeline/__tests__/timelineOps.test.ts +0 -277
- package/src/timeline/__tests__/timelinePaste.test.ts +0 -34
- package/src/timeline/timelineCommands.ts +0 -421
- package/src/timeline/timelineData.ts +0 -54
- package/src/timeline/timelineOps.ts +0 -381
- package/src/timeline/timelinePaste.ts +0 -8
- package/src/timelineSource.ts +0 -244
- package/src/tiptap/TiptapAudio.tsx +0 -86
- package/src/tiptap/TiptapVideo.tsx +0 -119
- package/src/tiptap/useResolvedMediaSrc.ts +0 -47
- package/src/tiptapBridge.ts +0 -986
- package/src/tooltipPlacement.ts +0 -13
- package/src/transformStyleId.ts +0 -17
- package/src/transitionCatalog.ts +0 -159
- package/src/treeview/TreeOutlineWidget.tsx +0 -390
- package/src/treeview/TreeViewExtension.ts +0 -250
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +0 -156
- package/src/treeview/__tests__/TreeViewExtension.test.ts +0 -122
- package/src/treeview/__tests__/treeOps.test.ts +0 -177
- package/src/treeview/__tests__/treePaste.test.ts +0 -40
- package/src/treeview/__tests__/treeViewCommands.test.ts +0 -179
- package/src/treeview/treeOps.ts +0 -231
- package/src/treeview/treePaste.ts +0 -13
- package/src/treeview/treeViewCommands.ts +0 -99
- package/src/treeview/treeViewData.ts +0 -41
- package/src/types/monaco-shims.d.ts +0 -10
- package/src/useBlockNavigator.ts +0 -153
- package/src/useHeadingLayout.ts +0 -294
- package/src/useMonacoLoader.ts +0 -82
- package/src/useTimelineClock.ts +0 -76
- package/src/utils/collectInlineFontAwesomeCss.ts +0 -69
- package/src/utils/dropUtils.ts +0 -136
- package/src/utils/normalizeMalformedAssetUrl.ts +0 -22
- package/src/writeCanvasSettings.ts +0 -30
- package/src/wysiwygImageUpload.ts +0 -113
package/src/Toolbar.tsx
DELETED
|
@@ -1,2885 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Toolbar
|
|
3
|
-
*
|
|
4
|
-
* Formatting toolbar that provides common markdown editing actions.
|
|
5
|
-
* In WYSIWYG mode, uses Tiptap's chain commands to toggle marks / set nodes.
|
|
6
|
-
* In Raw mode, appends markdown syntax at the cursor (or end of source).
|
|
7
|
-
* Hidden in Preview mode.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { ReactNode } from 'react';
|
|
11
|
-
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
12
|
-
import type { Editor as TiptapEditor, JSONContent } from '@tiptap/core';
|
|
13
|
-
import { TextSelection } from '@tiptap/pm/state';
|
|
14
|
-
import { Fragment } from '@tiptap/pm/model';
|
|
15
|
-
import type { IRange } from 'monaco-editor';
|
|
16
|
-
import type { Block } from '@bendyline/squisq/schemas';
|
|
17
|
-
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
18
|
-
import { DEFAULT_THEME, flattenBlocks } from '@bendyline/squisq/doc';
|
|
19
|
-
import {
|
|
20
|
-
KNOWN_BLOCK_META_KEYS,
|
|
21
|
-
matchTrailingTemplateAnnotation,
|
|
22
|
-
splitKeyValueToken,
|
|
23
|
-
tokenizeAttrTokens,
|
|
24
|
-
} from '@bendyline/squisq/markdown';
|
|
25
|
-
import { useEditorContext, type EditorView } from './EditorContext';
|
|
26
|
-
import { VersionHistoryPanel } from './VersionHistoryPanel';
|
|
27
|
-
import { RecorderEntry } from './RecorderEntry';
|
|
28
|
-
import { ViewMenuPanel } from './ViewMenuPanel';
|
|
29
|
-
import {
|
|
30
|
-
TemplatePicker,
|
|
31
|
-
TEMPLATE_NAMES,
|
|
32
|
-
TEMPLATE_GALLERY_PORTAL_SELECTOR,
|
|
33
|
-
templateLabel,
|
|
34
|
-
} from './TemplatePicker';
|
|
35
|
-
import { TransitionPicker, TRANSITION_FLYOUT_PORTAL_ID } from './TransitionPicker';
|
|
36
|
-
import {
|
|
37
|
-
readHeadingLineTransition,
|
|
38
|
-
setHeadingLineTransition,
|
|
39
|
-
readBlockAttrsTransition,
|
|
40
|
-
setHeadingAttrsTransition,
|
|
41
|
-
EMPTY_TRANSITION,
|
|
42
|
-
type TransitionFields,
|
|
43
|
-
} from './headingTransition';
|
|
44
|
-
import { profileBlockContents, recommendTemplatesForBlock } from '@bendyline/squisq/recommend';
|
|
45
|
-
import { findBlockSliceAtLine, findBlockSliceByHeadingIndex } from './blockSlice';
|
|
46
|
-
import { LinkDialog } from './LinkDialog';
|
|
47
|
-
import { DocumentSettingsDialog } from './DocumentSettingsDialog';
|
|
48
|
-
import { EmojiPicker, EMOJI_PICKER_WIDTH, EMOJI_PICKER_MAX_HEIGHT } from './EmojiPicker';
|
|
49
|
-
import { CustomLayoutManager } from './customTemplates/CustomLayoutManager';
|
|
50
|
-
import { Icon } from './Icon';
|
|
51
|
-
import type { PickerEntry } from './emojiData';
|
|
52
|
-
import { createPortal } from 'react-dom';
|
|
53
|
-
import { PreviewModeMenu, displayModeLabel, usePreviewSettingsOptional } from './PreviewControls';
|
|
54
|
-
import { filterVisibleMediaEntries } from './mediaEntries';
|
|
55
|
-
import {
|
|
56
|
-
selectionToTable,
|
|
57
|
-
selectionToTableMarkdown,
|
|
58
|
-
selectionToTaskItems,
|
|
59
|
-
selectionToTaskListMarkdown,
|
|
60
|
-
type SelectionTaskItem,
|
|
61
|
-
} from './selectionConversions';
|
|
62
|
-
import { isMarkdownFencedCodeLine, markdownFencedCodeLineMask } from './markdownCodeFence';
|
|
63
|
-
import {
|
|
64
|
-
CODE_SNIPPET_LANGUAGES,
|
|
65
|
-
codeSnippetMarkdown,
|
|
66
|
-
type CodeSnippetLanguage,
|
|
67
|
-
} from './codeSnippet/codeSnippetLanguages';
|
|
68
|
-
import {
|
|
69
|
-
DEFAULT_MERMAID_DIAGRAM_TYPE,
|
|
70
|
-
MERMAID_DIAGRAM_TYPES,
|
|
71
|
-
mermaidDiagramMarkdown,
|
|
72
|
-
type MermaidDiagramType,
|
|
73
|
-
} from './mermaid/mermaidDiagramTypes';
|
|
74
|
-
import { MermaidDiagramTypeThumbnail } from './mermaid/MermaidDiagramTypeThumbnail';
|
|
75
|
-
import { FindToolbar } from './find/FindToolbar';
|
|
76
|
-
|
|
77
|
-
const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
|
|
78
|
-
{ id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
|
|
79
|
-
{ id: 'raw', label: 'Source', shortcut: '⌘2' },
|
|
80
|
-
{ id: 'preview', label: 'Use', shortcut: '⌘3' },
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
const BLOCK_META_KEYS = new Set<string>(Object.keys(KNOWN_BLOCK_META_KEYS));
|
|
84
|
-
|
|
85
|
-
export interface ToolbarProps {
|
|
86
|
-
/** Additional class name */
|
|
87
|
-
className?: string;
|
|
88
|
-
/** Whether the Files panel is currently shown */
|
|
89
|
-
showFiles?: boolean;
|
|
90
|
-
/** Number of files currently available through the MediaProvider. */
|
|
91
|
-
fileCount?: number;
|
|
92
|
-
/** Toggle the Files panel. When provided, a "Files" button appears in the toolbar. */
|
|
93
|
-
onToggleFiles?: () => void;
|
|
94
|
-
/** Content rendered at the left edge of the toolbar, before the view tabs. */
|
|
95
|
-
slotLeft?: ReactNode;
|
|
96
|
-
/** Content rendered immediately after the view tabs, on the left side of the
|
|
97
|
-
* toolbar (before the formatting controls). Used for the preview mode
|
|
98
|
-
* switch in Use view. */
|
|
99
|
-
slotAfterTabs?: ReactNode;
|
|
100
|
-
/** Content rendered after the formatting controls (in the middle area). */
|
|
101
|
-
slotAfterActions?: ReactNode;
|
|
102
|
-
/** Content rendered at the rightmost end of the toolbar, after all other elements. */
|
|
103
|
-
slotRight?: ReactNode;
|
|
104
|
-
/**
|
|
105
|
-
* Whether to include the "Play" (preview) tab in the view switcher.
|
|
106
|
-
* Defaults to true. Hosts that don't want the slideshow preview — e.g.
|
|
107
|
-
* editing free-form prompts — can pass false to suppress it.
|
|
108
|
-
*/
|
|
109
|
-
showPlayTab?: boolean;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface ToolbarButton {
|
|
113
|
-
id: string;
|
|
114
|
-
label: string;
|
|
115
|
-
/** Text glyph shown when the button has no Font Awesome icon (headings). */
|
|
116
|
-
icon: string;
|
|
117
|
-
title: string;
|
|
118
|
-
group: 'format' | 'lists' | 'structure' | 'insert' | 'media';
|
|
119
|
-
/** Font Awesome class string (e.g. `"fa-solid fa-bold"`); omitted for text buttons. */
|
|
120
|
-
faIcon?: string;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const BUTTONS: ToolbarButton[] = [
|
|
124
|
-
// Format group — B/I/S trio.
|
|
125
|
-
{
|
|
126
|
-
id: 'bold',
|
|
127
|
-
label: 'B',
|
|
128
|
-
icon: 'B',
|
|
129
|
-
title: 'Bold (Ctrl+B)',
|
|
130
|
-
group: 'format',
|
|
131
|
-
faIcon: 'fa-solid fa-bold',
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
id: 'italic',
|
|
135
|
-
label: 'I',
|
|
136
|
-
icon: 'I',
|
|
137
|
-
title: 'Italic (Ctrl+I)',
|
|
138
|
-
group: 'format',
|
|
139
|
-
faIcon: 'fa-solid fa-italic',
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
id: 'strikethrough',
|
|
143
|
-
label: 'S',
|
|
144
|
-
icon: 'S',
|
|
145
|
-
title: 'Strikethrough',
|
|
146
|
-
group: 'format',
|
|
147
|
-
faIcon: 'fa-solid fa-strikethrough',
|
|
148
|
-
},
|
|
149
|
-
|
|
150
|
-
// Lists group — sits between format and structure so bullets/numbers
|
|
151
|
-
// are adjacent to the inline formatters people reach for together.
|
|
152
|
-
{
|
|
153
|
-
id: 'ul',
|
|
154
|
-
label: '•',
|
|
155
|
-
icon: '•',
|
|
156
|
-
title: 'Bullet list',
|
|
157
|
-
group: 'lists',
|
|
158
|
-
faIcon: 'fa-solid fa-list-ul',
|
|
159
|
-
},
|
|
160
|
-
{
|
|
161
|
-
id: 'ol',
|
|
162
|
-
label: '1.',
|
|
163
|
-
icon: '1.',
|
|
164
|
-
title: 'Numbered list',
|
|
165
|
-
group: 'lists',
|
|
166
|
-
faIcon: 'fa-solid fa-list-ol',
|
|
167
|
-
},
|
|
168
|
-
|
|
169
|
-
// Structure group — headings keep their text labels; Font Awesome Free
|
|
170
|
-
// has no numbered (H1–H6) heading glyphs, and the numerals are clearer.
|
|
171
|
-
{ id: 'h1', label: 'H1', icon: 'H1', title: 'Heading 1', group: 'structure' },
|
|
172
|
-
{ id: 'h2', label: 'H2', icon: 'H2', title: 'Heading 2', group: 'structure' },
|
|
173
|
-
{ id: 'h3', label: 'H3', icon: 'H3', title: 'Heading 3', group: 'structure' },
|
|
174
|
-
{ id: 'h4', label: 'H4', icon: 'H4', title: 'Heading 4', group: 'structure' },
|
|
175
|
-
{ id: 'h5', label: 'H5', icon: 'H5', title: 'Heading 5', group: 'structure' },
|
|
176
|
-
{ id: 'h6', label: 'H6', icon: 'H6', title: 'Heading 6', group: 'structure' },
|
|
177
|
-
|
|
178
|
-
// Insert group — block-level inserts (quote, code blocks, rules)
|
|
179
|
-
{
|
|
180
|
-
id: 'quote',
|
|
181
|
-
label: '❝',
|
|
182
|
-
icon: '❝',
|
|
183
|
-
title: 'Blockquote',
|
|
184
|
-
group: 'insert',
|
|
185
|
-
faIcon: 'fa-solid fa-quote-left',
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
id: 'codeblock',
|
|
189
|
-
label: '{ }',
|
|
190
|
-
icon: '{ }',
|
|
191
|
-
title: 'Code block',
|
|
192
|
-
group: 'insert',
|
|
193
|
-
faIcon: 'fa-solid fa-file-code',
|
|
194
|
-
},
|
|
195
|
-
{
|
|
196
|
-
id: 'code',
|
|
197
|
-
label: '</>',
|
|
198
|
-
icon: '</>',
|
|
199
|
-
title: 'Inline code',
|
|
200
|
-
group: 'insert',
|
|
201
|
-
faIcon: 'fa-solid fa-code',
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
id: 'hr',
|
|
205
|
-
label: '—',
|
|
206
|
-
icon: '—',
|
|
207
|
-
title: 'Horizontal rule',
|
|
208
|
-
group: 'insert',
|
|
209
|
-
faIcon: 'fa-solid fa-minus',
|
|
210
|
-
},
|
|
211
|
-
|
|
212
|
-
// Media group — links, tables, images, emoji
|
|
213
|
-
{
|
|
214
|
-
id: 'link',
|
|
215
|
-
label: '🔗',
|
|
216
|
-
icon: '🔗',
|
|
217
|
-
title: 'Insert link',
|
|
218
|
-
group: 'media',
|
|
219
|
-
faIcon: 'fa-solid fa-link',
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
id: 'table',
|
|
223
|
-
label: 'table',
|
|
224
|
-
icon: '',
|
|
225
|
-
title: 'Insert table',
|
|
226
|
-
group: 'media',
|
|
227
|
-
faIcon: 'fa-solid fa-table',
|
|
228
|
-
},
|
|
229
|
-
{
|
|
230
|
-
id: 'tasklist',
|
|
231
|
-
label: 'tasks',
|
|
232
|
-
icon: '',
|
|
233
|
-
title: 'Insert Task List',
|
|
234
|
-
group: 'media',
|
|
235
|
-
faIcon: 'fa-solid fa-list-check',
|
|
236
|
-
},
|
|
237
|
-
{
|
|
238
|
-
id: 'diagram',
|
|
239
|
-
label: 'diagram',
|
|
240
|
-
icon: '',
|
|
241
|
-
title: 'Insert diagram',
|
|
242
|
-
group: 'media',
|
|
243
|
-
faIcon: 'fa-solid fa-diagram-project',
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
id: 'complexdiagram',
|
|
247
|
-
label: 'complex diagram',
|
|
248
|
-
icon: '',
|
|
249
|
-
title: 'Insert Complex Diagram (Mermaid)',
|
|
250
|
-
group: 'media',
|
|
251
|
-
faIcon: 'fa-solid fa-code-branch',
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
id: 'tree',
|
|
255
|
-
label: 'tree',
|
|
256
|
-
icon: '',
|
|
257
|
-
title: 'Insert tree',
|
|
258
|
-
group: 'media',
|
|
259
|
-
faIcon: 'fa-solid fa-folder-tree',
|
|
260
|
-
},
|
|
261
|
-
{
|
|
262
|
-
id: 'timeline',
|
|
263
|
-
label: 'timeline',
|
|
264
|
-
icon: '',
|
|
265
|
-
title: 'Insert timeline',
|
|
266
|
-
group: 'media',
|
|
267
|
-
faIcon: 'fa-solid fa-timeline',
|
|
268
|
-
},
|
|
269
|
-
{
|
|
270
|
-
id: 'drawing',
|
|
271
|
-
label: 'drawing',
|
|
272
|
-
icon: '',
|
|
273
|
-
title: 'Insert drawing',
|
|
274
|
-
group: 'media',
|
|
275
|
-
faIcon: 'fa-solid fa-pen-nib',
|
|
276
|
-
},
|
|
277
|
-
{
|
|
278
|
-
id: 'layout',
|
|
279
|
-
label: 'layout',
|
|
280
|
-
icon: '',
|
|
281
|
-
title: 'Insert layout',
|
|
282
|
-
group: 'media',
|
|
283
|
-
faIcon: 'fa-solid fa-object-group',
|
|
284
|
-
},
|
|
285
|
-
{
|
|
286
|
-
id: 'image',
|
|
287
|
-
label: '🖼',
|
|
288
|
-
icon: '🖼',
|
|
289
|
-
title: 'Insert image',
|
|
290
|
-
group: 'media',
|
|
291
|
-
faIcon: 'fa-solid fa-image',
|
|
292
|
-
},
|
|
293
|
-
{
|
|
294
|
-
id: 'emoji',
|
|
295
|
-
label: '😊',
|
|
296
|
-
icon: '😊',
|
|
297
|
-
title: 'Insert emoji',
|
|
298
|
-
group: 'media',
|
|
299
|
-
faIcon: 'fa-solid fa-face-smile',
|
|
300
|
-
},
|
|
301
|
-
];
|
|
302
|
-
|
|
303
|
-
const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
|
|
304
|
-
const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
|
|
305
|
-
const CONVERT_BUTTONS = MEDIA_BUTTONS.filter((b) => b.id === 'table' || b.id === 'tasklist');
|
|
306
|
-
const INSERT_MENU_WIDTH = 200;
|
|
307
|
-
const CODE_SNIPPET_MENU_WIDTH = 220;
|
|
308
|
-
const MERMAID_TYPE_MENU_WIDTH = 520;
|
|
309
|
-
const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
|
|
310
|
-
const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
|
|
311
|
-
|
|
312
|
-
// BUTTONS position per id — stamped on each rendered button as
|
|
313
|
-
// data-btn-index so the overflow measurement can map a clipped DOM button
|
|
314
|
-
// back to its BUTTONS entry. Walking a counter over the DOM instead would
|
|
315
|
-
// drift whenever some entries render no button of their own (hidden H5/H6
|
|
316
|
-
// heading levels, the media group collapsed behind the Insert dropdown).
|
|
317
|
-
const BUTTON_INDEX_BY_ID = new Map(BUTTONS.map((b, i) => [b.id, i]));
|
|
318
|
-
|
|
319
|
-
/** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
|
|
320
|
-
function buttonIcon(btn: ToolbarButton): ReactNode {
|
|
321
|
-
return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function fileCountLabel(count: number): string {
|
|
325
|
-
return `${count} ${count === 1 ? 'file' : 'files'}`;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function fileCountBadge(count: number): string {
|
|
329
|
-
return count > 99 ? '99+' : String(count);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function paragraphContent(text: string): JSONContent {
|
|
333
|
-
return text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function tableContent(rows: string[][]): JSONContent {
|
|
337
|
-
return {
|
|
338
|
-
type: 'table',
|
|
339
|
-
content: rows.map((row, rowIndex) => ({
|
|
340
|
-
type: 'tableRow',
|
|
341
|
-
content: row.map((cell) => ({
|
|
342
|
-
type: rowIndex === 0 ? 'tableHeader' : 'tableCell',
|
|
343
|
-
content: [paragraphContent(cell)],
|
|
344
|
-
})),
|
|
345
|
-
})),
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function taskListContent(
|
|
350
|
-
items: readonly SelectionTaskItem[] = TASK_LIST_ITEMS.map((text) => ({
|
|
351
|
-
checked: false,
|
|
352
|
-
text,
|
|
353
|
-
})),
|
|
354
|
-
): JSONContent {
|
|
355
|
-
return {
|
|
356
|
-
type: 'taskList',
|
|
357
|
-
content: items.map((item) => ({
|
|
358
|
-
type: 'taskItem',
|
|
359
|
-
attrs: { checked: item.checked },
|
|
360
|
-
content: [paragraphContent(item.text)],
|
|
361
|
-
})),
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function insertTaskList(editor: TiptapEditor): void {
|
|
366
|
-
const supportsTaskList = !!editor.schema.nodes.taskList && !!editor.schema.nodes.taskItem;
|
|
367
|
-
const content = supportsTaskList ? taskListContent() : TASK_LIST_MARKDOWN;
|
|
368
|
-
editor.chain().focus().insertContent(content).run();
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Expand a full-line Tiptap text selection to the surrounding top-level node
|
|
373
|
-
* boundaries. Inserting a list/table at an in-paragraph range makes
|
|
374
|
-
* ProseMirror preserve the emptied paragraph before or after the new block.
|
|
375
|
-
* Partial-line selections deliberately retain their exact range.
|
|
376
|
-
*/
|
|
377
|
-
function blockConversionRange(editor: TiptapEditor): { from: number; to: number } {
|
|
378
|
-
const { from, to, $from, $to } = editor.state.selection;
|
|
379
|
-
return {
|
|
380
|
-
from:
|
|
381
|
-
$from.depth === 1 && $from.parent.isTextblock && $from.parentOffset === 0
|
|
382
|
-
? $from.before(1)
|
|
383
|
-
: from,
|
|
384
|
-
to:
|
|
385
|
-
$to.depth === 1 && $to.parent.isTextblock && $to.parentOffset === $to.parent.content.size
|
|
386
|
-
? $to.after(1)
|
|
387
|
-
: to,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// ─── Tiptap active-state map ────────────────────────────
|
|
392
|
-
|
|
393
|
-
/** Returns true if the given button id is currently active in Tiptap */
|
|
394
|
-
function isTiptapActive(editor: TiptapEditor, id: string): boolean {
|
|
395
|
-
if (!editor) return false;
|
|
396
|
-
switch (id) {
|
|
397
|
-
case 'bold':
|
|
398
|
-
return editor.isActive('bold');
|
|
399
|
-
case 'italic':
|
|
400
|
-
return editor.isActive('italic');
|
|
401
|
-
case 'strikethrough':
|
|
402
|
-
return editor.isActive('strike');
|
|
403
|
-
case 'code':
|
|
404
|
-
return editor.isActive('code');
|
|
405
|
-
case 'h1':
|
|
406
|
-
return editor.isActive('heading', { level: 1 });
|
|
407
|
-
case 'h2':
|
|
408
|
-
return editor.isActive('heading', { level: 2 });
|
|
409
|
-
case 'h3':
|
|
410
|
-
return editor.isActive('heading', { level: 3 });
|
|
411
|
-
case 'h4':
|
|
412
|
-
return editor.isActive('heading', { level: 4 });
|
|
413
|
-
case 'h5':
|
|
414
|
-
return editor.isActive('heading', { level: 5 });
|
|
415
|
-
case 'h6':
|
|
416
|
-
return editor.isActive('heading', { level: 6 });
|
|
417
|
-
case 'quote':
|
|
418
|
-
return editor.isActive('blockquote');
|
|
419
|
-
case 'ul':
|
|
420
|
-
return editor.isActive('bulletList');
|
|
421
|
-
case 'ol':
|
|
422
|
-
return editor.isActive('orderedList');
|
|
423
|
-
case 'codeblock':
|
|
424
|
-
return editor.isActive('codeBlock');
|
|
425
|
-
default:
|
|
426
|
-
return false;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// ─── Scene-block inserts (diagram / drawing / layout) ───
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* A freshly-inserted layout starts with one centered text layer so the
|
|
434
|
-
* canvas isn't a blank surface. Each layer is a readable child sub-block
|
|
435
|
-
* (`### {#id} {[type …]}`); a text layer's content is its markdown body.
|
|
436
|
-
* Coordinates are absolute within the 1920×1080 scene viewport
|
|
437
|
-
* (`SceneBlockWidget`). See `scene/commands/layoutCommands.ts`.
|
|
438
|
-
*/
|
|
439
|
-
const LAYOUT_STARTER_TEXT_PARAMS =
|
|
440
|
-
'x=360 y=380 width=1200 height=320 fontSize=64 fontWeight=bold align=center valign=middle color="#1e293b"';
|
|
441
|
-
/** Multi-line markdown for a new layout (raw / code views). */
|
|
442
|
-
const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text ${LAYOUT_STARTER_TEXT_PARAMS}]}\n\nLayout\n`;
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Starter art for a new diagram — diagrams are authored as ASCII-art code
|
|
446
|
-
* fences (the fence is the source of truth; `AsciiDiagramExtension` mounts
|
|
447
|
-
* the interactive canvas over it). This exact art is the codec's own
|
|
448
|
-
* canonical rendering of a two-node flow, so it re-parses and re-renders
|
|
449
|
-
* byte-stably. The leading rows/columns are deliberate: ASCII has no
|
|
450
|
-
* separate canvas-origin metadata, so this persisted gutter gives the first
|
|
451
|
-
* node real grid space to move north/west. Without it, content-fit makes the
|
|
452
|
-
* node look centered while its authored coordinate is still (0, 0), and a
|
|
453
|
-
* drag into the apparent margin clamps straight back to that corner.
|
|
454
|
-
*/
|
|
455
|
-
const DIAGRAM_STARTER_ART = [
|
|
456
|
-
'',
|
|
457
|
-
'',
|
|
458
|
-
' ┌─────────┐',
|
|
459
|
-
' │ Start │',
|
|
460
|
-
' └────┬────┘',
|
|
461
|
-
' │',
|
|
462
|
-
' ▼',
|
|
463
|
-
' ┌────┴────┐',
|
|
464
|
-
' │ Next │',
|
|
465
|
-
' └─────────┘',
|
|
466
|
-
].join('\n');
|
|
467
|
-
/**
|
|
468
|
-
* Fenced form for raw / code views — tagged with the explicit `diagram`
|
|
469
|
-
* language so the block's identity survives markdown ↔ Tiptap round-trips
|
|
470
|
-
* (the language class round-trips; fence meta does not).
|
|
471
|
-
*/
|
|
472
|
-
const DIAGRAM_STARTER_MARKDOWN = '\n```diagram\n' + DIAGRAM_STARTER_ART + '\n```\n';
|
|
473
|
-
|
|
474
|
-
/** Insert a starter ASCII-diagram code fence after the current top-level block. */
|
|
475
|
-
function insertAsciiDiagramBlock(editor: TiptapEditor): void {
|
|
476
|
-
insertFenceBlock(editor, DIAGRAM_STARTER_ART, 'diagram');
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** Default source for callers that invoke the legacy single-click action. */
|
|
480
|
-
const MERMAID_STARTER_ART = DEFAULT_MERMAID_DIAGRAM_TYPE.starter;
|
|
481
|
-
const MERMAID_STARTER_MARKDOWN = mermaidDiagramMarkdown(MERMAID_STARTER_ART);
|
|
482
|
-
|
|
483
|
-
/** Insert a Mermaid fence that MermaidDiagramExtension turns into the complex canvas. */
|
|
484
|
-
function insertMermaidDiagramBlock(editor: TiptapEditor, source = MERMAID_STARTER_ART): void {
|
|
485
|
-
insertFenceBlock(editor, source, 'mermaid');
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Starter art for a new file tree — an ASCII tree fence (the fence is the
|
|
490
|
-
* source of truth; `TreeViewExtension` mounts the interactive outline).
|
|
491
|
-
*/
|
|
492
|
-
const TREE_STARTER_ART = ['src/', '├── index.ts', '└── utils/', ' └── helpers.ts'].join('\n');
|
|
493
|
-
const TREE_STARTER_MARKDOWN = '\n```tree\n' + TREE_STARTER_ART + '\n```\n';
|
|
494
|
-
|
|
495
|
-
/** Insert a starter ASCII tree code fence after the current top-level block. */
|
|
496
|
-
function insertTreeBlock(editor: TiptapEditor): void {
|
|
497
|
-
insertFenceBlock(editor, TREE_STARTER_ART, 'tree');
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/** Starter multi-event rail for the authored timeline view. */
|
|
501
|
-
const TIMELINE_STARTER_ART =
|
|
502
|
-
'Milestones: ● Start {#start} ─────● Review {#review} ─────● Ship {#ship} ───►';
|
|
503
|
-
const TIMELINE_STARTER_MARKDOWN = '\n```timeline\n' + TIMELINE_STARTER_ART + '\n```\n';
|
|
504
|
-
|
|
505
|
-
/** Insert a starter ASCII timeline code fence after the current top-level block. */
|
|
506
|
-
function insertTimelineBlock(editor: TiptapEditor): void {
|
|
507
|
-
insertFenceBlock(editor, TIMELINE_STARTER_ART, 'timeline');
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Insert a code fence after the current top-level block. `lang` tags the
|
|
512
|
-
* fence's `language` attribute — pass the explicit authored-view tag so its
|
|
513
|
-
* identity round-trips through markdown and Tiptap.
|
|
514
|
-
*/
|
|
515
|
-
function insertFenceBlock(editor: TiptapEditor, art: string, lang?: string): void {
|
|
516
|
-
editor
|
|
517
|
-
.chain()
|
|
518
|
-
.focus()
|
|
519
|
-
.command(({ tr, state, dispatch }) => {
|
|
520
|
-
const codeBlockType = state.schema.nodes.codeBlock;
|
|
521
|
-
if (!codeBlockType) return false;
|
|
522
|
-
const attrs = lang ? { language: lang } : null;
|
|
523
|
-
const block = codeBlockType.create(attrs, state.schema.text(art));
|
|
524
|
-
const { $from } = state.selection;
|
|
525
|
-
const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
|
|
526
|
-
if (dispatch) tr.insert(insertPos, block);
|
|
527
|
-
return true;
|
|
528
|
-
})
|
|
529
|
-
.run();
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Insert a block-level heading carrying a Scene template (`diagram` /
|
|
534
|
-
* `drawing` / `layout`) at the top level, after the block the caret sits
|
|
535
|
-
* in. Going through a command (rather than `insertContent` at the caret)
|
|
536
|
-
* keeps the heading from being coerced into inline text when the caret is
|
|
537
|
-
* nested inside a list item or other block. The matching extension
|
|
538
|
-
* (DiagramExtension / SceneBlockExtension) then mounts the editable canvas
|
|
539
|
-
* right below it.
|
|
540
|
-
*/
|
|
541
|
-
function insertTemplateHeading(
|
|
542
|
-
editor: TiptapEditor,
|
|
543
|
-
opts: { template: string; text: string; level?: number; blockAttrs?: string | null },
|
|
544
|
-
): void {
|
|
545
|
-
editor
|
|
546
|
-
.chain()
|
|
547
|
-
.focus()
|
|
548
|
-
.command(({ tr, state, dispatch }) => {
|
|
549
|
-
const headingType = state.schema.nodes.heading;
|
|
550
|
-
if (!headingType) return false;
|
|
551
|
-
const heading = headingType.create(
|
|
552
|
-
{
|
|
553
|
-
level: opts.level ?? 2,
|
|
554
|
-
dataTemplate: opts.template,
|
|
555
|
-
dataBlockAttrs: opts.blockAttrs ?? null,
|
|
556
|
-
},
|
|
557
|
-
state.schema.text(opts.text),
|
|
558
|
-
);
|
|
559
|
-
const { $from } = state.selection;
|
|
560
|
-
const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
|
|
561
|
-
if (dispatch) {
|
|
562
|
-
tr.insert(insertPos, heading);
|
|
563
|
-
// Drop the caret into the new heading's text so it can be renamed.
|
|
564
|
-
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
|
565
|
-
}
|
|
566
|
-
return true;
|
|
567
|
-
})
|
|
568
|
-
.run();
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* Insert a `{[layout]}` block seeded with one text layer. The layer is a
|
|
573
|
-
* child sub-block heading (`### {#text-1} {[text …]}`) whose markdown body
|
|
574
|
-
* ("Layout") is the text content — the readable counterpart to the old
|
|
575
|
-
* base64 `layers=` blob. The parent + child + body go in as one fragment so
|
|
576
|
-
* the layout widget mounts with the seed already present.
|
|
577
|
-
*/
|
|
578
|
-
function insertLayoutBlock(editor: TiptapEditor): void {
|
|
579
|
-
editor
|
|
580
|
-
.chain()
|
|
581
|
-
.focus()
|
|
582
|
-
.command(({ tr, state, dispatch }) => {
|
|
583
|
-
const headingType = state.schema.nodes.heading;
|
|
584
|
-
const paragraphType = state.schema.nodes.paragraph;
|
|
585
|
-
if (!headingType || !paragraphType) return false;
|
|
586
|
-
const parent = headingType.create(
|
|
587
|
-
{ level: 2, dataTemplate: 'layout' },
|
|
588
|
-
state.schema.text('Layout'),
|
|
589
|
-
);
|
|
590
|
-
const child = headingType.create({
|
|
591
|
-
level: 3,
|
|
592
|
-
dataTemplate: 'text',
|
|
593
|
-
dataTemplateParams: LAYOUT_STARTER_TEXT_PARAMS,
|
|
594
|
-
dataBlockAttrs: '#text-1',
|
|
595
|
-
});
|
|
596
|
-
const body = paragraphType.create(null, state.schema.text('Layout'));
|
|
597
|
-
const { $from } = state.selection;
|
|
598
|
-
const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
|
|
599
|
-
if (dispatch) {
|
|
600
|
-
tr.insert(insertPos, Fragment.fromArray([parent, child, body]));
|
|
601
|
-
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
|
602
|
-
}
|
|
603
|
-
return true;
|
|
604
|
-
})
|
|
605
|
-
.run();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function findBlockBySourceLine(blocks: readonly Block[], lineNumber: number): Block | null {
|
|
609
|
-
for (const block of blocks) {
|
|
610
|
-
const pos = block.sourceHeading?.position;
|
|
611
|
-
if (!pos) continue;
|
|
612
|
-
if (pos.start.line <= lineNumber && pos.end.line >= lineNumber) return block;
|
|
613
|
-
}
|
|
614
|
-
return null;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function splitTemplateAnnotationParams(text: string): { text: string; params: string[] } {
|
|
618
|
-
const match = matchTrailingTemplateAnnotation(text);
|
|
619
|
-
if (!match) return { text: text.trimEnd(), params: [] };
|
|
620
|
-
const tokens = tokenizeAttrTokens(match.inner.trim());
|
|
621
|
-
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
622
|
-
return {
|
|
623
|
-
text: text.slice(0, match.index).trimEnd(),
|
|
624
|
-
params: keepBlockMetaParamTokens(tokens.slice(firstIsParam ? 0 : 1)),
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function buildTemplateAnnotation(template: string, params: string[]): string | null {
|
|
629
|
-
const parts = template ? [template, ...params] : params;
|
|
630
|
-
return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function keepBlockMetaParamTokens(tokens: readonly string[]): string[] {
|
|
634
|
-
return tokens.filter((token) => {
|
|
635
|
-
const kv = splitKeyValueToken(token);
|
|
636
|
-
return kv ? BLOCK_META_KEYS.has(kv.key) : false;
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function keepBlockMetaParamString(inner: string | null | undefined): string | null {
|
|
641
|
-
if (!inner) return null;
|
|
642
|
-
const params = keepBlockMetaParamTokens(tokenizeAttrTokens(inner));
|
|
643
|
-
return params.length > 0 ? params.join(' ') : null;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
/**
|
|
647
|
-
* Formatting toolbar.
|
|
648
|
-
* - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
|
|
649
|
-
* - Raw: appends markdown syntax to the source
|
|
650
|
-
*/
|
|
651
|
-
export function Toolbar({
|
|
652
|
-
className,
|
|
653
|
-
showFiles,
|
|
654
|
-
fileCount,
|
|
655
|
-
onToggleFiles,
|
|
656
|
-
slotLeft,
|
|
657
|
-
slotAfterTabs,
|
|
658
|
-
slotAfterActions,
|
|
659
|
-
slotRight,
|
|
660
|
-
showPlayTab = true,
|
|
661
|
-
}: ToolbarProps) {
|
|
662
|
-
const {
|
|
663
|
-
activeView,
|
|
664
|
-
setActiveView,
|
|
665
|
-
markdownSource,
|
|
666
|
-
doc,
|
|
667
|
-
setMarkdownSource,
|
|
668
|
-
tiptapEditor,
|
|
669
|
-
monacoEditor,
|
|
670
|
-
activeSceneText,
|
|
671
|
-
mediaProvider,
|
|
672
|
-
editorMode,
|
|
673
|
-
findMode,
|
|
674
|
-
setFindMode,
|
|
675
|
-
layoutMode,
|
|
676
|
-
activeBlockStartLine,
|
|
677
|
-
versioning,
|
|
678
|
-
allowRecording,
|
|
679
|
-
documentLinkProvider,
|
|
680
|
-
colorScheme,
|
|
681
|
-
mediaRevision,
|
|
682
|
-
bumpMediaRevision,
|
|
683
|
-
} = useEditorContext();
|
|
684
|
-
const previewSettings = usePreviewSettingsOptional();
|
|
685
|
-
const [useModeMenuRequest, requestUseModeMenu] = useReducer((count: number) => count + 1, 0);
|
|
686
|
-
// When a canvas textbox is being edited, its Tiptap instance takes over
|
|
687
|
-
// the formatting buttons; otherwise they drive the document editor. The
|
|
688
|
-
// `level` gates which buttons apply (inline labels vs. rich textboxes).
|
|
689
|
-
const formattingEditor = activeSceneText?.editor ?? tiptapEditor;
|
|
690
|
-
const sceneTextLevel = activeSceneText?.level ?? null;
|
|
691
|
-
const isCodeMode = editorMode === 'code';
|
|
692
|
-
// In code mode only the raw view is meaningful; the WYSIWYG and Preview
|
|
693
|
-
// surfaces aren't mounted, so hide their tabs.
|
|
694
|
-
const visibleViews = VIEWS.filter((v) => {
|
|
695
|
-
if (isCodeMode) return v.id === 'raw';
|
|
696
|
-
if (v.id === 'preview' && !showPlayTab) return false;
|
|
697
|
-
return true;
|
|
698
|
-
});
|
|
699
|
-
const showViewTabs = visibleViews.length > 1;
|
|
700
|
-
const [scannedFileCount, setScannedFileCount] = useState(0);
|
|
701
|
-
const resolvedFileCount = fileCount ?? scannedFileCount;
|
|
702
|
-
|
|
703
|
-
useEffect(() => {
|
|
704
|
-
if (fileCount !== undefined) return;
|
|
705
|
-
if (!mediaProvider) {
|
|
706
|
-
setScannedFileCount(0);
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
let cancelled = false;
|
|
711
|
-
mediaProvider.listMedia().then(
|
|
712
|
-
(entries) => {
|
|
713
|
-
if (!cancelled) setScannedFileCount(filterVisibleMediaEntries(entries).length);
|
|
714
|
-
},
|
|
715
|
-
() => {
|
|
716
|
-
if (!cancelled) setScannedFileCount(0);
|
|
717
|
-
},
|
|
718
|
-
);
|
|
719
|
-
|
|
720
|
-
return () => {
|
|
721
|
-
cancelled = true;
|
|
722
|
-
};
|
|
723
|
-
}, [fileCount, mediaProvider, mediaRevision, showFiles]);
|
|
724
|
-
|
|
725
|
-
// Hidden file input for image picker
|
|
726
|
-
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
727
|
-
|
|
728
|
-
// Link dialog — shared by WYSIWYG and Raw views.
|
|
729
|
-
const [linkDialog, setLinkDialog] = useState<{
|
|
730
|
-
mode: 'insert' | 'update';
|
|
731
|
-
target: 'wysiwyg' | 'raw';
|
|
732
|
-
initialText: string;
|
|
733
|
-
initialUrl: string;
|
|
734
|
-
/** For target='raw': the range to replace when editing an existing
|
|
735
|
-
* [text](url) under the cursor. Null means use the current Monaco
|
|
736
|
-
* selection (insert at cursor / wrap selection). */
|
|
737
|
-
rawRange: IRange | null;
|
|
738
|
-
} | null>(null);
|
|
739
|
-
|
|
740
|
-
// Emoji picker — toolbar-anchored popover. We track the trigger
|
|
741
|
-
// button's screen rect so the picker can position itself just below
|
|
742
|
-
// it via createPortal (the toolbar's overflow:hidden actions row
|
|
743
|
-
// would otherwise clip the popover).
|
|
744
|
-
const emojiButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
745
|
-
const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<{
|
|
746
|
-
top: number;
|
|
747
|
-
left: number;
|
|
748
|
-
} | null>(null);
|
|
749
|
-
|
|
750
|
-
const openEmojiPicker = useCallback(() => {
|
|
751
|
-
const btn = emojiButtonRef.current;
|
|
752
|
-
if (!btn) return;
|
|
753
|
-
const rect = btn.getBoundingClientRect();
|
|
754
|
-
// Position just below the trigger by default, then clamp into the
|
|
755
|
-
// visible viewport so the picker is never clipped on the right or
|
|
756
|
-
// bottom — flips above the trigger when there isn't room below.
|
|
757
|
-
const gap = 6;
|
|
758
|
-
const margin = 8;
|
|
759
|
-
const vw = window.innerWidth;
|
|
760
|
-
const vh = window.innerHeight;
|
|
761
|
-
let left = rect.left;
|
|
762
|
-
if (left + EMOJI_PICKER_WIDTH + margin > vw) {
|
|
763
|
-
left = Math.max(margin, vw - EMOJI_PICKER_WIDTH - margin);
|
|
764
|
-
}
|
|
765
|
-
let top = rect.bottom + gap;
|
|
766
|
-
if (top + EMOJI_PICKER_MAX_HEIGHT + margin > vh) {
|
|
767
|
-
const flipped = rect.top - EMOJI_PICKER_MAX_HEIGHT - gap;
|
|
768
|
-
// Prefer flipping above when there's more room there; otherwise
|
|
769
|
-
// pin to the top edge with margin and let the picker's own
|
|
770
|
-
// maxHeight clip it.
|
|
771
|
-
top = flipped >= margin ? flipped : margin;
|
|
772
|
-
}
|
|
773
|
-
setEmojiPickerAnchor({ top, left });
|
|
774
|
-
}, []);
|
|
775
|
-
|
|
776
|
-
const closeEmojiPicker = useCallback(() => setEmojiPickerAnchor(null), []);
|
|
777
|
-
|
|
778
|
-
// Insert menu — toolbar-anchored dropdown that replaces the individual media-group
|
|
779
|
-
// buttons with a single "+" button. Portaled out to avoid overflow:hidden clipping.
|
|
780
|
-
const insertMenuButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
781
|
-
const insertMenuRef = useRef<HTMLDivElement | null>(null);
|
|
782
|
-
const codeSnippetMenuRef = useRef<HTMLDivElement | null>(null);
|
|
783
|
-
const mermaidTypeMenuRef = useRef<HTMLDivElement | null>(null);
|
|
784
|
-
const [insertMenuAnchor, setInsertMenuAnchor] = useState<{ top: number; left: number } | null>(
|
|
785
|
-
null,
|
|
786
|
-
);
|
|
787
|
-
const [codeSnippetMenuAnchor, setCodeSnippetMenuAnchor] = useState<{
|
|
788
|
-
top: number;
|
|
789
|
-
left: number;
|
|
790
|
-
} | null>(null);
|
|
791
|
-
const [mermaidTypeMenuAnchor, setMermaidTypeMenuAnchor] = useState<{
|
|
792
|
-
top: number;
|
|
793
|
-
left: number;
|
|
794
|
-
} | null>(null);
|
|
795
|
-
|
|
796
|
-
const openInsertMenu = useCallback((trigger?: HTMLElement | null) => {
|
|
797
|
-
const btn = trigger ?? insertMenuButtonRef.current;
|
|
798
|
-
if (!btn) return;
|
|
799
|
-
const rect = btn.getBoundingClientRect();
|
|
800
|
-
const gap = 4;
|
|
801
|
-
const margin = 8;
|
|
802
|
-
const vw = window.innerWidth;
|
|
803
|
-
let left = rect.left;
|
|
804
|
-
if (left + INSERT_MENU_WIDTH + margin > vw) {
|
|
805
|
-
left = Math.max(margin, rect.right - INSERT_MENU_WIDTH);
|
|
806
|
-
}
|
|
807
|
-
setInsertMenuAnchor({ top: rect.bottom + gap, left });
|
|
808
|
-
setCodeSnippetMenuAnchor(null);
|
|
809
|
-
setMermaidTypeMenuAnchor(null);
|
|
810
|
-
}, []);
|
|
811
|
-
|
|
812
|
-
const openCodeSnippetMenu = useCallback((trigger: HTMLElement) => {
|
|
813
|
-
const rect = trigger.getBoundingClientRect();
|
|
814
|
-
const gap = 4;
|
|
815
|
-
const margin = 8;
|
|
816
|
-
const vw = window.innerWidth;
|
|
817
|
-
const vh = window.innerHeight;
|
|
818
|
-
let left = rect.right + gap;
|
|
819
|
-
if (left + CODE_SNIPPET_MENU_WIDTH + margin > vw) {
|
|
820
|
-
left = Math.max(margin, rect.left - CODE_SNIPPET_MENU_WIDTH - gap);
|
|
821
|
-
}
|
|
822
|
-
const maxMenuHeight = Math.min(440, vh - margin * 2);
|
|
823
|
-
const top = Math.max(margin, Math.min(rect.top, vh - maxMenuHeight - margin));
|
|
824
|
-
setCodeSnippetMenuAnchor({ top, left });
|
|
825
|
-
setMermaidTypeMenuAnchor(null);
|
|
826
|
-
}, []);
|
|
827
|
-
|
|
828
|
-
const openMermaidTypeMenu = useCallback((trigger: HTMLElement) => {
|
|
829
|
-
const rect = trigger.getBoundingClientRect();
|
|
830
|
-
const gap = 4;
|
|
831
|
-
const margin = 8;
|
|
832
|
-
const vw = window.innerWidth;
|
|
833
|
-
const vh = window.innerHeight;
|
|
834
|
-
let left = rect.right + gap;
|
|
835
|
-
if (left + MERMAID_TYPE_MENU_WIDTH + margin > vw) {
|
|
836
|
-
left = Math.max(margin, rect.left - MERMAID_TYPE_MENU_WIDTH - gap);
|
|
837
|
-
}
|
|
838
|
-
const maxMenuHeight = Math.min(600, vh - margin * 2);
|
|
839
|
-
const top = Math.max(margin, Math.min(rect.top, vh - maxMenuHeight - margin));
|
|
840
|
-
setMermaidTypeMenuAnchor({ top, left });
|
|
841
|
-
setCodeSnippetMenuAnchor(null);
|
|
842
|
-
}, []);
|
|
843
|
-
|
|
844
|
-
const closeInsertMenu = useCallback(() => {
|
|
845
|
-
setInsertMenuAnchor(null);
|
|
846
|
-
setCodeSnippetMenuAnchor(null);
|
|
847
|
-
setMermaidTypeMenuAnchor(null);
|
|
848
|
-
}, []);
|
|
849
|
-
|
|
850
|
-
// ── Overflow detection ────────────────────────────────
|
|
851
|
-
const actionsRef = useRef<HTMLDivElement>(null);
|
|
852
|
-
const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
|
|
853
|
-
// '|'-joined data-contextual ids of the contextual groups (template /
|
|
854
|
-
// transition pickers, table controls) whose right edge doesn't fit in the
|
|
855
|
-
// actions row. Stored as a string so identical measurements bail out of
|
|
856
|
-
// re-rendering.
|
|
857
|
-
const [clippedContextualKey, setClippedContextualKey] = useState('');
|
|
858
|
-
const [showOverflow, setShowOverflow] = useState(false);
|
|
859
|
-
const overflowRef = useRef<HTMLDivElement>(null);
|
|
860
|
-
|
|
861
|
-
// Document settings (frontmatter) dialog
|
|
862
|
-
const [showDocSettings, setShowDocSettings] = useState(false);
|
|
863
|
-
|
|
864
|
-
// Custom layout manager dialog (list of doc/library layouts + designer)
|
|
865
|
-
const [showLayoutManager, setShowLayoutManager] = useState(false);
|
|
866
|
-
|
|
867
|
-
useEffect(() => {
|
|
868
|
-
if (!findMode) return;
|
|
869
|
-
setShowOverflow(false);
|
|
870
|
-
closeInsertMenu();
|
|
871
|
-
closeEmojiPicker();
|
|
872
|
-
}, [closeEmojiPicker, closeInsertMenu, findMode]);
|
|
873
|
-
|
|
874
|
-
const overflowIndex = measuredOverflowIndex;
|
|
875
|
-
const clippedContextual = new Set(clippedContextualKey ? clippedContextualKey.split('|') : []);
|
|
876
|
-
|
|
877
|
-
useEffect(() => {
|
|
878
|
-
const container = actionsRef.current;
|
|
879
|
-
if (!container) return;
|
|
880
|
-
|
|
881
|
-
const measure = () => {
|
|
882
|
-
const containerRight = container.getBoundingClientRect().right;
|
|
883
|
-
// Contextual groups aren't BUTTONS entries, so they must not shift the
|
|
884
|
-
// btnIndex mapping below — measure them separately.
|
|
885
|
-
const children = container.querySelectorAll<HTMLElement>(
|
|
886
|
-
':scope > .squisq-toolbar-group:not(.squisq-toolbar-contextual) > .squisq-toolbar-button',
|
|
887
|
-
);
|
|
888
|
-
let firstHidden: number | null = null;
|
|
889
|
-
children.forEach((child) => {
|
|
890
|
-
if (firstHidden !== null) return;
|
|
891
|
-
// A button is hidden if its right edge extends past the container.
|
|
892
|
-
// Its data-btn-index carries its BUTTONS position (see
|
|
893
|
-
// BUTTON_INDEX_BY_ID for why the DOM can't be walked positionally).
|
|
894
|
-
if (child.getBoundingClientRect().right > containerRight + 2) {
|
|
895
|
-
const index = Number(child.dataset.btnIndex);
|
|
896
|
-
firstHidden = Number.isNaN(index) ? null : index;
|
|
897
|
-
}
|
|
898
|
-
});
|
|
899
|
-
setMeasuredOverflowIndex(firstHidden);
|
|
900
|
-
// Contextual groups (template/transition pickers, table controls) that
|
|
901
|
-
// don't fit move into the overflow menu instead of painting cropped.
|
|
902
|
-
const clipped: string[] = [];
|
|
903
|
-
container
|
|
904
|
-
.querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-contextual')
|
|
905
|
-
.forEach((group) => {
|
|
906
|
-
if (group.getBoundingClientRect().right > containerRight + 2) {
|
|
907
|
-
clipped.push(group.dataset.contextual ?? '');
|
|
908
|
-
}
|
|
909
|
-
});
|
|
910
|
-
setClippedContextualKey(clipped.join('|'));
|
|
911
|
-
};
|
|
912
|
-
|
|
913
|
-
// Observe the row and every group in it: contextual groups mount/unmount
|
|
914
|
-
// with the cursor context and change width with their value, all without
|
|
915
|
-
// resizing the flex:1 container itself — a container-only observer would
|
|
916
|
-
// go stale.
|
|
917
|
-
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
|
|
918
|
-
const observeAll = () => {
|
|
919
|
-
ro?.disconnect();
|
|
920
|
-
ro?.observe(container);
|
|
921
|
-
container
|
|
922
|
-
.querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-group')
|
|
923
|
-
.forEach((group) => ro?.observe(group));
|
|
924
|
-
};
|
|
925
|
-
const mo = new MutationObserver(() => {
|
|
926
|
-
observeAll();
|
|
927
|
-
measure();
|
|
928
|
-
});
|
|
929
|
-
mo.observe(container, { childList: true });
|
|
930
|
-
if (!ro) window.addEventListener('resize', measure);
|
|
931
|
-
observeAll();
|
|
932
|
-
measure();
|
|
933
|
-
return () => {
|
|
934
|
-
ro?.disconnect();
|
|
935
|
-
if (!ro) window.removeEventListener('resize', measure);
|
|
936
|
-
mo.disconnect();
|
|
937
|
-
};
|
|
938
|
-
}, [activeView]);
|
|
939
|
-
|
|
940
|
-
// Close overflow menu on outside click. Clicks inside the template
|
|
941
|
-
// gallery / transition flyout portals count as inside: those popovers are
|
|
942
|
-
// hosted by pickers living in this menu, and closing the menu on their
|
|
943
|
-
// mousedown would unmount them before the click completes.
|
|
944
|
-
useEffect(() => {
|
|
945
|
-
if (!showOverflow) return;
|
|
946
|
-
const handleClick = (e: MouseEvent) => {
|
|
947
|
-
const target = e.target as Node;
|
|
948
|
-
if (overflowRef.current?.contains(target)) return;
|
|
949
|
-
if (
|
|
950
|
-
target instanceof Element &&
|
|
951
|
-
target.closest(`${TEMPLATE_GALLERY_PORTAL_SELECTOR}, #${TRANSITION_FLYOUT_PORTAL_ID}`)
|
|
952
|
-
) {
|
|
953
|
-
return;
|
|
954
|
-
}
|
|
955
|
-
setShowOverflow(false);
|
|
956
|
-
};
|
|
957
|
-
document.addEventListener('mousedown', handleClick);
|
|
958
|
-
return () => document.removeEventListener('mousedown', handleClick);
|
|
959
|
-
}, [showOverflow]);
|
|
960
|
-
|
|
961
|
-
// Close insert menu on outside click
|
|
962
|
-
useEffect(() => {
|
|
963
|
-
if (!insertMenuAnchor) return;
|
|
964
|
-
const handleClick = (e: MouseEvent) => {
|
|
965
|
-
if (insertMenuButtonRef.current?.contains(e.target as Node)) return;
|
|
966
|
-
if (insertMenuRef.current?.contains(e.target as Node)) return;
|
|
967
|
-
if (codeSnippetMenuRef.current?.contains(e.target as Node)) return;
|
|
968
|
-
if (mermaidTypeMenuRef.current?.contains(e.target as Node)) return;
|
|
969
|
-
closeInsertMenu();
|
|
970
|
-
};
|
|
971
|
-
document.addEventListener('mousedown', handleClick);
|
|
972
|
-
return () => document.removeEventListener('mousedown', handleClick);
|
|
973
|
-
}, [insertMenuAnchor, closeInsertMenu]);
|
|
974
|
-
|
|
975
|
-
// Open-up vs open-down: the overflow menu is anchored to its trigger with
|
|
976
|
-
// `top: 100%` by default. When the toolbar lives near the bottom of a
|
|
977
|
-
// small container (e.g. a chat composer), a downward menu gets clipped.
|
|
978
|
-
// Measure on open and flip the anchor to `bottom: 100%` if the space
|
|
979
|
-
// above the trigger is larger than the space below.
|
|
980
|
-
const [overflowPlacement, setOverflowPlacement] = useState<'down' | 'up'>('down');
|
|
981
|
-
useEffect(() => {
|
|
982
|
-
if (!showOverflow || !overflowRef.current) return;
|
|
983
|
-
const trigger = overflowRef.current.querySelector<HTMLElement>(
|
|
984
|
-
'.squisq-toolbar-overflow-trigger',
|
|
985
|
-
);
|
|
986
|
-
if (!trigger) return;
|
|
987
|
-
const rect = trigger.getBoundingClientRect();
|
|
988
|
-
const spaceBelow = window.innerHeight - rect.bottom;
|
|
989
|
-
const spaceAbove = rect.top;
|
|
990
|
-
// Assume a typical menu height; exact measurement is unreliable on first
|
|
991
|
-
// open because the menu hasn't rendered yet when this runs.
|
|
992
|
-
const ESTIMATED_MENU_HEIGHT = 260;
|
|
993
|
-
if (spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow) {
|
|
994
|
-
setOverflowPlacement('up');
|
|
995
|
-
} else {
|
|
996
|
-
setOverflowPlacement('down');
|
|
997
|
-
}
|
|
998
|
-
}, [showOverflow]);
|
|
999
|
-
|
|
1000
|
-
// Force re-render when Tiptap selection or formatting state changes
|
|
1001
|
-
const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
|
|
1002
|
-
useEffect(() => {
|
|
1003
|
-
if (!formattingEditor) return;
|
|
1004
|
-
formattingEditor.on('transaction', forceUpdate);
|
|
1005
|
-
return () => {
|
|
1006
|
-
formattingEditor.off('transaction', forceUpdate);
|
|
1007
|
-
};
|
|
1008
|
-
}, [formattingEditor]);
|
|
1009
|
-
|
|
1010
|
-
// ── Tiptap handler ─────────────────────────────────────
|
|
1011
|
-
const handleTiptap = useCallback(
|
|
1012
|
-
(id: string) => {
|
|
1013
|
-
const tiptapEditor = formattingEditor;
|
|
1014
|
-
if (!tiptapEditor) return;
|
|
1015
|
-
const chain = tiptapEditor.chain().focus();
|
|
1016
|
-
switch (id) {
|
|
1017
|
-
case 'bold':
|
|
1018
|
-
chain.toggleBold().run();
|
|
1019
|
-
break;
|
|
1020
|
-
case 'italic':
|
|
1021
|
-
chain.toggleItalic().run();
|
|
1022
|
-
break;
|
|
1023
|
-
case 'strikethrough':
|
|
1024
|
-
chain.toggleStrike().run();
|
|
1025
|
-
break;
|
|
1026
|
-
case 'code':
|
|
1027
|
-
chain.toggleCode().run();
|
|
1028
|
-
break;
|
|
1029
|
-
case 'h1':
|
|
1030
|
-
chain.toggleHeading({ level: 1 }).run();
|
|
1031
|
-
break;
|
|
1032
|
-
case 'h2':
|
|
1033
|
-
chain.toggleHeading({ level: 2 }).run();
|
|
1034
|
-
break;
|
|
1035
|
-
case 'h3':
|
|
1036
|
-
chain.toggleHeading({ level: 3 }).run();
|
|
1037
|
-
break;
|
|
1038
|
-
case 'h4':
|
|
1039
|
-
chain.toggleHeading({ level: 4 }).run();
|
|
1040
|
-
break;
|
|
1041
|
-
case 'h5':
|
|
1042
|
-
chain.toggleHeading({ level: 5 }).run();
|
|
1043
|
-
break;
|
|
1044
|
-
case 'h6':
|
|
1045
|
-
chain.toggleHeading({ level: 6 }).run();
|
|
1046
|
-
break;
|
|
1047
|
-
case 'quote':
|
|
1048
|
-
chain.toggleBlockquote().run();
|
|
1049
|
-
break;
|
|
1050
|
-
case 'ul':
|
|
1051
|
-
chain.toggleBulletList().run();
|
|
1052
|
-
break;
|
|
1053
|
-
case 'ol':
|
|
1054
|
-
chain.toggleOrderedList().run();
|
|
1055
|
-
break;
|
|
1056
|
-
case 'codeblock':
|
|
1057
|
-
chain.toggleCodeBlock().run();
|
|
1058
|
-
break;
|
|
1059
|
-
case 'hr':
|
|
1060
|
-
chain.setHorizontalRule().run();
|
|
1061
|
-
break;
|
|
1062
|
-
case 'link': {
|
|
1063
|
-
const isActive = tiptapEditor.isActive('link');
|
|
1064
|
-
let initialText = '';
|
|
1065
|
-
let initialUrl = '';
|
|
1066
|
-
if (isActive) {
|
|
1067
|
-
// Snap selection to the full link mark so editing replaces
|
|
1068
|
-
// the entire `[text](url)` rather than just the cursor word.
|
|
1069
|
-
tiptapEditor.chain().focus().extendMarkRange('link').run();
|
|
1070
|
-
const sel = tiptapEditor.state.selection;
|
|
1071
|
-
initialText = tiptapEditor.state.doc.textBetween(sel.from, sel.to, ' ');
|
|
1072
|
-
initialUrl = (tiptapEditor.getAttributes('link') as { href?: string }).href ?? '';
|
|
1073
|
-
} else {
|
|
1074
|
-
const { from, to, empty } = tiptapEditor.state.selection;
|
|
1075
|
-
if (!empty) {
|
|
1076
|
-
initialText = tiptapEditor.state.doc.textBetween(from, to, ' ');
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
setLinkDialog({
|
|
1080
|
-
mode: isActive ? 'update' : 'insert',
|
|
1081
|
-
target: 'wysiwyg',
|
|
1082
|
-
initialText,
|
|
1083
|
-
initialUrl,
|
|
1084
|
-
rawRange: null,
|
|
1085
|
-
});
|
|
1086
|
-
break;
|
|
1087
|
-
}
|
|
1088
|
-
case 'table':
|
|
1089
|
-
tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
|
1090
|
-
break;
|
|
1091
|
-
case 'tasklist':
|
|
1092
|
-
insertTaskList(tiptapEditor);
|
|
1093
|
-
break;
|
|
1094
|
-
case 'diagram':
|
|
1095
|
-
// Diagrams are ASCII-art code fences; the AsciiDiagramExtension
|
|
1096
|
-
// mounts the interactive canvas over the inserted starter.
|
|
1097
|
-
insertAsciiDiagramBlock(tiptapEditor);
|
|
1098
|
-
break;
|
|
1099
|
-
case 'complexdiagram':
|
|
1100
|
-
// Complex diagrams remain full Mermaid source; the Mermaid extension
|
|
1101
|
-
// renders the fence without narrowing it to Squisq's graph model.
|
|
1102
|
-
insertMermaidDiagramBlock(tiptapEditor);
|
|
1103
|
-
break;
|
|
1104
|
-
case 'tree':
|
|
1105
|
-
// File trees are ASCII tree fences; TreeViewExtension mounts the
|
|
1106
|
-
// interactive outline over the inserted starter.
|
|
1107
|
-
insertTreeBlock(tiptapEditor);
|
|
1108
|
-
break;
|
|
1109
|
-
case 'timeline':
|
|
1110
|
-
// The authored fence is immediately replaced by the WYSIWYG rail
|
|
1111
|
-
// canvas; Preview uses the same events as vector layers.
|
|
1112
|
-
insertTimelineBlock(tiptapEditor);
|
|
1113
|
-
break;
|
|
1114
|
-
case 'drawing':
|
|
1115
|
-
// Empty drawing is usable via the canvas's "Shapes ▾" palette;
|
|
1116
|
-
// each shape persists as a child heading.
|
|
1117
|
-
insertTemplateHeading(tiptapEditor, { template: 'drawing', text: 'Drawing' });
|
|
1118
|
-
break;
|
|
1119
|
-
case 'layout':
|
|
1120
|
-
// Seed with a starter text layer (a child sub-block) so the canvas
|
|
1121
|
-
// isn't blank; further layers add via the toolbar Text/Box buttons.
|
|
1122
|
-
insertLayoutBlock(tiptapEditor);
|
|
1123
|
-
break;
|
|
1124
|
-
}
|
|
1125
|
-
},
|
|
1126
|
-
[formattingEditor],
|
|
1127
|
-
);
|
|
1128
|
-
|
|
1129
|
-
// ── Raw markdown handler ───────────────────────────────
|
|
1130
|
-
const handleRaw = useCallback(
|
|
1131
|
-
(id: string) => {
|
|
1132
|
-
if (monacoEditor) {
|
|
1133
|
-
// Use Monaco's selection API for proper wrap/insert behavior
|
|
1134
|
-
const selection = monacoEditor.getSelection();
|
|
1135
|
-
const model = monacoEditor.getModel();
|
|
1136
|
-
if (!selection || !model) return;
|
|
1137
|
-
|
|
1138
|
-
const selectedText = model.getValueInRange(selection);
|
|
1139
|
-
const hasSelection = selectedText.length > 0;
|
|
1140
|
-
|
|
1141
|
-
let replacement = '';
|
|
1142
|
-
let newCursorOffset = 0; // offset from start of replacement to place cursor
|
|
1143
|
-
|
|
1144
|
-
// Inline wrapping: wrap selection or insert placeholder
|
|
1145
|
-
const wrapInline = (before: string, after: string, placeholder: string) => {
|
|
1146
|
-
if (hasSelection) {
|
|
1147
|
-
replacement = before + selectedText + after;
|
|
1148
|
-
} else {
|
|
1149
|
-
replacement = before + placeholder + after;
|
|
1150
|
-
// Select the placeholder text after insertion
|
|
1151
|
-
newCursorOffset = before.length;
|
|
1152
|
-
}
|
|
1153
|
-
};
|
|
1154
|
-
|
|
1155
|
-
// Block-level: prefix each selected line, or insert a new block
|
|
1156
|
-
const prefixLines = (prefix: string, placeholder: string) => {
|
|
1157
|
-
if (hasSelection) {
|
|
1158
|
-
replacement = selectedText
|
|
1159
|
-
.split('\n')
|
|
1160
|
-
.map((line: string) => prefix + line)
|
|
1161
|
-
.join('\n');
|
|
1162
|
-
} else {
|
|
1163
|
-
replacement = prefix + placeholder;
|
|
1164
|
-
newCursorOffset = prefix.length;
|
|
1165
|
-
}
|
|
1166
|
-
};
|
|
1167
|
-
|
|
1168
|
-
switch (id) {
|
|
1169
|
-
case 'bold':
|
|
1170
|
-
wrapInline('**', '**', 'bold text');
|
|
1171
|
-
break;
|
|
1172
|
-
case 'italic':
|
|
1173
|
-
wrapInline('*', '*', 'italic text');
|
|
1174
|
-
break;
|
|
1175
|
-
case 'strikethrough':
|
|
1176
|
-
wrapInline('~~', '~~', 'strikethrough');
|
|
1177
|
-
break;
|
|
1178
|
-
case 'code':
|
|
1179
|
-
wrapInline('`', '`', 'code');
|
|
1180
|
-
break;
|
|
1181
|
-
case 'h1':
|
|
1182
|
-
prefixLines('# ', 'Heading 1');
|
|
1183
|
-
break;
|
|
1184
|
-
case 'h2':
|
|
1185
|
-
prefixLines('## ', 'Heading 2');
|
|
1186
|
-
break;
|
|
1187
|
-
case 'h3':
|
|
1188
|
-
prefixLines('### ', 'Heading 3');
|
|
1189
|
-
break;
|
|
1190
|
-
case 'h4':
|
|
1191
|
-
prefixLines('#### ', 'Heading 4');
|
|
1192
|
-
break;
|
|
1193
|
-
case 'h5':
|
|
1194
|
-
prefixLines('##### ', 'Heading 5');
|
|
1195
|
-
break;
|
|
1196
|
-
case 'h6':
|
|
1197
|
-
prefixLines('###### ', 'Heading 6');
|
|
1198
|
-
break;
|
|
1199
|
-
case 'quote':
|
|
1200
|
-
prefixLines('> ', 'Quote');
|
|
1201
|
-
break;
|
|
1202
|
-
case 'ul':
|
|
1203
|
-
prefixLines('- ', 'Item');
|
|
1204
|
-
break;
|
|
1205
|
-
case 'ol':
|
|
1206
|
-
prefixLines('1. ', 'Item');
|
|
1207
|
-
break;
|
|
1208
|
-
case 'codeblock': {
|
|
1209
|
-
const inner = hasSelection ? selectedText : 'code';
|
|
1210
|
-
replacement = '```\n' + inner + '\n```';
|
|
1211
|
-
if (!hasSelection) newCursorOffset = 4; // after ```\n
|
|
1212
|
-
break;
|
|
1213
|
-
}
|
|
1214
|
-
case 'hr': {
|
|
1215
|
-
replacement = '\n---\n';
|
|
1216
|
-
break;
|
|
1217
|
-
}
|
|
1218
|
-
case 'link': {
|
|
1219
|
-
// Open the LinkDialog instead of inserting literal text. If the
|
|
1220
|
-
// cursor sits inside an existing `[text](url)` on this line,
|
|
1221
|
-
// prefill from it and replace the whole match on confirm.
|
|
1222
|
-
const lineNumber = selection.startLineNumber;
|
|
1223
|
-
const lineText = model.getLineContent(lineNumber);
|
|
1224
|
-
const cursorCol = selection.startColumn;
|
|
1225
|
-
const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g;
|
|
1226
|
-
let match: RegExpExecArray | null;
|
|
1227
|
-
let existing: { text: string; url: string; range: IRange } | null = null;
|
|
1228
|
-
while ((match = linkRe.exec(lineText)) !== null) {
|
|
1229
|
-
const startCol = match.index + 1; // 1-based
|
|
1230
|
-
const endCol = startCol + match[0].length;
|
|
1231
|
-
if (cursorCol >= startCol && cursorCol <= endCol) {
|
|
1232
|
-
existing = {
|
|
1233
|
-
text: match[1],
|
|
1234
|
-
url: match[2],
|
|
1235
|
-
range: {
|
|
1236
|
-
startLineNumber: lineNumber,
|
|
1237
|
-
startColumn: startCol,
|
|
1238
|
-
endLineNumber: lineNumber,
|
|
1239
|
-
endColumn: endCol,
|
|
1240
|
-
},
|
|
1241
|
-
};
|
|
1242
|
-
break;
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
setLinkDialog({
|
|
1246
|
-
mode: existing ? 'update' : 'insert',
|
|
1247
|
-
target: 'raw',
|
|
1248
|
-
initialText: existing ? existing.text : hasSelection ? selectedText : '',
|
|
1249
|
-
initialUrl: existing ? existing.url : '',
|
|
1250
|
-
rawRange: existing ? existing.range : null,
|
|
1251
|
-
});
|
|
1252
|
-
// Skip the executeEdits/setPosition tail below — the dialog will
|
|
1253
|
-
// apply its own edit on confirm.
|
|
1254
|
-
return;
|
|
1255
|
-
}
|
|
1256
|
-
case 'table': {
|
|
1257
|
-
const tpl =
|
|
1258
|
-
'| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |';
|
|
1259
|
-
replacement = '\n' + tpl + '\n';
|
|
1260
|
-
newCursorOffset = 3; // after \n|
|
|
1261
|
-
break;
|
|
1262
|
-
}
|
|
1263
|
-
case 'tasklist': {
|
|
1264
|
-
replacement = `\n${TASK_LIST_MARKDOWN}\n`;
|
|
1265
|
-
newCursorOffset = replacement.length;
|
|
1266
|
-
break;
|
|
1267
|
-
}
|
|
1268
|
-
case 'diagram': {
|
|
1269
|
-
// A diagram is an ASCII-art code fence; the WYSIWYG view
|
|
1270
|
-
// renders its editable canvas, preview renders it as a slide.
|
|
1271
|
-
replacement = DIAGRAM_STARTER_MARKDOWN;
|
|
1272
|
-
newCursorOffset = replacement.length;
|
|
1273
|
-
break;
|
|
1274
|
-
}
|
|
1275
|
-
case 'complexdiagram': {
|
|
1276
|
-
replacement = MERMAID_STARTER_MARKDOWN;
|
|
1277
|
-
newCursorOffset = replacement.length;
|
|
1278
|
-
break;
|
|
1279
|
-
}
|
|
1280
|
-
case 'tree': {
|
|
1281
|
-
replacement = TREE_STARTER_MARKDOWN;
|
|
1282
|
-
newCursorOffset = replacement.length;
|
|
1283
|
-
break;
|
|
1284
|
-
}
|
|
1285
|
-
case 'timeline': {
|
|
1286
|
-
replacement = TIMELINE_STARTER_MARKDOWN;
|
|
1287
|
-
newCursorOffset = replacement.length;
|
|
1288
|
-
break;
|
|
1289
|
-
}
|
|
1290
|
-
case 'drawing': {
|
|
1291
|
-
replacement = '\n## Drawing {[drawing]}\n';
|
|
1292
|
-
newCursorOffset = replacement.length;
|
|
1293
|
-
break;
|
|
1294
|
-
}
|
|
1295
|
-
case 'layout': {
|
|
1296
|
-
// Seed a starter text layer (a child sub-block) so it isn't blank.
|
|
1297
|
-
replacement = LAYOUT_STARTER_MARKDOWN;
|
|
1298
|
-
newCursorOffset = replacement.length;
|
|
1299
|
-
break;
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// Apply the edit via Monaco's executeEdits for proper undo support
|
|
1304
|
-
const range = selection;
|
|
1305
|
-
monacoEditor.executeEdits('toolbar', [{ range, text: replacement }]);
|
|
1306
|
-
|
|
1307
|
-
// If no selection, move the cursor to the command's preferred edit point.
|
|
1308
|
-
if (!hasSelection && newCursorOffset > 0) {
|
|
1309
|
-
const startPos = model.getPositionAt(
|
|
1310
|
-
model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
|
|
1311
|
-
);
|
|
1312
|
-
monacoEditor.setPosition(startPos);
|
|
1313
|
-
monacoEditor.revealPositionInCenterIfOutsideViewport(startPos);
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
monacoEditor.focus();
|
|
1317
|
-
} else {
|
|
1318
|
-
// Fallback: no Monaco instance, just append
|
|
1319
|
-
let insertion = '';
|
|
1320
|
-
switch (id) {
|
|
1321
|
-
case 'bold':
|
|
1322
|
-
insertion = '**bold text**';
|
|
1323
|
-
break;
|
|
1324
|
-
case 'italic':
|
|
1325
|
-
insertion = '*italic text*';
|
|
1326
|
-
break;
|
|
1327
|
-
case 'strikethrough':
|
|
1328
|
-
insertion = '~~strikethrough~~';
|
|
1329
|
-
break;
|
|
1330
|
-
case 'code':
|
|
1331
|
-
insertion = '`code`';
|
|
1332
|
-
break;
|
|
1333
|
-
case 'h1':
|
|
1334
|
-
insertion = '\n# Heading 1\n';
|
|
1335
|
-
break;
|
|
1336
|
-
case 'h2':
|
|
1337
|
-
insertion = '\n## Heading 2\n';
|
|
1338
|
-
break;
|
|
1339
|
-
case 'h3':
|
|
1340
|
-
insertion = '\n### Heading 3\n';
|
|
1341
|
-
break;
|
|
1342
|
-
case 'quote':
|
|
1343
|
-
insertion = '\n> Quote\n';
|
|
1344
|
-
break;
|
|
1345
|
-
case 'ul':
|
|
1346
|
-
insertion = '\n- Item\n';
|
|
1347
|
-
break;
|
|
1348
|
-
case 'ol':
|
|
1349
|
-
insertion = '\n1. Item\n';
|
|
1350
|
-
break;
|
|
1351
|
-
case 'codeblock':
|
|
1352
|
-
insertion = '\n```\ncode\n```\n';
|
|
1353
|
-
break;
|
|
1354
|
-
case 'hr':
|
|
1355
|
-
insertion = '\n---\n';
|
|
1356
|
-
break;
|
|
1357
|
-
case 'link':
|
|
1358
|
-
insertion = '[link text](url)';
|
|
1359
|
-
break;
|
|
1360
|
-
case 'table':
|
|
1361
|
-
insertion =
|
|
1362
|
-
'\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
|
|
1363
|
-
break;
|
|
1364
|
-
case 'tasklist':
|
|
1365
|
-
insertion = `\n${TASK_LIST_MARKDOWN}\n`;
|
|
1366
|
-
break;
|
|
1367
|
-
case 'diagram':
|
|
1368
|
-
insertion = DIAGRAM_STARTER_MARKDOWN;
|
|
1369
|
-
break;
|
|
1370
|
-
case 'complexdiagram':
|
|
1371
|
-
insertion = MERMAID_STARTER_MARKDOWN;
|
|
1372
|
-
break;
|
|
1373
|
-
case 'tree':
|
|
1374
|
-
insertion = TREE_STARTER_MARKDOWN;
|
|
1375
|
-
break;
|
|
1376
|
-
case 'timeline':
|
|
1377
|
-
insertion = TIMELINE_STARTER_MARKDOWN;
|
|
1378
|
-
break;
|
|
1379
|
-
case 'drawing':
|
|
1380
|
-
insertion = '\n## Drawing {[drawing]}\n';
|
|
1381
|
-
break;
|
|
1382
|
-
case 'layout':
|
|
1383
|
-
insertion = LAYOUT_STARTER_MARKDOWN;
|
|
1384
|
-
break;
|
|
1385
|
-
}
|
|
1386
|
-
if (insertion) {
|
|
1387
|
-
setMarkdownSource(markdownSource + insertion);
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
},
|
|
1391
|
-
[monacoEditor, markdownSource, setMarkdownSource],
|
|
1392
|
-
);
|
|
1393
|
-
|
|
1394
|
-
const handleCodeSnippetInsert = useCallback(
|
|
1395
|
-
(language: CodeSnippetLanguage) => {
|
|
1396
|
-
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1397
|
-
insertFenceBlock(tiptapEditor, language.starter, language.fenceLanguage);
|
|
1398
|
-
closeInsertMenu();
|
|
1399
|
-
return;
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
if (monacoEditor) {
|
|
1403
|
-
const selection = monacoEditor.getSelection();
|
|
1404
|
-
const model = monacoEditor.getModel();
|
|
1405
|
-
if (!selection || !model) return;
|
|
1406
|
-
const selectedText = model.getValueInRange(selection);
|
|
1407
|
-
const source = selectedText.length > 0 ? selectedText : language.starter;
|
|
1408
|
-
const markdown = codeSnippetMarkdown(language.fenceLanguage, source);
|
|
1409
|
-
monacoEditor.executeEdits('toolbar-code-snippet', [{ range: selection, text: markdown }]);
|
|
1410
|
-
if (selectedText.length === 0) {
|
|
1411
|
-
const sourceOffset = 5 + language.fenceLanguage.length;
|
|
1412
|
-
const sourcePosition = model.getPositionAt(
|
|
1413
|
-
model.getOffsetAt(selection.getStartPosition()) + sourceOffset,
|
|
1414
|
-
);
|
|
1415
|
-
monacoEditor.setPosition(sourcePosition);
|
|
1416
|
-
monacoEditor.revealPositionInCenterIfOutsideViewport(sourcePosition);
|
|
1417
|
-
}
|
|
1418
|
-
monacoEditor.focus();
|
|
1419
|
-
closeInsertMenu();
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
setMarkdownSource(
|
|
1424
|
-
markdownSource + codeSnippetMarkdown(language.fenceLanguage, language.starter),
|
|
1425
|
-
);
|
|
1426
|
-
closeInsertMenu();
|
|
1427
|
-
},
|
|
1428
|
-
[activeView, closeInsertMenu, markdownSource, monacoEditor, setMarkdownSource, tiptapEditor],
|
|
1429
|
-
);
|
|
1430
|
-
|
|
1431
|
-
// ── Selected-text conversion handlers ─────────────────
|
|
1432
|
-
const handleMermaidDiagramInsert = useCallback(
|
|
1433
|
-
(diagramType: MermaidDiagramType) => {
|
|
1434
|
-
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1435
|
-
insertMermaidDiagramBlock(tiptapEditor, diagramType.starter);
|
|
1436
|
-
closeInsertMenu();
|
|
1437
|
-
return;
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
const markdown = mermaidDiagramMarkdown(diagramType.starter);
|
|
1441
|
-
if (monacoEditor) {
|
|
1442
|
-
const selection = monacoEditor.getSelection();
|
|
1443
|
-
const model = monacoEditor.getModel();
|
|
1444
|
-
if (!selection || !model) return;
|
|
1445
|
-
monacoEditor.executeEdits('toolbar-mermaid-diagram', [
|
|
1446
|
-
{ range: selection, text: markdown },
|
|
1447
|
-
]);
|
|
1448
|
-
const endPosition = model.getPositionAt(
|
|
1449
|
-
model.getOffsetAt(selection.getStartPosition()) + markdown.length,
|
|
1450
|
-
);
|
|
1451
|
-
monacoEditor.setPosition(endPosition);
|
|
1452
|
-
monacoEditor.revealPositionInCenterIfOutsideViewport(endPosition);
|
|
1453
|
-
monacoEditor.focus();
|
|
1454
|
-
closeInsertMenu();
|
|
1455
|
-
return;
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
setMarkdownSource(markdownSource + markdown);
|
|
1459
|
-
closeInsertMenu();
|
|
1460
|
-
},
|
|
1461
|
-
[activeView, closeInsertMenu, markdownSource, monacoEditor, setMarkdownSource, tiptapEditor],
|
|
1462
|
-
);
|
|
1463
|
-
|
|
1464
|
-
const readSelectedText = useCallback((): string => {
|
|
1465
|
-
// Canvas text overlays intentionally have a smaller schema without tables
|
|
1466
|
-
// or task lists. Ignore their selection instead of surfacing actions that
|
|
1467
|
-
// cannot be represented by those layers.
|
|
1468
|
-
if (activeSceneText) return '';
|
|
1469
|
-
|
|
1470
|
-
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1471
|
-
const { from, to, empty } = tiptapEditor.state.selection;
|
|
1472
|
-
return empty ? '' : tiptapEditor.state.doc.textBetween(from, to, '\n');
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
if (activeView === 'raw' && monacoEditor) {
|
|
1476
|
-
const selection = monacoEditor.getSelection();
|
|
1477
|
-
const model = monacoEditor.getModel();
|
|
1478
|
-
return selection && model ? model.getValueInRange(selection) : '';
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
return '';
|
|
1482
|
-
}, [activeSceneText, activeView, monacoEditor, tiptapEditor]);
|
|
1483
|
-
|
|
1484
|
-
const handleConvertSelection = useCallback(
|
|
1485
|
-
(target: 'table' | 'tasklist') => {
|
|
1486
|
-
const selectedText = readSelectedText();
|
|
1487
|
-
if (!selectedText.trim()) return;
|
|
1488
|
-
|
|
1489
|
-
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1490
|
-
const content =
|
|
1491
|
-
target === 'table'
|
|
1492
|
-
? tableContent(selectionToTable(selectedText).rows)
|
|
1493
|
-
: taskListContent(selectionToTaskItems(selectedText));
|
|
1494
|
-
tiptapEditor
|
|
1495
|
-
.chain()
|
|
1496
|
-
.focus()
|
|
1497
|
-
.insertContentAt(blockConversionRange(tiptapEditor), content)
|
|
1498
|
-
.run();
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
if (activeView === 'raw' && monacoEditor) {
|
|
1503
|
-
const selection = monacoEditor.getSelection();
|
|
1504
|
-
if (!selection) return;
|
|
1505
|
-
const converted =
|
|
1506
|
-
target === 'table'
|
|
1507
|
-
? selectionToTableMarkdown(selectedText)
|
|
1508
|
-
: selectionToTaskListMarkdown(selectedText);
|
|
1509
|
-
if (!converted) return;
|
|
1510
|
-
|
|
1511
|
-
// Monaco line selections commonly include the newline after the last
|
|
1512
|
-
// selected line. Retain selected edge newlines so the replacement
|
|
1513
|
-
// block never runs into the surrounding Markdown.
|
|
1514
|
-
const leadingNewline = /^(?:\r?\n)/.test(selectedText) ? '\n' : '';
|
|
1515
|
-
const trailingNewline = /(?:\r?\n)$/.test(selectedText) ? '\n' : '';
|
|
1516
|
-
monacoEditor.executeEdits('toolbar-convert-selection', [
|
|
1517
|
-
{ range: selection, text: leadingNewline + converted + trailingNewline },
|
|
1518
|
-
]);
|
|
1519
|
-
monacoEditor.focus();
|
|
1520
|
-
}
|
|
1521
|
-
},
|
|
1522
|
-
[activeView, monacoEditor, readSelectedText, tiptapEditor],
|
|
1523
|
-
);
|
|
1524
|
-
|
|
1525
|
-
// ── Image upload handler ───────────────────────────────
|
|
1526
|
-
const handleImageFile = useCallback(
|
|
1527
|
-
async (file: File) => {
|
|
1528
|
-
if (!mediaProvider) return;
|
|
1529
|
-
const buffer = await file.arrayBuffer();
|
|
1530
|
-
const relativePath = await mediaProvider.addMedia(file.name, buffer, file.type);
|
|
1531
|
-
bumpMediaRevision();
|
|
1532
|
-
const altText = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
|
1533
|
-
|
|
1534
|
-
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1535
|
-
tiptapEditor.chain().focus().setImage({ src: relativePath, alt: altText }).run();
|
|
1536
|
-
} else if (monacoEditor) {
|
|
1537
|
-
const selection = monacoEditor.getSelection();
|
|
1538
|
-
const model = monacoEditor.getModel();
|
|
1539
|
-
if (selection && model) {
|
|
1540
|
-
const md = ``;
|
|
1541
|
-
monacoEditor.executeEdits('toolbar', [{ range: selection, text: md }]);
|
|
1542
|
-
monacoEditor.focus();
|
|
1543
|
-
}
|
|
1544
|
-
} else {
|
|
1545
|
-
setMarkdownSource(markdownSource + `\n\n`);
|
|
1546
|
-
}
|
|
1547
|
-
},
|
|
1548
|
-
[
|
|
1549
|
-
mediaProvider,
|
|
1550
|
-
activeView,
|
|
1551
|
-
tiptapEditor,
|
|
1552
|
-
monacoEditor,
|
|
1553
|
-
markdownSource,
|
|
1554
|
-
setMarkdownSource,
|
|
1555
|
-
bumpMediaRevision,
|
|
1556
|
-
],
|
|
1557
|
-
);
|
|
1558
|
-
|
|
1559
|
-
const handleAction = useCallback(
|
|
1560
|
-
(id: string) => {
|
|
1561
|
-
if (id === 'image') {
|
|
1562
|
-
imageInputRef.current?.click();
|
|
1563
|
-
return;
|
|
1564
|
-
}
|
|
1565
|
-
if (id === 'emoji') {
|
|
1566
|
-
// Toggle the popover: clicking the button again closes it.
|
|
1567
|
-
if (emojiPickerAnchor) closeEmojiPicker();
|
|
1568
|
-
else openEmojiPicker();
|
|
1569
|
-
return;
|
|
1570
|
-
}
|
|
1571
|
-
// A focused canvas textbox takes the formatting (handleTiptap targets
|
|
1572
|
-
// `formattingEditor`); otherwise route to the document editor.
|
|
1573
|
-
if (activeSceneText || (activeView === 'wysiwyg' && tiptapEditor)) {
|
|
1574
|
-
handleTiptap(id);
|
|
1575
|
-
} else {
|
|
1576
|
-
handleRaw(id);
|
|
1577
|
-
}
|
|
1578
|
-
},
|
|
1579
|
-
[
|
|
1580
|
-
activeView,
|
|
1581
|
-
tiptapEditor,
|
|
1582
|
-
activeSceneText,
|
|
1583
|
-
handleTiptap,
|
|
1584
|
-
handleRaw,
|
|
1585
|
-
emojiPickerAnchor,
|
|
1586
|
-
openEmojiPicker,
|
|
1587
|
-
closeEmojiPicker,
|
|
1588
|
-
],
|
|
1589
|
-
);
|
|
1590
|
-
|
|
1591
|
-
// ── Picker insert (emoji or FontAwesome icon) ──────
|
|
1592
|
-
// Inserts a chosen picker entry at the cursor. We bypass
|
|
1593
|
-
// `insertAtCursor` (which routes through markdown→Tiptap conversion
|
|
1594
|
-
// and wraps the input in a paragraph) so entries land inline at the
|
|
1595
|
-
// caret rather than starting a new block. Emoji insert as a plain
|
|
1596
|
-
// character; FontAwesome icons insert as the `InlineIcon` Tiptap
|
|
1597
|
-
// node so the editor renders them inline immediately.
|
|
1598
|
-
const handleEmojiSelect = useCallback(
|
|
1599
|
-
(entry: PickerEntry) => {
|
|
1600
|
-
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1601
|
-
if (entry.kind === 'emoji') {
|
|
1602
|
-
tiptapEditor.chain().focus().insertContent(entry.char).run();
|
|
1603
|
-
} else {
|
|
1604
|
-
tiptapEditor
|
|
1605
|
-
.chain()
|
|
1606
|
-
.focus()
|
|
1607
|
-
.insertContent({
|
|
1608
|
-
type: 'inlineIcon',
|
|
1609
|
-
attrs: { token: entry.token, family: entry.family, name: entry.name },
|
|
1610
|
-
})
|
|
1611
|
-
.run();
|
|
1612
|
-
}
|
|
1613
|
-
} else if (activeView === 'raw' && monacoEditor) {
|
|
1614
|
-
const insertion = entry.kind === 'emoji' ? entry.char : `{[${entry.token}]}`;
|
|
1615
|
-
const position = monacoEditor.getPosition();
|
|
1616
|
-
if (position) {
|
|
1617
|
-
const range = {
|
|
1618
|
-
startLineNumber: position.lineNumber,
|
|
1619
|
-
startColumn: position.column,
|
|
1620
|
-
endLineNumber: position.lineNumber,
|
|
1621
|
-
endColumn: position.column,
|
|
1622
|
-
};
|
|
1623
|
-
monacoEditor.executeEdits('picker-insert', [{ range, text: insertion }]);
|
|
1624
|
-
monacoEditor.focus();
|
|
1625
|
-
} else {
|
|
1626
|
-
setMarkdownSource(markdownSource + insertion);
|
|
1627
|
-
}
|
|
1628
|
-
} else {
|
|
1629
|
-
const insertion = entry.kind === 'emoji' ? entry.char : `{[${entry.token}]}`;
|
|
1630
|
-
setMarkdownSource(markdownSource + insertion);
|
|
1631
|
-
}
|
|
1632
|
-
closeEmojiPicker();
|
|
1633
|
-
},
|
|
1634
|
-
[activeView, tiptapEditor, monacoEditor, markdownSource, setMarkdownSource, closeEmojiPicker],
|
|
1635
|
-
);
|
|
1636
|
-
|
|
1637
|
-
// ── Ctrl+K / Cmd+K → open the link dialog ────────────
|
|
1638
|
-
// Mirrors the behaviour of common editors (Word, Google Docs, VS Code's
|
|
1639
|
-
// Markdown preview): if the cursor is in a Squisq editor surface, the
|
|
1640
|
-
// shortcut routes through the same handler the toolbar Link button uses,
|
|
1641
|
-
// which prefills the dialog from the current selection (or the link
|
|
1642
|
-
// under the cursor) before opening.
|
|
1643
|
-
useEffect(() => {
|
|
1644
|
-
const onKeyDown = (e: KeyboardEvent) => {
|
|
1645
|
-
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
|
|
1646
|
-
if (e.key.toLowerCase() !== 'k') return;
|
|
1647
|
-
const target = e.target as HTMLElement | null;
|
|
1648
|
-
if (!target) return;
|
|
1649
|
-
// Only intercept when focus is inside one of our editor surfaces.
|
|
1650
|
-
const inEditor = !!target.closest(
|
|
1651
|
-
'.squisq-wysiwyg-editor, .ProseMirror, .squisq-raw-editor-container, .monaco-editor',
|
|
1652
|
-
);
|
|
1653
|
-
if (!inEditor) return;
|
|
1654
|
-
e.preventDefault();
|
|
1655
|
-
e.stopPropagation();
|
|
1656
|
-
handleAction('link');
|
|
1657
|
-
};
|
|
1658
|
-
window.addEventListener('keydown', onKeyDown, true);
|
|
1659
|
-
return () => window.removeEventListener('keydown', onKeyDown, true);
|
|
1660
|
-
}, [handleAction]);
|
|
1661
|
-
|
|
1662
|
-
// ── Link dialog confirm ──────────────────────────────
|
|
1663
|
-
const handleLinkConfirm = useCallback(
|
|
1664
|
-
(text: string, url: string) => {
|
|
1665
|
-
if (!linkDialog) return;
|
|
1666
|
-
const trimmedUrl = url.trim();
|
|
1667
|
-
const trimmedText = text.trim();
|
|
1668
|
-
|
|
1669
|
-
// Target the focused canvas textbox editor when one is active, else
|
|
1670
|
-
// the document editor (the dialog was opened from the same target).
|
|
1671
|
-
const wysiwygTarget = formattingEditor;
|
|
1672
|
-
if (linkDialog.target === 'wysiwyg' && wysiwygTarget) {
|
|
1673
|
-
if (!trimmedUrl) {
|
|
1674
|
-
// Empty URL on update = unlink. On insert with no URL, do nothing.
|
|
1675
|
-
if (linkDialog.mode === 'update') {
|
|
1676
|
-
wysiwygTarget.chain().focus().unsetLink().run();
|
|
1677
|
-
}
|
|
1678
|
-
setLinkDialog(null);
|
|
1679
|
-
return;
|
|
1680
|
-
}
|
|
1681
|
-
const visibleText = trimmedText || trimmedUrl;
|
|
1682
|
-
const chain = wysiwygTarget.chain().focus();
|
|
1683
|
-
// Insert (or replace selection) with text carrying a link mark. When
|
|
1684
|
-
// updating an existing link, the selection was extended to the full
|
|
1685
|
-
// mark range earlier, so this replaces the entire `[text](url)`.
|
|
1686
|
-
chain
|
|
1687
|
-
.insertContent({
|
|
1688
|
-
type: 'text',
|
|
1689
|
-
text: visibleText,
|
|
1690
|
-
marks: [{ type: 'link', attrs: { href: trimmedUrl } }],
|
|
1691
|
-
})
|
|
1692
|
-
.run();
|
|
1693
|
-
setLinkDialog(null);
|
|
1694
|
-
return;
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
if (linkDialog.target === 'raw' && monacoEditor) {
|
|
1698
|
-
const model = monacoEditor.getModel();
|
|
1699
|
-
if (!model) {
|
|
1700
|
-
setLinkDialog(null);
|
|
1701
|
-
return;
|
|
1702
|
-
}
|
|
1703
|
-
if (!trimmedUrl && linkDialog.mode === 'update' && linkDialog.rawRange) {
|
|
1704
|
-
// Empty URL on update = strip the markdown link, keep the text.
|
|
1705
|
-
monacoEditor.executeEdits('toolbar-link-edit', [
|
|
1706
|
-
{ range: linkDialog.rawRange, text: trimmedText || linkDialog.initialText },
|
|
1707
|
-
]);
|
|
1708
|
-
monacoEditor.focus();
|
|
1709
|
-
setLinkDialog(null);
|
|
1710
|
-
return;
|
|
1711
|
-
}
|
|
1712
|
-
if (!trimmedUrl) {
|
|
1713
|
-
setLinkDialog(null);
|
|
1714
|
-
return;
|
|
1715
|
-
}
|
|
1716
|
-
const visibleText = trimmedText || trimmedUrl;
|
|
1717
|
-
const replacement = `[${visibleText}](${trimmedUrl})`;
|
|
1718
|
-
const range = linkDialog.rawRange ?? monacoEditor.getSelection();
|
|
1719
|
-
if (!range) {
|
|
1720
|
-
setLinkDialog(null);
|
|
1721
|
-
return;
|
|
1722
|
-
}
|
|
1723
|
-
monacoEditor.executeEdits('toolbar-link-edit', [{ range, text: replacement }]);
|
|
1724
|
-
monacoEditor.focus();
|
|
1725
|
-
setLinkDialog(null);
|
|
1726
|
-
return;
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
setLinkDialog(null);
|
|
1730
|
-
},
|
|
1731
|
-
[linkDialog, formattingEditor, monacoEditor],
|
|
1732
|
-
);
|
|
1733
|
-
|
|
1734
|
-
const groups = ['format', 'lists', 'structure', 'insert'] as const;
|
|
1735
|
-
const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
|
|
1736
|
-
const isPreview = activeView === 'preview';
|
|
1737
|
-
|
|
1738
|
-
// Formatting buttons reflect/drive the focused canvas textbox when one is
|
|
1739
|
-
// active, else the document's WYSIWYG editor.
|
|
1740
|
-
const formatActive = !!activeSceneText || isWysiwyg;
|
|
1741
|
-
// Inline-level textboxes (diagram/drawing labels) only support inline
|
|
1742
|
-
// marks — grey out block formatters so the toolbar reflects what applies.
|
|
1743
|
-
const INLINE_SCENE_BUTTONS = new Set([
|
|
1744
|
-
'bold',
|
|
1745
|
-
'italic',
|
|
1746
|
-
'strikethrough',
|
|
1747
|
-
'code',
|
|
1748
|
-
'link',
|
|
1749
|
-
'emoji',
|
|
1750
|
-
]);
|
|
1751
|
-
// `block`-level textboxes (layout) add lists/blockquote/code block on top of
|
|
1752
|
-
// the inline marks — but NOT headings (their content is a sub-block body, so
|
|
1753
|
-
// a heading there would re-parse as a sibling sub-block).
|
|
1754
|
-
const BLOCK_SCENE_BUTTONS = new Set([...INLINE_SCENE_BUTTONS, 'ul', 'ol', 'quote', 'codeblock']);
|
|
1755
|
-
const buttonAllowed = (id: string): boolean => {
|
|
1756
|
-
if (!sceneTextLevel) return true;
|
|
1757
|
-
if (sceneTextLevel === 'rich') return true;
|
|
1758
|
-
if (sceneTextLevel === 'block') return BLOCK_SCENE_BUTTONS.has(id);
|
|
1759
|
-
return INLINE_SCENE_BUTTONS.has(id);
|
|
1760
|
-
};
|
|
1761
|
-
const showConvertActions =
|
|
1762
|
-
insertMenuAnchor !== null &&
|
|
1763
|
-
readSelectedText().trim().length > 0 &&
|
|
1764
|
-
CONVERT_BUTTONS.some((button) => buttonAllowed(button.id));
|
|
1765
|
-
|
|
1766
|
-
// ── Progressive heading disclosure ───────────────────
|
|
1767
|
-
// H1\u2013H3 are always visible. H4 appears once the document already
|
|
1768
|
-
// contains an H3, H5 once it contains an H4, and H6 once it contains
|
|
1769
|
-
// an H5. This keeps the toolbar compact for typical short documents
|
|
1770
|
-
// while letting deeply nested documents reach every level.
|
|
1771
|
-
const maxHeadingLevelInDoc = useMemo(() => {
|
|
1772
|
-
if (!markdownSource) return 0;
|
|
1773
|
-
let max = 0;
|
|
1774
|
-
const lines = markdownSource.split(/\r\n|\r|\n/);
|
|
1775
|
-
const fencedLines = markdownFencedCodeLineMask(markdownSource);
|
|
1776
|
-
for (let index = 0; index < lines.length; index++) {
|
|
1777
|
-
if (fencedLines[index]) continue;
|
|
1778
|
-
const rawLine = lines[index];
|
|
1779
|
-
const line = rawLine.trimEnd();
|
|
1780
|
-
const m = /^(#{1,6})\s+\S/.exec(line);
|
|
1781
|
-
if (m && m[1].length > max) max = m[1].length;
|
|
1782
|
-
}
|
|
1783
|
-
return max;
|
|
1784
|
-
}, [markdownSource]);
|
|
1785
|
-
// Show H(n+1) when the document already contains H(n), starting from H3.
|
|
1786
|
-
const visibleHeadingMax = Math.min(6, Math.max(3, maxHeadingLevelInDoc + 1));
|
|
1787
|
-
const isButtonVisible = (id: string): boolean => {
|
|
1788
|
-
const m = /^h([1-6])$/.exec(id);
|
|
1789
|
-
if (!m) return true;
|
|
1790
|
-
return Number(m[1]) <= visibleHeadingMax;
|
|
1791
|
-
};
|
|
1792
|
-
const hasVisibleMediaButtons = MEDIA_BUTTONS.some((b) => isButtonVisible(b.id));
|
|
1793
|
-
const showInsertInOverflow =
|
|
1794
|
-
overflowIndex !== null && overflowIndex <= FIRST_MEDIA_INDEX && hasVisibleMediaButtons;
|
|
1795
|
-
|
|
1796
|
-
// Detect whether cursor is inside a table (WYSIWYG mode only)
|
|
1797
|
-
const isInTable = isWysiwyg ? tiptapEditor.isActive('table') : false;
|
|
1798
|
-
|
|
1799
|
-
// Detect current heading template (WYSIWYG mode only)
|
|
1800
|
-
const wysiwygTemplate = isWysiwyg
|
|
1801
|
-
? tiptapEditor.isActive('heading')
|
|
1802
|
-
? (tiptapEditor.getAttributes('heading')?.dataTemplate ?? '')
|
|
1803
|
-
: null
|
|
1804
|
-
: null;
|
|
1805
|
-
|
|
1806
|
-
// ── Monaco heading detection (Markdown view) ─────────────────────
|
|
1807
|
-
// Watch the Monaco cursor and surface the template picker whenever the
|
|
1808
|
-
// cursor is on a heading line. `null` hides the picker; '' shows it
|
|
1809
|
-
// with no template selected; any other string is the current template.
|
|
1810
|
-
const isRawView = activeView === 'raw';
|
|
1811
|
-
const [rawTemplate, setRawTemplate] = useState<string | null>(null);
|
|
1812
|
-
const [rawHeadingLine, setRawHeadingLine] = useState<number | null>(null);
|
|
1813
|
-
const [rawTransition, setRawTransition] = useState<TransitionFields>(EMPTY_TRANSITION);
|
|
1814
|
-
useEffect(() => {
|
|
1815
|
-
if (!isRawView || !monacoEditor) {
|
|
1816
|
-
setRawTemplate(null);
|
|
1817
|
-
setRawHeadingLine(null);
|
|
1818
|
-
setRawTransition(EMPTY_TRANSITION);
|
|
1819
|
-
return;
|
|
1820
|
-
}
|
|
1821
|
-
const recompute = () => {
|
|
1822
|
-
const model = monacoEditor.getModel();
|
|
1823
|
-
const pos = monacoEditor.getPosition();
|
|
1824
|
-
if (!model || !pos) {
|
|
1825
|
-
setRawTemplate(null);
|
|
1826
|
-
setRawHeadingLine(null);
|
|
1827
|
-
setRawTransition(EMPTY_TRANSITION);
|
|
1828
|
-
return;
|
|
1829
|
-
}
|
|
1830
|
-
if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) {
|
|
1831
|
-
setRawTemplate(null);
|
|
1832
|
-
setRawHeadingLine(null);
|
|
1833
|
-
setRawTransition(EMPTY_TRANSITION);
|
|
1834
|
-
return;
|
|
1835
|
-
}
|
|
1836
|
-
const line = model.getLineContent(pos.lineNumber);
|
|
1837
|
-
const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
|
|
1838
|
-
if (!headingMatch) {
|
|
1839
|
-
setRawTemplate(null);
|
|
1840
|
-
setRawHeadingLine(null);
|
|
1841
|
-
setRawTransition(EMPTY_TRANSITION);
|
|
1842
|
-
return;
|
|
1843
|
-
}
|
|
1844
|
-
setRawHeadingLine(pos.lineNumber);
|
|
1845
|
-
setRawTransition(readHeadingLineTransition(line));
|
|
1846
|
-
const annotMatch = matchTrailingTemplateAnnotation(headingMatch[1]);
|
|
1847
|
-
if (annotMatch) {
|
|
1848
|
-
const tokens = tokenizeAttrTokens(annotMatch.inner.trim());
|
|
1849
|
-
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
1850
|
-
setRawTemplate(firstIsParam ? '' : (tokens[0] ?? ''));
|
|
1851
|
-
} else {
|
|
1852
|
-
setRawTemplate('');
|
|
1853
|
-
}
|
|
1854
|
-
};
|
|
1855
|
-
recompute();
|
|
1856
|
-
const cursorSub = monacoEditor.onDidChangeCursorPosition(recompute);
|
|
1857
|
-
const contentSub = monacoEditor.onDidChangeModelContent(recompute);
|
|
1858
|
-
return () => {
|
|
1859
|
-
cursorSub.dispose();
|
|
1860
|
-
contentSub.dispose();
|
|
1861
|
-
};
|
|
1862
|
-
}, [isRawView, monacoEditor]);
|
|
1863
|
-
|
|
1864
|
-
// Track the index of the heading the WYSIWYG cursor is in among all
|
|
1865
|
-
// top-level headings. Used to locate the same heading in the markdown
|
|
1866
|
-
// source for content-based template recommendations.
|
|
1867
|
-
const [wysiwygHeadingIndex, setWysiwygHeadingIndex] = useState<number | null>(null);
|
|
1868
|
-
useEffect(() => {
|
|
1869
|
-
if (!isWysiwyg || !tiptapEditor) {
|
|
1870
|
-
setWysiwygHeadingIndex(null);
|
|
1871
|
-
return;
|
|
1872
|
-
}
|
|
1873
|
-
const recompute = () => {
|
|
1874
|
-
if (!tiptapEditor.isActive('heading')) {
|
|
1875
|
-
setWysiwygHeadingIndex(null);
|
|
1876
|
-
return;
|
|
1877
|
-
}
|
|
1878
|
-
const cursor = tiptapEditor.state.selection.from;
|
|
1879
|
-
let index = -1;
|
|
1880
|
-
let count = 0;
|
|
1881
|
-
tiptapEditor.state.doc.descendants((node, pos) => {
|
|
1882
|
-
if (node.type.name !== 'heading') return;
|
|
1883
|
-
if (pos <= cursor && pos + node.nodeSize > cursor) {
|
|
1884
|
-
index = count;
|
|
1885
|
-
return false;
|
|
1886
|
-
}
|
|
1887
|
-
count++;
|
|
1888
|
-
});
|
|
1889
|
-
setWysiwygHeadingIndex(index >= 0 ? index : null);
|
|
1890
|
-
};
|
|
1891
|
-
recompute();
|
|
1892
|
-
tiptapEditor.on('selectionUpdate', recompute);
|
|
1893
|
-
tiptapEditor.on('update', recompute);
|
|
1894
|
-
return () => {
|
|
1895
|
-
tiptapEditor.off('selectionUpdate', recompute);
|
|
1896
|
-
tiptapEditor.off('update', recompute);
|
|
1897
|
-
};
|
|
1898
|
-
}, [isWysiwyg, tiptapEditor]);
|
|
1899
|
-
|
|
1900
|
-
const currentTemplate = isWysiwyg ? wysiwygTemplate : isRawView ? rawTemplate : null;
|
|
1901
|
-
|
|
1902
|
-
// Compute recommended templates for the active block. Heading slice
|
|
1903
|
-
// comes from markdownSource — raw view supplies the cursor line,
|
|
1904
|
-
// WYSIWYG supplies the heading index.
|
|
1905
|
-
const recommendedTemplates = useMemo(() => {
|
|
1906
|
-
if (currentTemplate === null) return undefined;
|
|
1907
|
-
let slice = null;
|
|
1908
|
-
if (isRawView && rawHeadingLine !== null) {
|
|
1909
|
-
slice = findBlockSliceAtLine(markdownSource, rawHeadingLine);
|
|
1910
|
-
} else if (isWysiwyg && wysiwygHeadingIndex !== null) {
|
|
1911
|
-
slice = findBlockSliceByHeadingIndex(markdownSource, wysiwygHeadingIndex);
|
|
1912
|
-
}
|
|
1913
|
-
if (slice === null) return undefined;
|
|
1914
|
-
const profile = profileBlockContents(slice);
|
|
1915
|
-
return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
|
|
1916
|
-
}, [currentTemplate, isRawView, isWysiwyg, rawHeadingLine, wysiwygHeadingIndex, markdownSource]);
|
|
1917
|
-
|
|
1918
|
-
const templatePreviewBlock = useMemo(() => {
|
|
1919
|
-
if (!doc || currentTemplate === null) return null;
|
|
1920
|
-
const flat = flattenBlocks(doc.blocks);
|
|
1921
|
-
if (flat.length === 0) return null;
|
|
1922
|
-
|
|
1923
|
-
if (isRawView && rawHeadingLine !== null) {
|
|
1924
|
-
return findBlockBySourceLine(flat, rawHeadingLine);
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
if (isWysiwyg) {
|
|
1928
|
-
if (layoutMode !== 'document' && activeBlockStartLine !== null) {
|
|
1929
|
-
return findBlockBySourceLine(flat, activeBlockStartLine);
|
|
1930
|
-
}
|
|
1931
|
-
if (wysiwygHeadingIndex !== null) {
|
|
1932
|
-
return flat[wysiwygHeadingIndex] ?? null;
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
return null;
|
|
1937
|
-
}, [
|
|
1938
|
-
activeBlockStartLine,
|
|
1939
|
-
currentTemplate,
|
|
1940
|
-
doc,
|
|
1941
|
-
isRawView,
|
|
1942
|
-
isWysiwyg,
|
|
1943
|
-
layoutMode,
|
|
1944
|
-
rawHeadingLine,
|
|
1945
|
-
wysiwygHeadingIndex,
|
|
1946
|
-
]);
|
|
1947
|
-
|
|
1948
|
-
const templatePreviewSource = useMemo(
|
|
1949
|
-
() =>
|
|
1950
|
-
templatePreviewBlock
|
|
1951
|
-
? {
|
|
1952
|
-
block: templatePreviewBlock,
|
|
1953
|
-
theme: previewSettings?.activeTheme ?? DEFAULT_THEME,
|
|
1954
|
-
viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
|
|
1955
|
-
basePath: '/',
|
|
1956
|
-
mediaProvider,
|
|
1957
|
-
customTemplates: doc?.customTemplates,
|
|
1958
|
-
}
|
|
1959
|
-
: undefined,
|
|
1960
|
-
[
|
|
1961
|
-
mediaProvider,
|
|
1962
|
-
doc?.customTemplates,
|
|
1963
|
-
previewSettings?.activeTheme,
|
|
1964
|
-
previewSettings?.activeViewport,
|
|
1965
|
-
templatePreviewBlock,
|
|
1966
|
-
],
|
|
1967
|
-
);
|
|
1968
|
-
|
|
1969
|
-
const handleTemplatePick = (value: string) => {
|
|
1970
|
-
// Raw (Monaco) — rewrite the heading line's annotation suffix in place.
|
|
1971
|
-
if (isRawView && monacoEditor) {
|
|
1972
|
-
const model = monacoEditor.getModel();
|
|
1973
|
-
const pos = monacoEditor.getPosition();
|
|
1974
|
-
if (!model || !pos) return;
|
|
1975
|
-
if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) return;
|
|
1976
|
-
const lineNumber = pos.lineNumber;
|
|
1977
|
-
const lineText = model.getLineContent(lineNumber);
|
|
1978
|
-
const headingMatch = lineText.match(/^(#{1,6}\s+)(.+)$/);
|
|
1979
|
-
if (!headingMatch) return;
|
|
1980
|
-
const prefix = headingMatch[1];
|
|
1981
|
-
const { text: bareText, params } = splitTemplateAnnotationParams(headingMatch[2]);
|
|
1982
|
-
const annotation = buildTemplateAnnotation(value, params);
|
|
1983
|
-
const newLine = annotation ? `${prefix}${bareText} ${annotation}` : `${prefix}${bareText}`;
|
|
1984
|
-
monacoEditor.executeEdits('toolbar-template-pick', [
|
|
1985
|
-
{
|
|
1986
|
-
range: {
|
|
1987
|
-
startLineNumber: lineNumber,
|
|
1988
|
-
startColumn: 1,
|
|
1989
|
-
endLineNumber: lineNumber,
|
|
1990
|
-
endColumn: lineText.length + 1,
|
|
1991
|
-
},
|
|
1992
|
-
text: newLine,
|
|
1993
|
-
},
|
|
1994
|
-
]);
|
|
1995
|
-
monacoEditor.focus();
|
|
1996
|
-
return;
|
|
1997
|
-
}
|
|
1998
|
-
// WYSIWYG — update the heading node attributes.
|
|
1999
|
-
if (!tiptapEditor) return;
|
|
2000
|
-
const attrs = tiptapEditor.getAttributes('heading') as {
|
|
2001
|
-
dataTemplateParams?: string | null;
|
|
2002
|
-
};
|
|
2003
|
-
const dataTemplateParams = keepBlockMetaParamString(attrs.dataTemplateParams);
|
|
2004
|
-
if (value === '') {
|
|
2005
|
-
tiptapEditor
|
|
2006
|
-
.chain()
|
|
2007
|
-
.focus()
|
|
2008
|
-
.updateAttributes('heading', { dataTemplate: null, dataTemplateParams })
|
|
2009
|
-
.run();
|
|
2010
|
-
} else {
|
|
2011
|
-
tiptapEditor
|
|
2012
|
-
.chain()
|
|
2013
|
-
.focus()
|
|
2014
|
-
.updateAttributes('heading', { dataTemplate: value, dataTemplateParams })
|
|
2015
|
-
.run();
|
|
2016
|
-
}
|
|
2017
|
-
};
|
|
2018
|
-
|
|
2019
|
-
// ── Current block transition ──────────────────────────
|
|
2020
|
-
// Non-null exactly when a block (heading) is active — same condition as
|
|
2021
|
-
// the template picker. Read from the Pandoc `{…}` block (canonical), with
|
|
2022
|
-
// the `{[…]}` params as a fallback for hand-typed transitions.
|
|
2023
|
-
let currentTransition: TransitionFields | null = null;
|
|
2024
|
-
if (isWysiwyg && wysiwygTemplate !== null) {
|
|
2025
|
-
const attrs = tiptapEditor.getAttributes('heading') as {
|
|
2026
|
-
dataBlockAttrs?: string | null;
|
|
2027
|
-
dataTemplateParams?: string | null;
|
|
2028
|
-
};
|
|
2029
|
-
currentTransition = readBlockAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams);
|
|
2030
|
-
} else if (isRawView && rawTemplate !== null) {
|
|
2031
|
-
currentTransition = rawTransition;
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
const handleTransitionPick = (next: TransitionFields) => {
|
|
2035
|
-
// Raw (Monaco) — rewrite the heading line, writing into the `{…}` block.
|
|
2036
|
-
if (isRawView && monacoEditor) {
|
|
2037
|
-
const model = monacoEditor.getModel();
|
|
2038
|
-
const pos = monacoEditor.getPosition();
|
|
2039
|
-
if (!model || !pos) return;
|
|
2040
|
-
if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) return;
|
|
2041
|
-
const lineNumber = pos.lineNumber;
|
|
2042
|
-
const lineText = model.getLineContent(lineNumber);
|
|
2043
|
-
const newLine = setHeadingLineTransition(lineText, next);
|
|
2044
|
-
if (newLine === lineText) return;
|
|
2045
|
-
monacoEditor.executeEdits('toolbar-transition-pick', [
|
|
2046
|
-
{
|
|
2047
|
-
range: {
|
|
2048
|
-
startLineNumber: lineNumber,
|
|
2049
|
-
startColumn: 1,
|
|
2050
|
-
endLineNumber: lineNumber,
|
|
2051
|
-
endColumn: lineText.length + 1,
|
|
2052
|
-
},
|
|
2053
|
-
text: newLine,
|
|
2054
|
-
},
|
|
2055
|
-
]);
|
|
2056
|
-
monacoEditor.focus();
|
|
2057
|
-
return;
|
|
2058
|
-
}
|
|
2059
|
-
// WYSIWYG — write transitions into `{[…]}` params and migrate legacy
|
|
2060
|
-
// Pandoc transition keys out of `dataBlockAttrs`.
|
|
2061
|
-
if (!tiptapEditor) return;
|
|
2062
|
-
const attrs = tiptapEditor.getAttributes('heading') as {
|
|
2063
|
-
dataBlockAttrs?: string | null;
|
|
2064
|
-
dataTemplateParams?: string | null;
|
|
2065
|
-
};
|
|
2066
|
-
const updated = setHeadingAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams, next);
|
|
2067
|
-
tiptapEditor
|
|
2068
|
-
.chain()
|
|
2069
|
-
.focus()
|
|
2070
|
-
.updateAttributes('heading', {
|
|
2071
|
-
dataBlockAttrs: updated.blockAttrsInner,
|
|
2072
|
-
dataTemplateParams: updated.templateParams,
|
|
2073
|
-
})
|
|
2074
|
-
.run();
|
|
2075
|
-
};
|
|
2076
|
-
|
|
2077
|
-
// ── Per-block controls in the overflow menu ────────────
|
|
2078
|
-
// The template + transition pickers configure the active heading block, so
|
|
2079
|
-
// when either is pushed into the ··· menu we group them under a labeled
|
|
2080
|
-
// "<name> Block:" heading. `currentTemplate`/`currentTransition` are non-null
|
|
2081
|
-
// together (both gated on a heading being active); an empty template string
|
|
2082
|
-
// is a heading with no visual template, labeled "Heading".
|
|
2083
|
-
const showTemplateInOverflow = currentTemplate !== null && clippedContextual.has('template');
|
|
2084
|
-
const showTransitionInOverflow =
|
|
2085
|
-
currentTransition !== null && clippedContextual.has('transition');
|
|
2086
|
-
const showBlockSectionInOverflow = showTemplateInOverflow || showTransitionInOverflow;
|
|
2087
|
-
const overflowBlockLabel = currentTemplate ? templateLabel(currentTemplate) : 'Heading';
|
|
2088
|
-
const showToolbarOverflow =
|
|
2089
|
-
!findMode &&
|
|
2090
|
-
!isPreview &&
|
|
2091
|
-
!isCodeMode &&
|
|
2092
|
-
(overflowIndex !== null || clippedContextual.size > 0);
|
|
2093
|
-
|
|
2094
|
-
return (
|
|
2095
|
-
<div
|
|
2096
|
-
className={`squisq-toolbar${findMode ? ' squisq-toolbar--find' : ''} ${className || ''}`}
|
|
2097
|
-
role="toolbar"
|
|
2098
|
-
aria-label={findMode ? 'Find toolbar' : 'Formatting toolbar'}
|
|
2099
|
-
>
|
|
2100
|
-
{/* Hidden file input for image picker */}
|
|
2101
|
-
<input
|
|
2102
|
-
ref={imageInputRef}
|
|
2103
|
-
type="file"
|
|
2104
|
-
accept="image/*"
|
|
2105
|
-
style={{ display: 'none' }}
|
|
2106
|
-
onChange={(e) => {
|
|
2107
|
-
const file = e.target.files?.[0];
|
|
2108
|
-
if (file) handleImageFile(file);
|
|
2109
|
-
// Reset so the same file can be re-selected
|
|
2110
|
-
e.target.value = '';
|
|
2111
|
-
}}
|
|
2112
|
-
/>
|
|
2113
|
-
{/* Left slot — before view tabs */}
|
|
2114
|
-
{!findMode && slotLeft}
|
|
2115
|
-
{/* View tabs — hidden when only one view is available (e.g. code mode). */}
|
|
2116
|
-
{showViewTabs && (
|
|
2117
|
-
<div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
|
|
2118
|
-
{visibleViews.map((view) => {
|
|
2119
|
-
const viewLabel =
|
|
2120
|
-
view.id === 'preview' && previewSettings
|
|
2121
|
-
? displayModeLabel(previewSettings.activeDisplayMode)
|
|
2122
|
-
: view.label;
|
|
2123
|
-
const tab = (
|
|
2124
|
-
<button
|
|
2125
|
-
role="tab"
|
|
2126
|
-
data-view={view.id}
|
|
2127
|
-
aria-selected={activeView === view.id}
|
|
2128
|
-
className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
|
|
2129
|
-
onClick={() => {
|
|
2130
|
-
if (view.id === 'preview' && activeView === 'preview' && previewSettings) {
|
|
2131
|
-
requestUseModeMenu();
|
|
2132
|
-
return;
|
|
2133
|
-
}
|
|
2134
|
-
setActiveView(view.id);
|
|
2135
|
-
}}
|
|
2136
|
-
data-tooltip={`${viewLabel} (${view.shortcut})`}
|
|
2137
|
-
>
|
|
2138
|
-
<span
|
|
2139
|
-
className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--long"
|
|
2140
|
-
data-label={viewLabel}
|
|
2141
|
-
>
|
|
2142
|
-
{viewLabel}
|
|
2143
|
-
</span>
|
|
2144
|
-
{view.shortLabel && view.shortLabel !== view.label && (
|
|
2145
|
-
<span
|
|
2146
|
-
className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--short"
|
|
2147
|
-
data-label={view.shortLabel}
|
|
2148
|
-
>
|
|
2149
|
-
{view.shortLabel}
|
|
2150
|
-
</span>
|
|
2151
|
-
)}
|
|
2152
|
-
</button>
|
|
2153
|
-
);
|
|
2154
|
-
|
|
2155
|
-
if (view.id !== 'preview' || !previewSettings) {
|
|
2156
|
-
return (
|
|
2157
|
-
<div key={view.id} className="squisq-toolbar-view-tab-wrap" role="presentation">
|
|
2158
|
-
{tab}
|
|
2159
|
-
</div>
|
|
2160
|
-
);
|
|
2161
|
-
}
|
|
2162
|
-
|
|
2163
|
-
return (
|
|
2164
|
-
<div
|
|
2165
|
-
key={view.id}
|
|
2166
|
-
className={`squisq-toolbar-view-tab-wrap squisq-toolbar-use-tab${activeView === 'preview' ? ' squisq-toolbar-use-tab--active' : ''}`}
|
|
2167
|
-
role="presentation"
|
|
2168
|
-
>
|
|
2169
|
-
{tab}
|
|
2170
|
-
<PreviewModeMenu openRequest={useModeMenuRequest} />
|
|
2171
|
-
</div>
|
|
2172
|
-
);
|
|
2173
|
-
})}
|
|
2174
|
-
</div>
|
|
2175
|
-
)}
|
|
2176
|
-
{/* After-tabs slot — left side, before formatting or preview controls. */}
|
|
2177
|
-
{findMode ? <FindToolbar onClose={() => setFindMode(false)} /> : slotAfterTabs}
|
|
2178
|
-
{/* Formatting buttons — hidden in preview mode and code mode */}
|
|
2179
|
-
{!findMode && !isPreview && !isCodeMode && (
|
|
2180
|
-
<div className="squisq-toolbar-actions" ref={actionsRef}>
|
|
2181
|
-
{groups.map((group, gi) => (
|
|
2182
|
-
<div key={group} className="squisq-toolbar-group">
|
|
2183
|
-
{gi > 0 && <div className="squisq-toolbar-separator" />}
|
|
2184
|
-
{BUTTONS.filter((b) => b.group === group && isButtonVisible(b.id)).map((btn) => {
|
|
2185
|
-
const active =
|
|
2186
|
-
btn.id === 'emoji'
|
|
2187
|
-
? emojiPickerAnchor !== null
|
|
2188
|
-
: formatActive && formattingEditor
|
|
2189
|
-
? isTiptapActive(formattingEditor, btn.id)
|
|
2190
|
-
: false;
|
|
2191
|
-
const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
|
|
2192
|
-
return (
|
|
2193
|
-
<button
|
|
2194
|
-
key={btn.id}
|
|
2195
|
-
ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
|
|
2196
|
-
className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
|
|
2197
|
-
data-btn-index={BUTTON_INDEX_BY_ID.get(btn.id)}
|
|
2198
|
-
data-tooltip={disabled ? 'Insert image (requires media provider)' : btn.title}
|
|
2199
|
-
onClick={() => handleAction(btn.id)}
|
|
2200
|
-
aria-label={btn.title}
|
|
2201
|
-
aria-pressed={active}
|
|
2202
|
-
disabled={disabled}
|
|
2203
|
-
>
|
|
2204
|
-
{buttonIcon(btn)}
|
|
2205
|
-
</button>
|
|
2206
|
-
);
|
|
2207
|
-
})}
|
|
2208
|
-
</div>
|
|
2209
|
-
))}
|
|
2210
|
-
|
|
2211
|
-
{/* Insert menu button — collapses all media-group actions into a single dropdown */}
|
|
2212
|
-
<div className="squisq-toolbar-group">
|
|
2213
|
-
<div className="squisq-toolbar-separator" />
|
|
2214
|
-
<button
|
|
2215
|
-
ref={insertMenuButtonRef}
|
|
2216
|
-
className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
|
|
2217
|
-
data-btn-index={FIRST_MEDIA_INDEX}
|
|
2218
|
-
data-tooltip="Insert..."
|
|
2219
|
-
onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
|
|
2220
|
-
aria-label="Insert"
|
|
2221
|
-
aria-expanded={insertMenuAnchor !== null}
|
|
2222
|
-
aria-haspopup="menu"
|
|
2223
|
-
>
|
|
2224
|
-
<Icon icon="fa-solid fa-plus" />
|
|
2225
|
-
</button>
|
|
2226
|
-
</div>
|
|
2227
|
-
|
|
2228
|
-
{/* Template picker — visible when the cursor is in a heading.
|
|
2229
|
-
In WYSIWYG, reads from the heading node's `dataTemplate`; in
|
|
2230
|
-
Markdown view, parses the `{[...]}` suffix on the cursor's line.
|
|
2231
|
-
The `squisq-toolbar-contextual` class + data-contextual id opt
|
|
2232
|
-
this group (and the two below) into the clipping measurement:
|
|
2233
|
-
when it doesn't fit it hides here and moves into the ··· menu. */}
|
|
2234
|
-
{currentTemplate !== null && (
|
|
2235
|
-
<div
|
|
2236
|
-
className={`squisq-toolbar-group squisq-toolbar-contextual squisq-template-picker${clippedContextual.has('template') ? ' squisq-toolbar-contextual--clipped' : ''}`}
|
|
2237
|
-
data-contextual="template"
|
|
2238
|
-
>
|
|
2239
|
-
<div className="squisq-toolbar-separator" />
|
|
2240
|
-
<TemplatePicker
|
|
2241
|
-
value={currentTemplate}
|
|
2242
|
-
onChange={handleTemplatePick}
|
|
2243
|
-
colorScheme={colorScheme}
|
|
2244
|
-
recommended={recommendedTemplates}
|
|
2245
|
-
previewSource={templatePreviewSource}
|
|
2246
|
-
/>
|
|
2247
|
-
</div>
|
|
2248
|
-
)}
|
|
2249
|
-
|
|
2250
|
-
{/* Transition picker — visible alongside the template picker when a
|
|
2251
|
-
block (heading) is active. Writes `transition=` into the block's
|
|
2252
|
-
Pandoc `{…}` attribute block (the canonical home for transitions). */}
|
|
2253
|
-
{currentTransition !== null && (
|
|
2254
|
-
<div
|
|
2255
|
-
className={`squisq-toolbar-group squisq-toolbar-contextual squisq-transition-picker-group${clippedContextual.has('transition') ? ' squisq-toolbar-contextual--clipped' : ''}`}
|
|
2256
|
-
data-contextual="transition"
|
|
2257
|
-
>
|
|
2258
|
-
<div className="squisq-toolbar-separator" />
|
|
2259
|
-
<TransitionPicker
|
|
2260
|
-
value={currentTransition}
|
|
2261
|
-
onChange={handleTransitionPick}
|
|
2262
|
-
colorScheme={colorScheme}
|
|
2263
|
-
accentColor={previewSettings?.activeTheme.colors.primary}
|
|
2264
|
-
/>
|
|
2265
|
-
</div>
|
|
2266
|
-
)}
|
|
2267
|
-
|
|
2268
|
-
{/* Table controls — visible when cursor is in a table (WYSIWYG) */}
|
|
2269
|
-
{isInTable && (
|
|
2270
|
-
<div
|
|
2271
|
-
className={`squisq-toolbar-group squisq-toolbar-contextual squisq-table-controls${clippedContextual.has('table') ? ' squisq-toolbar-contextual--clipped' : ''}`}
|
|
2272
|
-
data-contextual="table"
|
|
2273
|
-
>
|
|
2274
|
-
<div className="squisq-toolbar-separator" />
|
|
2275
|
-
<span className="squisq-table-controls-label">Table:</span>
|
|
2276
|
-
<button
|
|
2277
|
-
className="squisq-toolbar-button"
|
|
2278
|
-
data-tooltip="Add column before"
|
|
2279
|
-
onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
|
|
2280
|
-
aria-label="Add column before"
|
|
2281
|
-
>
|
|
2282
|
-
<svg
|
|
2283
|
-
width="16"
|
|
2284
|
-
height="16"
|
|
2285
|
-
viewBox="0 0 16 16"
|
|
2286
|
-
fill="none"
|
|
2287
|
-
stroke="currentColor"
|
|
2288
|
-
strokeWidth="1.5"
|
|
2289
|
-
strokeLinecap="round"
|
|
2290
|
-
>
|
|
2291
|
-
<rect x="7" y="2" width="8" height="12" rx="1" />
|
|
2292
|
-
<line x1="11" y1="2" x2="11" y2="14" />
|
|
2293
|
-
<line x1="1" y1="8" x2="4.5" y2="8" />
|
|
2294
|
-
<line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
|
|
2295
|
-
</svg>
|
|
2296
|
-
</button>
|
|
2297
|
-
<button
|
|
2298
|
-
className="squisq-toolbar-button"
|
|
2299
|
-
data-tooltip="Add column after"
|
|
2300
|
-
onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
|
|
2301
|
-
aria-label="Add column after"
|
|
2302
|
-
>
|
|
2303
|
-
<svg
|
|
2304
|
-
width="16"
|
|
2305
|
-
height="16"
|
|
2306
|
-
viewBox="0 0 16 16"
|
|
2307
|
-
fill="none"
|
|
2308
|
-
stroke="currentColor"
|
|
2309
|
-
strokeWidth="1.5"
|
|
2310
|
-
strokeLinecap="round"
|
|
2311
|
-
>
|
|
2312
|
-
<rect x="1" y="2" width="8" height="12" rx="1" />
|
|
2313
|
-
<line x1="5" y1="2" x2="5" y2="14" />
|
|
2314
|
-
<line x1="11.5" y1="8" x2="15" y2="8" />
|
|
2315
|
-
<line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
|
|
2316
|
-
</svg>
|
|
2317
|
-
</button>
|
|
2318
|
-
<button
|
|
2319
|
-
className="squisq-toolbar-button"
|
|
2320
|
-
data-tooltip="Delete column"
|
|
2321
|
-
onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
|
|
2322
|
-
aria-label="Delete column"
|
|
2323
|
-
>
|
|
2324
|
-
<svg
|
|
2325
|
-
width="16"
|
|
2326
|
-
height="16"
|
|
2327
|
-
viewBox="0 0 16 16"
|
|
2328
|
-
fill="none"
|
|
2329
|
-
stroke="currentColor"
|
|
2330
|
-
strokeWidth="1.5"
|
|
2331
|
-
strokeLinecap="round"
|
|
2332
|
-
>
|
|
2333
|
-
<rect x="4" y="1" width="8" height="14" rx="1" />
|
|
2334
|
-
<line x1="6" y1="5.5" x2="10" y2="10.5" />
|
|
2335
|
-
<line x1="10" y1="5.5" x2="6" y2="10.5" />
|
|
2336
|
-
</svg>
|
|
2337
|
-
</button>
|
|
2338
|
-
<button
|
|
2339
|
-
className="squisq-toolbar-button"
|
|
2340
|
-
data-tooltip="Add row above"
|
|
2341
|
-
onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
|
|
2342
|
-
aria-label="Add row above"
|
|
2343
|
-
>
|
|
2344
|
-
<svg
|
|
2345
|
-
width="16"
|
|
2346
|
-
height="16"
|
|
2347
|
-
viewBox="0 0 16 16"
|
|
2348
|
-
fill="none"
|
|
2349
|
-
stroke="currentColor"
|
|
2350
|
-
strokeWidth="1.5"
|
|
2351
|
-
strokeLinecap="round"
|
|
2352
|
-
>
|
|
2353
|
-
<rect x="2" y="6" width="12" height="9" rx="1" />
|
|
2354
|
-
<line x1="2" y1="10.5" x2="14" y2="10.5" />
|
|
2355
|
-
<line x1="8" y1="1" x2="8" y2="4.5" />
|
|
2356
|
-
<line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
|
|
2357
|
-
</svg>
|
|
2358
|
-
</button>
|
|
2359
|
-
<button
|
|
2360
|
-
className="squisq-toolbar-button"
|
|
2361
|
-
data-tooltip="Add row below"
|
|
2362
|
-
onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
|
|
2363
|
-
aria-label="Add row below"
|
|
2364
|
-
>
|
|
2365
|
-
<svg
|
|
2366
|
-
width="16"
|
|
2367
|
-
height="16"
|
|
2368
|
-
viewBox="0 0 16 16"
|
|
2369
|
-
fill="none"
|
|
2370
|
-
stroke="currentColor"
|
|
2371
|
-
strokeWidth="1.5"
|
|
2372
|
-
strokeLinecap="round"
|
|
2373
|
-
>
|
|
2374
|
-
<rect x="2" y="1" width="12" height="9" rx="1" />
|
|
2375
|
-
<line x1="2" y1="5.5" x2="14" y2="5.5" />
|
|
2376
|
-
<line x1="8" y1="11.5" x2="8" y2="15" />
|
|
2377
|
-
<line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
|
|
2378
|
-
</svg>
|
|
2379
|
-
</button>
|
|
2380
|
-
<button
|
|
2381
|
-
className="squisq-toolbar-button"
|
|
2382
|
-
data-tooltip="Delete row"
|
|
2383
|
-
onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
|
|
2384
|
-
aria-label="Delete row"
|
|
2385
|
-
>
|
|
2386
|
-
<svg
|
|
2387
|
-
width="16"
|
|
2388
|
-
height="16"
|
|
2389
|
-
viewBox="0 0 16 16"
|
|
2390
|
-
fill="none"
|
|
2391
|
-
stroke="currentColor"
|
|
2392
|
-
strokeWidth="1.5"
|
|
2393
|
-
strokeLinecap="round"
|
|
2394
|
-
>
|
|
2395
|
-
<rect x="1" y="4" width="14" height="8" rx="1" />
|
|
2396
|
-
<line x1="5.5" y1="6" x2="10.5" y2="10" />
|
|
2397
|
-
<line x1="10.5" y1="6" x2="5.5" y2="10" />
|
|
2398
|
-
</svg>
|
|
2399
|
-
</button>
|
|
2400
|
-
<button
|
|
2401
|
-
className="squisq-toolbar-button squisq-toolbar-button--danger"
|
|
2402
|
-
data-tooltip="Delete table"
|
|
2403
|
-
onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
|
|
2404
|
-
aria-label="Delete table"
|
|
2405
|
-
>
|
|
2406
|
-
<svg
|
|
2407
|
-
width="16"
|
|
2408
|
-
height="16"
|
|
2409
|
-
viewBox="0 0 16 16"
|
|
2410
|
-
fill="none"
|
|
2411
|
-
stroke="currentColor"
|
|
2412
|
-
strokeWidth="1.5"
|
|
2413
|
-
strokeLinecap="round"
|
|
2414
|
-
>
|
|
2415
|
-
<rect x="1" y="1" width="14" height="14" rx="1" />
|
|
2416
|
-
<line x1="1" y1="5.5" x2="15" y2="5.5" />
|
|
2417
|
-
<line x1="5.5" y1="1" x2="5.5" y2="15" />
|
|
2418
|
-
<line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
|
|
2419
|
-
<line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
|
|
2420
|
-
</svg>
|
|
2421
|
-
</button>
|
|
2422
|
-
</div>
|
|
2423
|
-
)}
|
|
2424
|
-
</div>
|
|
2425
|
-
)}
|
|
2426
|
-
|
|
2427
|
-
{/* Overflow menu — outside the overflow:hidden actions container.
|
|
2428
|
-
Also appears when a contextual group (template/transition picker,
|
|
2429
|
-
table controls) is clipped, even if every plain button fits. */}
|
|
2430
|
-
{showToolbarOverflow && (
|
|
2431
|
-
<div className="squisq-toolbar-overflow" ref={overflowRef}>
|
|
2432
|
-
<button
|
|
2433
|
-
className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
|
|
2434
|
-
data-tooltip="More actions"
|
|
2435
|
-
onClick={() => setShowOverflow((v) => !v)}
|
|
2436
|
-
aria-label="More actions"
|
|
2437
|
-
aria-expanded={showOverflow}
|
|
2438
|
-
>
|
|
2439
|
-
···
|
|
2440
|
-
</button>
|
|
2441
|
-
{showOverflow && (
|
|
2442
|
-
<div
|
|
2443
|
-
className={`squisq-toolbar-overflow-menu squisq-toolbar-overflow-menu--${overflowPlacement}`}
|
|
2444
|
-
>
|
|
2445
|
-
{BUTTONS.slice(overflowIndex ?? BUTTONS.length)
|
|
2446
|
-
.filter((b) => isButtonVisible(b.id))
|
|
2447
|
-
// Media buttons are represented by the synthetic Insert dropdown
|
|
2448
|
-
// in both the visible toolbar and the overflow menu.
|
|
2449
|
-
.filter((b) => b.group !== 'media')
|
|
2450
|
-
.map((btn) => {
|
|
2451
|
-
const active =
|
|
2452
|
-
btn.id === 'emoji'
|
|
2453
|
-
? emojiPickerAnchor !== null
|
|
2454
|
-
: formatActive && formattingEditor
|
|
2455
|
-
? isTiptapActive(formattingEditor, btn.id)
|
|
2456
|
-
: false;
|
|
2457
|
-
const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
|
|
2458
|
-
return (
|
|
2459
|
-
<button
|
|
2460
|
-
key={btn.id}
|
|
2461
|
-
ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
|
|
2462
|
-
className={`squisq-toolbar-overflow-item${active ? ' squisq-toolbar-overflow-item--active' : ''}`}
|
|
2463
|
-
onClick={() => {
|
|
2464
|
-
handleAction(btn.id);
|
|
2465
|
-
// Keep the overflow open when opening the emoji
|
|
2466
|
-
// picker — otherwise its anchor (the overflow
|
|
2467
|
-
// item) unmounts and the popover loses its ref.
|
|
2468
|
-
if (btn.id !== 'emoji') setShowOverflow(false);
|
|
2469
|
-
}}
|
|
2470
|
-
disabled={disabled}
|
|
2471
|
-
>
|
|
2472
|
-
<span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
|
|
2473
|
-
<span>{btn.title}</span>
|
|
2474
|
-
</button>
|
|
2475
|
-
);
|
|
2476
|
-
})}
|
|
2477
|
-
|
|
2478
|
-
{showInsertInOverflow && (
|
|
2479
|
-
<button
|
|
2480
|
-
className={`squisq-toolbar-overflow-item${insertMenuAnchor ? ' squisq-toolbar-overflow-item--active' : ''}`}
|
|
2481
|
-
onClick={(event) => {
|
|
2482
|
-
if (insertMenuAnchor) {
|
|
2483
|
-
closeInsertMenu();
|
|
2484
|
-
} else {
|
|
2485
|
-
openInsertMenu(event.currentTarget);
|
|
2486
|
-
}
|
|
2487
|
-
setShowOverflow(false);
|
|
2488
|
-
}}
|
|
2489
|
-
aria-haspopup="menu"
|
|
2490
|
-
aria-expanded={insertMenuAnchor !== null}
|
|
2491
|
-
>
|
|
2492
|
-
<span className="squisq-toolbar-overflow-icon">
|
|
2493
|
-
<Icon icon="fa-solid fa-plus" />
|
|
2494
|
-
</span>
|
|
2495
|
-
<span>Insert...</span>
|
|
2496
|
-
</button>
|
|
2497
|
-
)}
|
|
2498
|
-
|
|
2499
|
-
{/* Per-block section — the template + transition pickers move
|
|
2500
|
-
here when clipped, under a divider and a "<name> Block:"
|
|
2501
|
-
heading that names the block they configure. */}
|
|
2502
|
-
{showBlockSectionInOverflow && (
|
|
2503
|
-
<>
|
|
2504
|
-
<div
|
|
2505
|
-
className="squisq-toolbar-separator"
|
|
2506
|
-
style={{ margin: '4px 0', width: '100%', height: 1 }}
|
|
2507
|
-
/>
|
|
2508
|
-
<div className="squisq-toolbar-overflow-heading">{overflowBlockLabel} Block:</div>
|
|
2509
|
-
</>
|
|
2510
|
-
)}
|
|
2511
|
-
|
|
2512
|
-
{/* Contextual: template picker in overflow — only when the
|
|
2513
|
-
inline one is clipped, so the control lives in exactly one
|
|
2514
|
-
visible place at a time. */}
|
|
2515
|
-
{showTemplateInOverflow && (
|
|
2516
|
-
<div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
|
|
2517
|
-
<TemplatePicker
|
|
2518
|
-
value={currentTemplate!}
|
|
2519
|
-
onChange={(v) => {
|
|
2520
|
-
handleTemplatePick(v);
|
|
2521
|
-
setShowOverflow(false);
|
|
2522
|
-
}}
|
|
2523
|
-
colorScheme={colorScheme}
|
|
2524
|
-
recommended={recommendedTemplates}
|
|
2525
|
-
previewSource={templatePreviewSource}
|
|
2526
|
-
/>
|
|
2527
|
-
</div>
|
|
2528
|
-
)}
|
|
2529
|
-
|
|
2530
|
-
{/* Contextual: transition picker in overflow — mirrors the
|
|
2531
|
-
template picker above. The menu stays open on change since
|
|
2532
|
-
the flyout carries follow-up controls (direction/duration). */}
|
|
2533
|
-
{showTransitionInOverflow && (
|
|
2534
|
-
<div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
|
|
2535
|
-
<TransitionPicker
|
|
2536
|
-
value={currentTransition!}
|
|
2537
|
-
onChange={handleTransitionPick}
|
|
2538
|
-
colorScheme={colorScheme}
|
|
2539
|
-
accentColor={previewSettings?.activeTheme.colors.primary}
|
|
2540
|
-
/>
|
|
2541
|
-
</div>
|
|
2542
|
-
)}
|
|
2543
|
-
|
|
2544
|
-
{/* Contextual: table controls in overflow */}
|
|
2545
|
-
{isInTable && clippedContextual.has('table') && (
|
|
2546
|
-
<>
|
|
2547
|
-
<div
|
|
2548
|
-
className="squisq-toolbar-separator"
|
|
2549
|
-
style={{ margin: '4px 0', width: '100%', height: 1 }}
|
|
2550
|
-
/>
|
|
2551
|
-
{[
|
|
2552
|
-
{
|
|
2553
|
-
label: 'Add column before',
|
|
2554
|
-
action: () => tiptapEditor!.chain().focus().addColumnBefore().run(),
|
|
2555
|
-
},
|
|
2556
|
-
{
|
|
2557
|
-
label: 'Add column after',
|
|
2558
|
-
action: () => tiptapEditor!.chain().focus().addColumnAfter().run(),
|
|
2559
|
-
},
|
|
2560
|
-
{
|
|
2561
|
-
label: 'Delete column',
|
|
2562
|
-
action: () => tiptapEditor!.chain().focus().deleteColumn().run(),
|
|
2563
|
-
},
|
|
2564
|
-
{
|
|
2565
|
-
label: 'Add row above',
|
|
2566
|
-
action: () => tiptapEditor!.chain().focus().addRowBefore().run(),
|
|
2567
|
-
},
|
|
2568
|
-
{
|
|
2569
|
-
label: 'Add row below',
|
|
2570
|
-
action: () => tiptapEditor!.chain().focus().addRowAfter().run(),
|
|
2571
|
-
},
|
|
2572
|
-
{
|
|
2573
|
-
label: 'Delete row',
|
|
2574
|
-
action: () => tiptapEditor!.chain().focus().deleteRow().run(),
|
|
2575
|
-
},
|
|
2576
|
-
{
|
|
2577
|
-
label: 'Delete table',
|
|
2578
|
-
action: () => tiptapEditor!.chain().focus().deleteTable().run(),
|
|
2579
|
-
},
|
|
2580
|
-
].map((item) => (
|
|
2581
|
-
<button
|
|
2582
|
-
key={item.label}
|
|
2583
|
-
className={`squisq-toolbar-overflow-item${item.label.startsWith('Delete') ? ' squisq-toolbar-overflow-item--danger' : ''}`}
|
|
2584
|
-
onClick={() => {
|
|
2585
|
-
item.action();
|
|
2586
|
-
setShowOverflow(false);
|
|
2587
|
-
}}
|
|
2588
|
-
>
|
|
2589
|
-
<span>{item.label}</span>
|
|
2590
|
-
</button>
|
|
2591
|
-
))}
|
|
2592
|
-
</>
|
|
2593
|
-
)}
|
|
2594
|
-
</div>
|
|
2595
|
-
)}
|
|
2596
|
-
</div>
|
|
2597
|
-
)}
|
|
2598
|
-
|
|
2599
|
-
{/* After-actions slot — after formatting controls */}
|
|
2600
|
-
{!findMode && slotAfterActions}
|
|
2601
|
-
{/* Spacer — pushes right-side items to the end when the flex:1 actions
|
|
2602
|
-
container isn't rendered. In preview mode PreviewToolbarControls
|
|
2603
|
-
supplies its own flex:1 filler (and measures that leftover width to
|
|
2604
|
-
decide whether to collapse), so a second spacer here would split the
|
|
2605
|
-
slack and make the controls collapse too early. */}
|
|
2606
|
-
{isCodeMode && !findMode && <div style={{ flex: 1 }} />}
|
|
2607
|
-
{/* Version history — renders only when the host enabled versioning
|
|
2608
|
-
and a container is wired up. The component owns its own button
|
|
2609
|
-
and popover; we just give it a slot in the toolbar. */}
|
|
2610
|
-
{versioning && !isCodeMode && <VersionHistoryPanel />}
|
|
2611
|
-
{/* Media recorder — surfaces when the host has a mediaProvider
|
|
2612
|
-
and hasn't opted out. RecorderEntry returns null when no
|
|
2613
|
-
provider is wired, so this stays a no-op for hosts that
|
|
2614
|
-
haven't enabled media at all. */}
|
|
2615
|
-
{allowRecording && !isCodeMode && mediaProvider && <RecorderEntry />}
|
|
2616
|
-
{!isCodeMode && (
|
|
2617
|
-
<button
|
|
2618
|
-
type="button"
|
|
2619
|
-
className="squisq-toolbar-button"
|
|
2620
|
-
onClick={() => setShowDocSettings(true)}
|
|
2621
|
-
data-tooltip="Document settings"
|
|
2622
|
-
aria-label="Document settings"
|
|
2623
|
-
>
|
|
2624
|
-
<Icon icon="fa-solid fa-file-lines" />
|
|
2625
|
-
</button>
|
|
2626
|
-
)}
|
|
2627
|
-
{!isCodeMode && (
|
|
2628
|
-
<button
|
|
2629
|
-
type="button"
|
|
2630
|
-
className={`squisq-toolbar-button${showLayoutManager ? ' squisq-toolbar-button--active' : ''}`}
|
|
2631
|
-
onClick={() => setShowLayoutManager(true)}
|
|
2632
|
-
data-tooltip="Custom layouts"
|
|
2633
|
-
aria-label="Custom layouts"
|
|
2634
|
-
>
|
|
2635
|
-
<Icon icon="fa-solid fa-shapes" />
|
|
2636
|
-
</button>
|
|
2637
|
-
)}
|
|
2638
|
-
{!isCodeMode && <ViewMenuPanel />}
|
|
2639
|
-
{/* Files toggle — visible when callback is provided */}
|
|
2640
|
-
{onToggleFiles && (
|
|
2641
|
-
<button
|
|
2642
|
-
className={`squisq-toolbar-button squisq-toolbar-files-toggle${showFiles ? ' squisq-toolbar-button--active' : ''}`}
|
|
2643
|
-
onClick={onToggleFiles}
|
|
2644
|
-
data-tooltip={`${showFiles ? 'Hide' : 'Show'} Files panel${resolvedFileCount > 0 ? ` (${fileCountLabel(resolvedFileCount)})` : ''}`}
|
|
2645
|
-
aria-pressed={showFiles}
|
|
2646
|
-
aria-label={`Toggle Files panel${resolvedFileCount > 0 ? `, ${fileCountLabel(resolvedFileCount)}` : ''}`}
|
|
2647
|
-
>
|
|
2648
|
-
<Icon icon="fa-solid fa-paperclip" />
|
|
2649
|
-
{resolvedFileCount > 0 && (
|
|
2650
|
-
<span className="squisq-toolbar-files-badge" aria-hidden="true">
|
|
2651
|
-
{fileCountBadge(resolvedFileCount)}
|
|
2652
|
-
</span>
|
|
2653
|
-
)}
|
|
2654
|
-
</button>
|
|
2655
|
-
)}
|
|
2656
|
-
{/* Right slot — rightmost end of toolbar */}
|
|
2657
|
-
{slotRight}
|
|
2658
|
-
|
|
2659
|
-
{/* Document settings (frontmatter) dialog */}
|
|
2660
|
-
{showDocSettings && (
|
|
2661
|
-
<DocumentSettingsDialog
|
|
2662
|
-
markdownSource={markdownSource}
|
|
2663
|
-
onSave={(next) => {
|
|
2664
|
-
setMarkdownSource(next);
|
|
2665
|
-
setShowDocSettings(false);
|
|
2666
|
-
}}
|
|
2667
|
-
onClose={() => setShowDocSettings(false)}
|
|
2668
|
-
/>
|
|
2669
|
-
)}
|
|
2670
|
-
|
|
2671
|
-
{/* Custom layout manager — full-window list + designer dialog. */}
|
|
2672
|
-
{showLayoutManager && <CustomLayoutManager onClose={() => setShowLayoutManager(false)} />}
|
|
2673
|
-
|
|
2674
|
-
{/* Link insert/edit dialog — shared by WYSIWYG and Raw views. */}
|
|
2675
|
-
{linkDialog && (
|
|
2676
|
-
<LinkDialog
|
|
2677
|
-
mode={linkDialog.mode}
|
|
2678
|
-
initialText={linkDialog.initialText}
|
|
2679
|
-
initialUrl={linkDialog.initialUrl}
|
|
2680
|
-
onConfirm={handleLinkConfirm}
|
|
2681
|
-
onClose={() => setLinkDialog(null)}
|
|
2682
|
-
documentLinkProvider={documentLinkProvider}
|
|
2683
|
-
/>
|
|
2684
|
-
)}
|
|
2685
|
-
|
|
2686
|
-
{/* Insert menu — portaled to the document body so the overflow:hidden
|
|
2687
|
-
actions row doesn't clip it. Position computed from trigger rect. */}
|
|
2688
|
-
{insertMenuAnchor &&
|
|
2689
|
-
createPortal(
|
|
2690
|
-
<div
|
|
2691
|
-
ref={insertMenuRef}
|
|
2692
|
-
className="squisq-insert-menu"
|
|
2693
|
-
data-theme={colorScheme}
|
|
2694
|
-
style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
|
|
2695
|
-
role="menu"
|
|
2696
|
-
>
|
|
2697
|
-
{showConvertActions && (
|
|
2698
|
-
<>
|
|
2699
|
-
<div className="squisq-insert-menu-header">Convert</div>
|
|
2700
|
-
{CONVERT_BUTTONS.map((btn) => {
|
|
2701
|
-
const label = btn.id === 'table' ? 'Table' : 'Task List';
|
|
2702
|
-
return (
|
|
2703
|
-
<button
|
|
2704
|
-
key={`convert-${btn.id}`}
|
|
2705
|
-
className="squisq-toolbar-overflow-item"
|
|
2706
|
-
disabled={!buttonAllowed(btn.id)}
|
|
2707
|
-
onClick={() => {
|
|
2708
|
-
handleConvertSelection(btn.id as 'table' | 'tasklist');
|
|
2709
|
-
closeInsertMenu();
|
|
2710
|
-
}}
|
|
2711
|
-
role="menuitem"
|
|
2712
|
-
aria-label={`Convert selection to ${label}`}
|
|
2713
|
-
>
|
|
2714
|
-
<span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
|
|
2715
|
-
<span>{label}</span>
|
|
2716
|
-
</button>
|
|
2717
|
-
);
|
|
2718
|
-
})}
|
|
2719
|
-
</>
|
|
2720
|
-
)}
|
|
2721
|
-
<div
|
|
2722
|
-
className={`squisq-insert-menu-header${showConvertActions ? ' squisq-insert-menu-header--separated' : ''}`}
|
|
2723
|
-
>
|
|
2724
|
-
Insert
|
|
2725
|
-
</div>
|
|
2726
|
-
{MEDIA_BUTTONS.filter((b) => isButtonVisible(b.id)).map((btn) => {
|
|
2727
|
-
const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
|
|
2728
|
-
const stripped = btn.title.replace(/^Insert\s+/i, '');
|
|
2729
|
-
const label = stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
|
2730
|
-
const hasMermaidTypeMenu = btn.id === 'complexdiagram';
|
|
2731
|
-
return (
|
|
2732
|
-
<button
|
|
2733
|
-
key={btn.id}
|
|
2734
|
-
ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
|
|
2735
|
-
className="squisq-toolbar-overflow-item"
|
|
2736
|
-
disabled={disabled}
|
|
2737
|
-
onClick={(event) => {
|
|
2738
|
-
if (hasMermaidTypeMenu) {
|
|
2739
|
-
if (mermaidTypeMenuAnchor) setMermaidTypeMenuAnchor(null);
|
|
2740
|
-
else openMermaidTypeMenu(event.currentTarget);
|
|
2741
|
-
return;
|
|
2742
|
-
}
|
|
2743
|
-
handleAction(btn.id);
|
|
2744
|
-
if (btn.id !== 'emoji') closeInsertMenu();
|
|
2745
|
-
}}
|
|
2746
|
-
role="menuitem"
|
|
2747
|
-
aria-haspopup={hasMermaidTypeMenu ? 'menu' : undefined}
|
|
2748
|
-
aria-expanded={hasMermaidTypeMenu ? mermaidTypeMenuAnchor !== null : undefined}
|
|
2749
|
-
>
|
|
2750
|
-
<span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
|
|
2751
|
-
<span>{label}</span>
|
|
2752
|
-
{hasMermaidTypeMenu && <Icon icon="fa-solid fa-chevron-right" />}
|
|
2753
|
-
</button>
|
|
2754
|
-
);
|
|
2755
|
-
})}
|
|
2756
|
-
<button
|
|
2757
|
-
className="squisq-toolbar-overflow-item"
|
|
2758
|
-
onClick={(event) => {
|
|
2759
|
-
if (codeSnippetMenuAnchor) setCodeSnippetMenuAnchor(null);
|
|
2760
|
-
else openCodeSnippetMenu(event.currentTarget);
|
|
2761
|
-
}}
|
|
2762
|
-
role="menuitem"
|
|
2763
|
-
aria-label="Insert Code Snippet"
|
|
2764
|
-
aria-haspopup="menu"
|
|
2765
|
-
aria-expanded={codeSnippetMenuAnchor !== null}
|
|
2766
|
-
>
|
|
2767
|
-
<span className="squisq-toolbar-overflow-icon">
|
|
2768
|
-
<Icon icon="fa-solid fa-file-code" />
|
|
2769
|
-
</span>
|
|
2770
|
-
<span>Code Snippet</span>
|
|
2771
|
-
<Icon icon="fa-solid fa-chevron-right" />
|
|
2772
|
-
</button>
|
|
2773
|
-
</div>,
|
|
2774
|
-
document.body,
|
|
2775
|
-
)}
|
|
2776
|
-
|
|
2777
|
-
{mermaidTypeMenuAnchor &&
|
|
2778
|
-
createPortal(
|
|
2779
|
-
<div
|
|
2780
|
-
ref={mermaidTypeMenuRef}
|
|
2781
|
-
className="squisq-insert-menu squisq-mermaid-type-menu"
|
|
2782
|
-
data-theme={colorScheme}
|
|
2783
|
-
style={{
|
|
2784
|
-
position: 'fixed',
|
|
2785
|
-
top: mermaidTypeMenuAnchor.top,
|
|
2786
|
-
left: mermaidTypeMenuAnchor.left,
|
|
2787
|
-
}}
|
|
2788
|
-
role="menu"
|
|
2789
|
-
aria-label="Mermaid diagram type"
|
|
2790
|
-
>
|
|
2791
|
-
<div className="squisq-mermaid-type-menu-heading">
|
|
2792
|
-
<strong>Complex Diagram</strong>
|
|
2793
|
-
<span>Choose a Mermaid diagram type. Flow direction remains a separate edit.</span>
|
|
2794
|
-
</div>
|
|
2795
|
-
{(['Structure', 'Planning', 'Data'] as const).map((category) => (
|
|
2796
|
-
<section key={category} className="squisq-mermaid-type-section">
|
|
2797
|
-
<div className="squisq-insert-menu-header">{category}</div>
|
|
2798
|
-
<div className="squisq-mermaid-type-grid">
|
|
2799
|
-
{MERMAID_DIAGRAM_TYPES.filter((entry) => entry.category === category).map(
|
|
2800
|
-
(entry) => (
|
|
2801
|
-
<button
|
|
2802
|
-
key={entry.id}
|
|
2803
|
-
type="button"
|
|
2804
|
-
className="squisq-mermaid-type-card"
|
|
2805
|
-
onClick={() => handleMermaidDiagramInsert(entry)}
|
|
2806
|
-
role="menuitem"
|
|
2807
|
-
aria-label={`Insert ${entry.label} Mermaid diagram`}
|
|
2808
|
-
>
|
|
2809
|
-
<span className="squisq-mermaid-type-thumbnail">
|
|
2810
|
-
<MermaidDiagramTypeThumbnail preview={entry.preview} />
|
|
2811
|
-
</span>
|
|
2812
|
-
<span className="squisq-mermaid-type-copy">
|
|
2813
|
-
<span className="squisq-mermaid-type-title">
|
|
2814
|
-
{entry.label}
|
|
2815
|
-
{entry.badge && (
|
|
2816
|
-
<span className="squisq-mermaid-type-badge">{entry.badge}</span>
|
|
2817
|
-
)}
|
|
2818
|
-
</span>
|
|
2819
|
-
<span className="squisq-mermaid-type-description">
|
|
2820
|
-
{entry.description}
|
|
2821
|
-
</span>
|
|
2822
|
-
</span>
|
|
2823
|
-
</button>
|
|
2824
|
-
),
|
|
2825
|
-
)}
|
|
2826
|
-
</div>
|
|
2827
|
-
</section>
|
|
2828
|
-
))}
|
|
2829
|
-
</div>,
|
|
2830
|
-
document.body,
|
|
2831
|
-
)}
|
|
2832
|
-
|
|
2833
|
-
{codeSnippetMenuAnchor &&
|
|
2834
|
-
createPortal(
|
|
2835
|
-
<div
|
|
2836
|
-
ref={codeSnippetMenuRef}
|
|
2837
|
-
className="squisq-insert-menu squisq-code-snippet-menu"
|
|
2838
|
-
data-theme={colorScheme}
|
|
2839
|
-
style={{
|
|
2840
|
-
position: 'fixed',
|
|
2841
|
-
top: codeSnippetMenuAnchor.top,
|
|
2842
|
-
left: codeSnippetMenuAnchor.left,
|
|
2843
|
-
}}
|
|
2844
|
-
role="menu"
|
|
2845
|
-
aria-label="Code snippet language"
|
|
2846
|
-
>
|
|
2847
|
-
<div className="squisq-insert-menu-header">Code Snippet</div>
|
|
2848
|
-
{CODE_SNIPPET_LANGUAGES.map((language) => (
|
|
2849
|
-
<button
|
|
2850
|
-
key={language.fenceLanguage}
|
|
2851
|
-
className="squisq-toolbar-overflow-item squisq-code-snippet-language"
|
|
2852
|
-
onClick={() => handleCodeSnippetInsert(language)}
|
|
2853
|
-
role="menuitem"
|
|
2854
|
-
aria-label={`Insert ${language.label} code snippet`}
|
|
2855
|
-
>
|
|
2856
|
-
<span>{language.label}</span>
|
|
2857
|
-
<code>{language.fenceLanguage}</code>
|
|
2858
|
-
</button>
|
|
2859
|
-
))}
|
|
2860
|
-
</div>,
|
|
2861
|
-
document.body,
|
|
2862
|
-
)}
|
|
2863
|
-
|
|
2864
|
-
{/* Emoji picker — portaled to the document body so the toolbar's
|
|
2865
|
-
overflow:hidden actions row doesn't clip the popover. Position
|
|
2866
|
-
is computed from the trigger button's screen rect at open. */}
|
|
2867
|
-
{emojiPickerAnchor &&
|
|
2868
|
-
createPortal(
|
|
2869
|
-
<EmojiPicker
|
|
2870
|
-
open
|
|
2871
|
-
onSelect={handleEmojiSelect}
|
|
2872
|
-
onClose={closeEmojiPicker}
|
|
2873
|
-
anchorRef={emojiButtonRef as React.RefObject<HTMLElement>}
|
|
2874
|
-
theme={colorScheme === 'dark' ? 'dark' : 'light'}
|
|
2875
|
-
style={{
|
|
2876
|
-
position: 'fixed',
|
|
2877
|
-
top: emojiPickerAnchor.top,
|
|
2878
|
-
left: emojiPickerAnchor.left,
|
|
2879
|
-
}}
|
|
2880
|
-
/>,
|
|
2881
|
-
document.body,
|
|
2882
|
-
)}
|
|
2883
|
-
</div>
|
|
2884
|
-
);
|
|
2885
|
-
}
|