@bendyline/squisq-editor-react 1.6.2 → 2.0.1
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/README.md +12 -3
- package/dist/index.d.ts +1275 -183
- package/dist/index.js +20345 -12777
- package/dist/index.js.map +1 -1
- package/dist/monaco.d.ts +1 -0
- package/dist/monaco.js +4 -0
- package/dist/monaco.js.map +1 -0
- package/dist/styles/index.css +1607 -241
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +30 -15
- package/src/DocumentSettingsDialog.tsx +39 -33
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +349 -261
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +107 -14
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +362 -89
- package/src/PreviewPanel.tsx +312 -78
- package/src/RawEditor.tsx +13 -2
- package/src/RecorderEntry.tsx +2 -0
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +12 -3
- package/src/TemplatePicker.tsx +75 -21
- package/src/ThemeCustomizerPanel.tsx +8 -0
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +357 -51
- package/src/TransitionPicker.tsx +36 -4
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/WysiwygEditor.tsx +124 -91
- package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
- package/src/__tests__/blockTagActivity.test.ts +183 -0
- package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
- package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
- package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
- package/src/__tests__/editorScrollSync.test.ts +65 -0
- package/src/__tests__/editorShellProps.test.tsx +123 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +0 -12
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +15 -1
- package/src/__tests__/jsonEditor.test.tsx +56 -1
- package/src/__tests__/layersPanel.test.tsx +75 -5
- package/src/__tests__/majorApiTypes.test.ts +26 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaBinDrop.test.tsx +90 -0
- package/src/__tests__/narrationSave.test.ts +148 -0
- package/src/__tests__/outlinePanel.test.tsx +93 -3
- package/src/__tests__/outlineSource.test.ts +217 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
- package/src/__tests__/presentationMode.test.tsx +290 -0
- package/src/__tests__/previewControls.test.tsx +382 -5
- package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
- package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
- package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
- package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/statusBar.test.tsx +27 -0
- package/src/__tests__/teleprompter.test.tsx +190 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +71 -7
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
- package/src/__tests__/transformStyleId.test.ts +13 -0
- package/src/__tests__/useImageEditor.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +75 -0
- package/src/__tests__/viewMenuPanel.test.tsx +130 -0
- package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
- package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
- package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
- package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
- package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
- package/src/asciiDiagram/asciiDiagramData.ts +149 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
- package/src/asciiDiagram/asciiPaste.ts +19 -0
- package/src/blockTagActivity.ts +233 -0
- package/src/buildDocumentPreviewMarkdown.ts +168 -0
- package/src/buildPreviewDoc.ts +19 -12
- package/src/codeContext/types.ts +1 -1
- package/src/customTemplates/CustomLayoutManager.tsx +3 -2
- package/src/customTemplates/CustomTemplateContext.tsx +6 -0
- package/src/customTemplates/TemplateDesigner.tsx +7 -4
- package/src/customTemplates/__tests__/library.test.ts +8 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/customThemes/CustomThemeContext.tsx +6 -0
- package/src/customThemes/CustomThemeDialog.tsx +35 -5
- package/src/customThemes/ImportThemeSection.tsx +178 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
- package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
- package/src/customThemes/customThemeLibrary.ts +7 -4
- package/src/customThemes/index.ts +7 -1
- package/src/customThemes/themeDraft.ts +23 -7
- package/src/diagram/DiagramCanvas.tsx +25 -6
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/frontmatterSettings.ts +23 -0
- package/src/headingTransition.ts +0 -15
- package/src/imageEditor/CanvasSurface.tsx +29 -23
- package/src/imageEditor/LayersPanel.tsx +78 -3
- package/src/imageEditor/Toolbar.tsx +5 -1
- package/src/imageEditor/icons.tsx +4 -0
- package/src/imageEditor/image-editor.css +75 -0
- package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
- package/src/imageEditor/useImageEditor.ts +71 -14
- package/src/index.ts +198 -20
- package/src/jsonEditor/JsonEditorContext.tsx +12 -6
- package/src/jsonEditor/RenderNode.tsx +24 -3
- package/src/jsonEditor/editors.tsx +86 -19
- package/src/mediaEntries.ts +12 -0
- package/src/monaco.ts +35 -0
- package/src/monacoWorkers.ts +89 -0
- package/src/outlineSource.ts +171 -0
- package/src/presentation/PresentationMode.tsx +596 -0
- package/src/rawEditorIsolation.ts +18 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/recorder/hooks/useMediaRecorder.ts +97 -53
- package/src/resolveBlockVisual.ts +10 -4
- package/src/scene/Scene.tsx +48 -18
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +5 -1
- package/src/scene/SceneViewport.tsx +7 -3
- package/src/scene/ShapePalette.tsx +17 -2
- package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
- package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +17 -3
- package/src/scene/index.ts +1 -6
- package/src/scene/layers/DiagramEdges.tsx +21 -7
- package/src/scene/layers/edgeGeometry.ts +9 -1
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +5 -2
- package/src/scene/text/SceneTextOverlay.tsx +5 -5
- package/src/scene/text/sceneTextChannel.ts +26 -20
- package/src/scene/text/sceneTextConfig.ts +4 -0
- package/src/scene/tools/ConnectTool.ts +13 -4
- package/src/scene/tools/SceneTool.ts +8 -0
- package/src/scene/tools/SelectTool.ts +39 -23
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +596 -0
- package/src/styles/editor.css +736 -120
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +189 -0
- package/src/teleprompter/TeleprompterControls.tsx +218 -0
- package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
- package/src/teleprompter/TeleprompterSurface.tsx +267 -0
- package/src/teleprompter/TeleprompterView.tsx +338 -0
- package/src/teleprompter/canvasRenderer.ts +161 -0
- package/src/teleprompter/floatingWindow.ts +352 -0
- package/src/teleprompter/index.ts +61 -0
- package/src/teleprompter/pcmWorklet.ts +62 -0
- package/src/teleprompter/recording/insertPreamble.ts +80 -0
- package/src/teleprompter/recording/narrationSave.ts +134 -0
- package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
- package/src/teleprompter/scrollModel.ts +85 -0
- package/src/teleprompter/teleprompterTheme.ts +303 -0
- package/src/teleprompter/types.ts +38 -0
- package/src/teleprompter/useFloatingWindow.ts +74 -0
- package/src/teleprompter/useMicAnalysis.ts +211 -0
- package/src/teleprompter/useTeleprompter.ts +421 -0
- package/src/templateContentPreviewResolver.ts +12 -5
- package/src/timeline/TimelineEditorWidget.tsx +861 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
- package/src/timeline/__tests__/timelineOps.test.ts +277 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +421 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +381 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +47 -21
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +390 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +177 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
- package/src/treeview/treeOps.ts +231 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +99 -0
- package/src/treeview/treeViewData.ts +41 -0
- package/src/useMonacoLoader.ts +11 -34
- package/src/wysiwygImageUpload.ts +113 -0
- package/src/diagram/DiagramExtension.ts +0 -209
- package/src/diagram/DiagramWidget.tsx +0 -272
- package/src/diagram/useDiagramData.ts +0 -126
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeOutlineWidget — the interactive outline editor mounted below a hidden
|
|
3
|
+
* ASCII tree fence. Reads the live tree via `useTreeViewData` and writes
|
|
4
|
+
* every structural edit back as regenerated tree art via `applyTreeCommand`
|
|
5
|
+
* (op → render → verify → single-transaction fence rewrite).
|
|
6
|
+
*
|
|
7
|
+
* Manage-items UX: a toolbar (add file / add folder), inline-editable
|
|
8
|
+
* labels, per-row controls (add child, indent/outdent, move up/down,
|
|
9
|
+
* folder⇄file, delete), and collapse chevrons (widget-local, not
|
|
10
|
+
* persisted). Enter in a label adds a sibling; Backspace on an empty label
|
|
11
|
+
* deletes it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react';
|
|
15
|
+
import type { Editor } from '@tiptap/react';
|
|
16
|
+
import type { TreeNode } from '@bendyline/squisq/doc';
|
|
17
|
+
import { Icon } from '../Icon';
|
|
18
|
+
import { useTreeViewData } from './treeViewData';
|
|
19
|
+
import { applyTreeCommand, type TreeCommand } from './treeViewCommands';
|
|
20
|
+
import type { TreeDropPosition } from './treeOps';
|
|
21
|
+
|
|
22
|
+
interface TreeOutlineWidgetProps {
|
|
23
|
+
editor: Editor;
|
|
24
|
+
blockId: string;
|
|
25
|
+
fallbackPos: number;
|
|
26
|
+
host?: HTMLElement | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TreeDropTarget {
|
|
30
|
+
id: string;
|
|
31
|
+
position: TreeDropPosition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TREE_DRAG_MIME = 'application/x-squisq-tree-node';
|
|
35
|
+
|
|
36
|
+
function findNode(nodes: readonly TreeNode[], id: string): TreeNode | null {
|
|
37
|
+
for (const node of nodes) {
|
|
38
|
+
if (node.id === id) return node;
|
|
39
|
+
const child = findNode(node.children, id);
|
|
40
|
+
if (child) return child;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nodeContains(node: TreeNode, id: string): boolean {
|
|
46
|
+
return node.id === id || node.children.some((child) => nodeContains(child, id));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function canDropNode(nodes: readonly TreeNode[], sourceId: string, targetId: string): boolean {
|
|
50
|
+
const source = findNode(nodes, sourceId);
|
|
51
|
+
return source != null && !nodeContains(source, targetId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dropPositionForPointer(event: ReactDragEvent<HTMLElement>): TreeDropPosition {
|
|
55
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
56
|
+
if (rect.height <= 0) return 'child';
|
|
57
|
+
const ratio = (event.clientY - rect.top) / rect.height;
|
|
58
|
+
if (ratio < 0.3) return 'before';
|
|
59
|
+
if (ratio > 0.7) return 'after';
|
|
60
|
+
return 'child';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function TreeOutlineWidget({ editor, blockId }: TreeOutlineWidgetProps) {
|
|
64
|
+
const view = useTreeViewData(editor, blockId);
|
|
65
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
|
|
66
|
+
const activeDragRef = useRef<string | null>(null);
|
|
67
|
+
const [draggedId, setDraggedId] = useState<string | null>(null);
|
|
68
|
+
const [dropTarget, setDropTarget] = useState<TreeDropTarget | null>(null);
|
|
69
|
+
|
|
70
|
+
const dispatch = useCallback(
|
|
71
|
+
(cmd: TreeCommand) => applyTreeCommand(editor, blockId, cmd),
|
|
72
|
+
[editor, blockId],
|
|
73
|
+
);
|
|
74
|
+
const toggleCollapse = useCallback((id: string) => {
|
|
75
|
+
setCollapsed((prev) => {
|
|
76
|
+
const next = new Set(prev);
|
|
77
|
+
if (next.has(id)) next.delete(id);
|
|
78
|
+
else next.add(id);
|
|
79
|
+
return next;
|
|
80
|
+
});
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const clearDragState = useCallback(() => {
|
|
84
|
+
activeDragRef.current = null;
|
|
85
|
+
setDraggedId(null);
|
|
86
|
+
setDropTarget(null);
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const handleDragStart = useCallback((event: ReactDragEvent<HTMLElement>, id: string) => {
|
|
90
|
+
event.stopPropagation();
|
|
91
|
+
activeDragRef.current = id;
|
|
92
|
+
setDraggedId(id);
|
|
93
|
+
setDropTarget(null);
|
|
94
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
95
|
+
// Firefox requires a text payload before it starts a native drag.
|
|
96
|
+
event.dataTransfer.setData(TREE_DRAG_MIME, id);
|
|
97
|
+
event.dataTransfer.setData('text/plain', id);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const handleDragOver = useCallback(
|
|
101
|
+
(event: ReactDragEvent<HTMLElement>, targetId: string) => {
|
|
102
|
+
const sourceId = activeDragRef.current;
|
|
103
|
+
if (!sourceId) return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
|
|
107
|
+
if (!view || !canDropNode(view.tree.roots, sourceId, targetId)) {
|
|
108
|
+
event.dataTransfer.dropEffect = 'none';
|
|
109
|
+
setDropTarget(null);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const position = dropPositionForPointer(event);
|
|
114
|
+
event.dataTransfer.dropEffect = 'move';
|
|
115
|
+
setDropTarget((current) =>
|
|
116
|
+
current?.id === targetId && current.position === position
|
|
117
|
+
? current
|
|
118
|
+
: { id: targetId, position },
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
[view],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const handleDrop = useCallback(
|
|
125
|
+
(event: ReactDragEvent<HTMLElement>, targetId: string) => {
|
|
126
|
+
const sourceId = activeDragRef.current;
|
|
127
|
+
if (!sourceId) return;
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
event.stopPropagation();
|
|
130
|
+
const position = dropPositionForPointer(event);
|
|
131
|
+
const canMove = view && canDropNode(view.tree.roots, sourceId, targetId);
|
|
132
|
+
|
|
133
|
+
clearDragState();
|
|
134
|
+
if (!canMove) return;
|
|
135
|
+
const moved = dispatch({ kind: 'moveItem', id: sourceId, targetId, position });
|
|
136
|
+
if (moved && position === 'child') {
|
|
137
|
+
// Make the result visible when a node is dropped into a collapsed row.
|
|
138
|
+
setCollapsed((current) => {
|
|
139
|
+
if (!current.has(targetId)) return current;
|
|
140
|
+
const next = new Set(current);
|
|
141
|
+
next.delete(targetId);
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[clearDragState, dispatch, view],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (!view) return null;
|
|
150
|
+
const roots = view.tree.roots;
|
|
151
|
+
const firstRootId = roots[0]?.id;
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className="squisq-tree-outline">
|
|
155
|
+
<div className="squisq-tree-toolbar">
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
className="squisq-tree-btn"
|
|
159
|
+
title="Add file"
|
|
160
|
+
disabled={!firstRootId}
|
|
161
|
+
onClick={() =>
|
|
162
|
+
firstRootId
|
|
163
|
+
? dispatch({ kind: 'addItem', targetId: firstRootId, position: 'siblingAfter' })
|
|
164
|
+
: undefined
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
<Icon icon="fa-solid fa-plus" /> Item
|
|
168
|
+
</button>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
className="squisq-tree-btn"
|
|
172
|
+
title="Add folder"
|
|
173
|
+
disabled={!firstRootId}
|
|
174
|
+
onClick={() =>
|
|
175
|
+
firstRootId
|
|
176
|
+
? dispatch({
|
|
177
|
+
kind: 'addItem',
|
|
178
|
+
targetId: firstRootId,
|
|
179
|
+
position: 'siblingAfter',
|
|
180
|
+
isDir: true,
|
|
181
|
+
})
|
|
182
|
+
: undefined
|
|
183
|
+
}
|
|
184
|
+
>
|
|
185
|
+
<Icon icon="fa-solid fa-folder-plus" /> Folder
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<ul className="squisq-tree-rows" role="tree">
|
|
190
|
+
{roots.map((node) => (
|
|
191
|
+
<TreeRowView
|
|
192
|
+
key={node.id}
|
|
193
|
+
node={node}
|
|
194
|
+
depth={0}
|
|
195
|
+
collapsed={collapsed}
|
|
196
|
+
draggedId={draggedId}
|
|
197
|
+
dropTarget={dropTarget}
|
|
198
|
+
toggleCollapse={toggleCollapse}
|
|
199
|
+
dispatch={dispatch}
|
|
200
|
+
onDragStart={handleDragStart}
|
|
201
|
+
onDragOver={handleDragOver}
|
|
202
|
+
onDrop={handleDrop}
|
|
203
|
+
onDragEnd={clearDragState}
|
|
204
|
+
/>
|
|
205
|
+
))}
|
|
206
|
+
</ul>
|
|
207
|
+
|
|
208
|
+
{view.warnings.length > 0 ? (
|
|
209
|
+
<div className="squisq-tree-warnings">
|
|
210
|
+
{view.warnings.length} parser note{view.warnings.length === 1 ? '' : 's'}
|
|
211
|
+
</div>
|
|
212
|
+
) : null}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function TreeRowView({
|
|
218
|
+
node,
|
|
219
|
+
depth,
|
|
220
|
+
collapsed,
|
|
221
|
+
draggedId,
|
|
222
|
+
dropTarget,
|
|
223
|
+
toggleCollapse,
|
|
224
|
+
dispatch,
|
|
225
|
+
onDragStart,
|
|
226
|
+
onDragOver,
|
|
227
|
+
onDrop,
|
|
228
|
+
onDragEnd,
|
|
229
|
+
}: {
|
|
230
|
+
node: TreeNode;
|
|
231
|
+
depth: number;
|
|
232
|
+
collapsed: ReadonlySet<string>;
|
|
233
|
+
draggedId: string | null;
|
|
234
|
+
dropTarget: TreeDropTarget | null;
|
|
235
|
+
toggleCollapse: (id: string) => void;
|
|
236
|
+
dispatch: (cmd: TreeCommand) => boolean;
|
|
237
|
+
onDragStart: (event: ReactDragEvent<HTMLElement>, id: string) => void;
|
|
238
|
+
onDragOver: (event: ReactDragEvent<HTMLElement>, id: string) => void;
|
|
239
|
+
onDrop: (event: ReactDragEvent<HTMLElement>, id: string) => void;
|
|
240
|
+
onDragEnd: () => void;
|
|
241
|
+
}) {
|
|
242
|
+
const hasChildren = node.children.length > 0;
|
|
243
|
+
const isCollapsed = collapsed.has(node.id);
|
|
244
|
+
const isDir = node.isDir || hasChildren;
|
|
245
|
+
const [draft, setDraft] = useState(node.label);
|
|
246
|
+
const dropPosition = dropTarget?.id === node.id ? dropTarget.position : null;
|
|
247
|
+
const itemClassName = [
|
|
248
|
+
'squisq-tree-item',
|
|
249
|
+
draggedId === node.id ? 'squisq-tree-item--dragging' : '',
|
|
250
|
+
dropPosition ? `squisq-tree-item--drop-${dropPosition}` : '',
|
|
251
|
+
]
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.join(' ');
|
|
254
|
+
|
|
255
|
+
const commit = () => {
|
|
256
|
+
if (draft !== node.label && draft.trim().length > 0) {
|
|
257
|
+
dispatch({ kind: 'renameItem', id: node.id, label: draft });
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<li className={itemClassName} role="treeitem" style={{ paddingLeft: `${depth * 18}px` }}>
|
|
263
|
+
<div
|
|
264
|
+
className="squisq-tree-row"
|
|
265
|
+
onDragOver={(event) => onDragOver(event, node.id)}
|
|
266
|
+
onDrop={(event) => onDrop(event, node.id)}
|
|
267
|
+
>
|
|
268
|
+
{hasChildren ? (
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
className="squisq-tree-chevron"
|
|
272
|
+
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
|
273
|
+
onClick={() => toggleCollapse(node.id)}
|
|
274
|
+
>
|
|
275
|
+
<Icon icon={`fa-solid ${isCollapsed ? 'fa-chevron-right' : 'fa-chevron-down'}`} />
|
|
276
|
+
</button>
|
|
277
|
+
) : (
|
|
278
|
+
<span className="squisq-tree-chevron squisq-tree-chevron--empty" />
|
|
279
|
+
)}
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
className="squisq-tree-icon"
|
|
283
|
+
title={isDir ? 'Make a file' : 'Make a folder'}
|
|
284
|
+
onClick={() => dispatch({ kind: 'toggleDir', id: node.id })}
|
|
285
|
+
>
|
|
286
|
+
<Icon icon={`fa-solid ${isDir ? 'fa-folder' : 'fa-file'}`} />
|
|
287
|
+
</button>
|
|
288
|
+
<input
|
|
289
|
+
className="squisq-tree-label"
|
|
290
|
+
value={draft}
|
|
291
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
292
|
+
onBlur={commit}
|
|
293
|
+
onKeyDown={(e) => {
|
|
294
|
+
if (e.key === 'Enter') {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
commit();
|
|
297
|
+
dispatch({ kind: 'addItem', targetId: node.id, position: 'siblingAfter' });
|
|
298
|
+
} else if (e.key === 'Backspace' && draft.length === 0) {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
dispatch({ kind: 'removeItem', id: node.id });
|
|
301
|
+
} else if (e.key === 'Tab') {
|
|
302
|
+
// Structural indent/outdent operate on the committed label's id;
|
|
303
|
+
// commit the draft first so no typing is lost.
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
commit();
|
|
306
|
+
dispatch({ kind: e.shiftKey ? 'outdentItem' : 'indentItem', id: node.id });
|
|
307
|
+
}
|
|
308
|
+
}}
|
|
309
|
+
/>
|
|
310
|
+
<span className="squisq-tree-controls">
|
|
311
|
+
<span
|
|
312
|
+
className="squisq-tree-drag-handle"
|
|
313
|
+
draggable
|
|
314
|
+
aria-hidden="true"
|
|
315
|
+
title={`Drag ${node.label} to move`}
|
|
316
|
+
onDragStart={(event) => onDragStart(event, node.id)}
|
|
317
|
+
onDragEnd={onDragEnd}
|
|
318
|
+
>
|
|
319
|
+
<Icon icon="fa-solid fa-grip-vertical" />
|
|
320
|
+
</span>
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
title="Add child"
|
|
324
|
+
onClick={() => dispatch({ kind: 'addItem', targetId: node.id, position: 'child' })}
|
|
325
|
+
>
|
|
326
|
+
<Icon icon="fa-solid fa-plus" />
|
|
327
|
+
</button>
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
title="Outdent"
|
|
331
|
+
onClick={() => dispatch({ kind: 'outdentItem', id: node.id })}
|
|
332
|
+
>
|
|
333
|
+
<Icon icon="fa-solid fa-outdent" />
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
title="Indent"
|
|
338
|
+
onClick={() => dispatch({ kind: 'indentItem', id: node.id })}
|
|
339
|
+
>
|
|
340
|
+
<Icon icon="fa-solid fa-indent" />
|
|
341
|
+
</button>
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
title="Move up"
|
|
345
|
+
onClick={() => dispatch({ kind: 'moveItemUp', id: node.id })}
|
|
346
|
+
>
|
|
347
|
+
<Icon icon="fa-solid fa-arrow-up" />
|
|
348
|
+
</button>
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
title="Move down"
|
|
352
|
+
onClick={() => dispatch({ kind: 'moveItemDown', id: node.id })}
|
|
353
|
+
>
|
|
354
|
+
<Icon icon="fa-solid fa-arrow-down" />
|
|
355
|
+
</button>
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
title="Delete"
|
|
359
|
+
className="squisq-tree-delete"
|
|
360
|
+
onClick={() => dispatch({ kind: 'removeItem', id: node.id })}
|
|
361
|
+
>
|
|
362
|
+
<Icon icon="fa-solid fa-trash" />
|
|
363
|
+
</button>
|
|
364
|
+
</span>
|
|
365
|
+
</div>
|
|
366
|
+
{hasChildren && !isCollapsed ? (
|
|
367
|
+
<ul className="squisq-tree-rows" role="group">
|
|
368
|
+
{node.children.map((child) => (
|
|
369
|
+
<TreeRowView
|
|
370
|
+
key={child.id}
|
|
371
|
+
node={child}
|
|
372
|
+
depth={depth + 1}
|
|
373
|
+
collapsed={collapsed}
|
|
374
|
+
draggedId={draggedId}
|
|
375
|
+
dropTarget={dropTarget}
|
|
376
|
+
toggleCollapse={toggleCollapse}
|
|
377
|
+
dispatch={dispatch}
|
|
378
|
+
onDragStart={onDragStart}
|
|
379
|
+
onDragOver={onDragOver}
|
|
380
|
+
onDrop={onDrop}
|
|
381
|
+
onDragEnd={onDragEnd}
|
|
382
|
+
/>
|
|
383
|
+
))}
|
|
384
|
+
</ul>
|
|
385
|
+
) : null}
|
|
386
|
+
</li>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export default TreeOutlineWidget;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeViewExtension — Tiptap/ProseMirror plugin that turns code blocks
|
|
3
|
+
* containing ASCII file-tree / outline art into interactive outline editors.
|
|
4
|
+
*
|
|
5
|
+
* Structurally identical to AsciiDiagramExtension (position registry, stable
|
|
6
|
+
* synthetic ids, hysteresis, self-rewrite-survival via the decoration key);
|
|
7
|
+
* only the detect/parse gates and class names differ. The fence text stays
|
|
8
|
+
* the source of truth — every edit re-renders the tree art.
|
|
9
|
+
*
|
|
10
|
+
* Mutual exclusion with the diagram extension: a fence with ≥2 closed boxes
|
|
11
|
+
* is a diagram, never a tree (checked in both gates), so at most one
|
|
12
|
+
* extension claims any given fence.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Extension } from '@tiptap/core';
|
|
16
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
17
|
+
import type { Transaction } from '@tiptap/pm/state';
|
|
18
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
19
|
+
import type { Node as PMNode } from '@tiptap/pm/model';
|
|
20
|
+
import type { Editor } from '@tiptap/react';
|
|
21
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
22
|
+
import { createElement } from 'react';
|
|
23
|
+
import {
|
|
24
|
+
detectTree,
|
|
25
|
+
isEligibleTreeFenceLang,
|
|
26
|
+
isExplicitTreeLang,
|
|
27
|
+
parseAsciiDiagram,
|
|
28
|
+
parseTree,
|
|
29
|
+
type Tree,
|
|
30
|
+
} from '@bendyline/squisq/doc';
|
|
31
|
+
import { TreeOutlineWidget } from './TreeOutlineWidget';
|
|
32
|
+
|
|
33
|
+
export interface TreeBlockEntry {
|
|
34
|
+
/** Synthetic session id (`tree-N`), stable across edits for one block. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** Document position of the codeBlock node at the current state. */
|
|
37
|
+
pos: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TreeViewPluginState {
|
|
41
|
+
entries: TreeBlockEntry[];
|
|
42
|
+
decorations: DecorationSet;
|
|
43
|
+
seq: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const TREEVIEW_KEY = new PluginKey<TreeViewPluginState>('squisq-treeview');
|
|
47
|
+
|
|
48
|
+
const detectCache = new WeakMap<PMNode, Tree | null>();
|
|
49
|
+
const parseCache = new WeakMap<PMNode, Tree | null>();
|
|
50
|
+
|
|
51
|
+
function fenceLangOf(node: PMNode): string | null {
|
|
52
|
+
const lang = (node.attrs as { language?: string | null }).language;
|
|
53
|
+
return typeof lang === 'string' ? lang : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function countTreeNodes(nodes: readonly Tree['roots'][number][]): number {
|
|
57
|
+
let n = 0;
|
|
58
|
+
for (const node of nodes) n += 1 + countTreeNodes(node.children);
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Full-detection gate for NEW blocks: lang allowlist + tree thresholds. */
|
|
63
|
+
export function getTreeForNode(node: PMNode): Tree | null {
|
|
64
|
+
if (node.type.name !== 'codeBlock') return null;
|
|
65
|
+
const cached = detectCache.get(node);
|
|
66
|
+
if (cached !== undefined) return cached;
|
|
67
|
+
let result: Tree | null = null;
|
|
68
|
+
const lang = fenceLangOf(node);
|
|
69
|
+
if (isEligibleTreeFenceLang(lang)) {
|
|
70
|
+
const detection = detectTree(node.textContent, { explicit: isExplicitTreeLang(lang) });
|
|
71
|
+
if (detection.isTree && detection.tree) {
|
|
72
|
+
result = detection.tree;
|
|
73
|
+
parseCache.set(node, result);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
detectCache.set(node, result);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Relaxed gate for ALREADY-REGISTERED blocks: any parse with ≥1 node and no boxes. */
|
|
81
|
+
export function parseTreeForNode(node: PMNode): Tree | null {
|
|
82
|
+
if (node.type.name !== 'codeBlock') return null;
|
|
83
|
+
const cached = parseCache.get(node);
|
|
84
|
+
if (cached !== undefined) return cached;
|
|
85
|
+
let result: Tree | null = null;
|
|
86
|
+
if (isEligibleTreeFenceLang(fenceLangOf(node))) {
|
|
87
|
+
const text = node.textContent;
|
|
88
|
+
// Box-diagram fences belong to the diagram extension, never here.
|
|
89
|
+
if (parseAsciiDiagram(text).nodes.length < 2) {
|
|
90
|
+
const parsed = parseTree(text);
|
|
91
|
+
if (countTreeNodes(parsed.roots) > 0) result = parsed;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
parseCache.set(node, result);
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function findTreeBlockPos(editor: Editor, blockId: string): number | null {
|
|
99
|
+
return TREEVIEW_KEY.getState(editor.state)?.entries.find((e) => e.id === blockId)?.pos ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface WidgetRootRef {
|
|
103
|
+
root: Root;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildDecorations(doc: PMNode, entries: TreeBlockEntry[], editor: Editor): DecorationSet {
|
|
107
|
+
const decos: Decoration[] = [];
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const node = doc.nodeAt(entry.pos);
|
|
110
|
+
if (!node || node.type.name !== 'codeBlock') continue;
|
|
111
|
+
decos.push(
|
|
112
|
+
Decoration.node(entry.pos, entry.pos + node.nodeSize, { class: 'squisq-tree-fence-hidden' }),
|
|
113
|
+
);
|
|
114
|
+
const blockId = entry.id;
|
|
115
|
+
const fallbackPos = entry.pos;
|
|
116
|
+
decos.push(
|
|
117
|
+
Decoration.widget(
|
|
118
|
+
entry.pos + node.nodeSize,
|
|
119
|
+
(view) => {
|
|
120
|
+
const container = document.createElement('div');
|
|
121
|
+
container.className = 'squisq-tree-widget-host';
|
|
122
|
+
container.contentEditable = 'false';
|
|
123
|
+
container.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
124
|
+
container.addEventListener('keydown', (e) => e.stopPropagation());
|
|
125
|
+
const root = createRoot(container);
|
|
126
|
+
root.render(
|
|
127
|
+
createElement(TreeOutlineWidget, {
|
|
128
|
+
editor,
|
|
129
|
+
blockId,
|
|
130
|
+
fallbackPos,
|
|
131
|
+
host: view.dom.parentElement ?? view.dom,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
(container as HTMLElement & { __squisqTreeRoot?: WidgetRootRef }).__squisqTreeRoot = {
|
|
135
|
+
root,
|
|
136
|
+
};
|
|
137
|
+
return container;
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
destroy: (dom) => {
|
|
141
|
+
const ref = (dom as HTMLElement & { __squisqTreeRoot?: WidgetRootRef })
|
|
142
|
+
.__squisqTreeRoot;
|
|
143
|
+
if (ref) setTimeout(() => ref.root.unmount(), 0);
|
|
144
|
+
},
|
|
145
|
+
side: 1,
|
|
146
|
+
ignoreSelection: true,
|
|
147
|
+
key: `squisq-tree-${entry.id}`,
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return DecorationSet.create(doc, decos);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function applyState(
|
|
156
|
+
tr: Transaction,
|
|
157
|
+
prev: TreeViewPluginState,
|
|
158
|
+
editor: Editor,
|
|
159
|
+
doc: PMNode,
|
|
160
|
+
): TreeViewPluginState {
|
|
161
|
+
if (!tr.docChanged) return prev;
|
|
162
|
+
|
|
163
|
+
const mapped = new Map<number, string>();
|
|
164
|
+
const claimed = new Set<string>();
|
|
165
|
+
// Pass 1: survivors whose start position wasn't touched.
|
|
166
|
+
for (const entry of prev.entries) {
|
|
167
|
+
const result = tr.mapping.mapResult(entry.pos, 1);
|
|
168
|
+
if (!result.deleted) {
|
|
169
|
+
mapped.set(result.pos, entry.id);
|
|
170
|
+
claimed.add(entry.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Pass 2: boundary-churn survivors. An attrs-only `setNodeMarkup` (the
|
|
174
|
+
// language-promotion step) rewrites the codeBlock's opening token, so
|
|
175
|
+
// `mapResult` reports the start as `deleted` even though the block is still
|
|
176
|
+
// there. Adopt the old id when a codeBlock still sits at the mapped position
|
|
177
|
+
// and that slot isn't already claimed — keeps the widget alive across the
|
|
178
|
+
// one-time promotion instead of remounting it.
|
|
179
|
+
for (const entry of prev.entries) {
|
|
180
|
+
if (claimed.has(entry.id)) continue;
|
|
181
|
+
const result = tr.mapping.mapResult(entry.pos, 1);
|
|
182
|
+
if (mapped.has(result.pos)) continue;
|
|
183
|
+
if (doc.nodeAt(result.pos)?.type.name === 'codeBlock') {
|
|
184
|
+
mapped.set(result.pos, entry.id);
|
|
185
|
+
claimed.add(entry.id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let seq = prev.seq;
|
|
190
|
+
const entries: TreeBlockEntry[] = [];
|
|
191
|
+
doc.descendants((node, pos) => {
|
|
192
|
+
if (node.type.name !== 'codeBlock') return;
|
|
193
|
+
const knownId = mapped.get(pos);
|
|
194
|
+
const tree = knownId ? parseTreeForNode(node) : getTreeForNode(node);
|
|
195
|
+
if (!tree) return false;
|
|
196
|
+
entries.push({ id: knownId ?? `tree-${++seq}`, pos });
|
|
197
|
+
return false;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return { entries, seq, decorations: buildDecorations(doc, entries, editor) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface TreeViewExtensionOptions {
|
|
204
|
+
/** When false, the extension is inert (no widgets, no decorations). */
|
|
205
|
+
enabled?: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const TreeViewExtension = Extension.create<TreeViewExtensionOptions>({
|
|
209
|
+
name: 'squisqTreeView',
|
|
210
|
+
|
|
211
|
+
addOptions() {
|
|
212
|
+
return { enabled: true };
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
addProseMirrorPlugins() {
|
|
216
|
+
const editor = this.editor as Editor;
|
|
217
|
+
if (this.options.enabled === false) return [];
|
|
218
|
+
|
|
219
|
+
return [
|
|
220
|
+
new Plugin<TreeViewPluginState>({
|
|
221
|
+
key: TREEVIEW_KEY,
|
|
222
|
+
state: {
|
|
223
|
+
init: (_config, state) => {
|
|
224
|
+
let seq = 0;
|
|
225
|
+
const entries: TreeBlockEntry[] = [];
|
|
226
|
+
state.doc.descendants((node, pos) => {
|
|
227
|
+
if (node.type.name !== 'codeBlock') return;
|
|
228
|
+
if (!getTreeForNode(node)) return false;
|
|
229
|
+
entries.push({ id: `tree-${++seq}`, pos });
|
|
230
|
+
return false;
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
entries,
|
|
234
|
+
seq,
|
|
235
|
+
decorations: buildDecorations(state.doc, entries, editor),
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
apply: (tr, prev, _oldState, newState) => applyState(tr, prev, editor, newState.doc),
|
|
239
|
+
},
|
|
240
|
+
props: {
|
|
241
|
+
decorations(state) {
|
|
242
|
+
return this.getState(state)?.decorations ?? DecorationSet.empty;
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
];
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
export default TreeViewExtension;
|