@bendyline/squisq-editor-react 1.6.2 → 2.0.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/README.md +12 -3
- package/dist/index.d.ts +1229 -179
- package/dist/index.js +18017 -11231
- 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 +1471 -245
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +30 -15
- package/src/DocumentSettingsDialog.tsx +13 -21
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +348 -260
- 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 +309 -69
- package/src/PreviewPanel.tsx +312 -78
- package/src/RawEditor.tsx +13 -2
- 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 +202 -41
- 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__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +29 -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__/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 +218 -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__/resolveBlockVisual.test.ts +43 -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 +23 -0
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -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 +224 -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 +227 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
- package/src/asciiDiagram/asciiDiagramData.ts +116 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +219 -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/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/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- 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 +9 -4
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- 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 +191 -19
- 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/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 +93 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/commands/SceneCommand.ts +4 -1
- 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 +34 -18
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +499 -0
- package/src/styles/editor.css +706 -120
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +139 -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 +702 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
- package/src/timeline/__tests__/timelineOps.test.ts +222 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +367 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +277 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +24 -16
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +240 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +125 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
- package/src/treeview/treeOps.ts +172 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +94 -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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure edits over the authored ASCII timeline model.
|
|
3
|
+
*
|
|
4
|
+
* The code fence remains the source of truth. Canvas coordinates are
|
|
5
|
+
* normalized to the same global, shared scale used by
|
|
6
|
+
* `asciiTimelineToTemplateData`; commands render the edited model back into
|
|
7
|
+
* canonical fence text after applying one of these operations.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AsciiTimeline,
|
|
12
|
+
AsciiTimelineEvent,
|
|
13
|
+
AsciiTimelineMarker,
|
|
14
|
+
AsciiTimelineSide,
|
|
15
|
+
} from '@bendyline/squisq/doc';
|
|
16
|
+
|
|
17
|
+
const STRUCTURAL_MARKERS = /[●○◉◆◇•]+/gu;
|
|
18
|
+
const LEADING_RAILS = /^[─━═╌╍┄┅┈┉-]+/u;
|
|
19
|
+
const COORDINATE_PRECISION = 1_000;
|
|
20
|
+
const COLUMN_EPSILON = 1 / COORDINATE_PRECISION / 2;
|
|
21
|
+
|
|
22
|
+
export interface TimelineEventPatch {
|
|
23
|
+
label?: string;
|
|
24
|
+
/** `null` clears the optional description. */
|
|
25
|
+
description?: string | null;
|
|
26
|
+
side?: AsciiTimelineSide;
|
|
27
|
+
/** `null` restores the label side/default placement. */
|
|
28
|
+
descriptionSide?: AsciiTimelineSide | null;
|
|
29
|
+
callout?: boolean;
|
|
30
|
+
marker?: AsciiTimelineMarker;
|
|
31
|
+
/** Normalized position on the global timeline scale. */
|
|
32
|
+
position?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AddTimelineEventOptions {
|
|
36
|
+
id?: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
side?: AsciiTimelineSide;
|
|
40
|
+
descriptionSide?: AsciiTimelineSide;
|
|
41
|
+
callout?: boolean;
|
|
42
|
+
marker?: AsciiTimelineMarker;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AddTimelineEventResult {
|
|
46
|
+
timeline: AsciiTimeline;
|
|
47
|
+
eventId: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize editable one-line prose exactly as the canonical core renderer
|
|
52
|
+
* does. This prevents the verified command path from accepting a value that
|
|
53
|
+
* is silently reinterpreted as timeline syntax on its first render.
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeTimelineText(value: string): string {
|
|
56
|
+
const normalized = value
|
|
57
|
+
.normalize('NFC')
|
|
58
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
59
|
+
.replace(STRUCTURAL_MARKERS, '·')
|
|
60
|
+
.replace(/\*/g, '∗')
|
|
61
|
+
.replace(/::/g, '∶')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim();
|
|
64
|
+
|
|
65
|
+
return normalized.replace(LEADING_RAILS, (run) =>
|
|
66
|
+
Array.from(run, (character) => (character === '-' ? '−' : '–')).join(''),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Return a globally unique, renderer-safe event id. */
|
|
71
|
+
export function nextTimelineEventId(timeline: AsciiTimeline, base = 'event'): string {
|
|
72
|
+
const used = new Set(timeline.tracks.flatMap((track) => track.events.map((event) => event.id)));
|
|
73
|
+
const safeBase = safeId(base) || 'event';
|
|
74
|
+
if (!used.has(safeBase)) return safeBase;
|
|
75
|
+
let suffix = 2;
|
|
76
|
+
while (used.has(`${safeBase}-${suffix}`)) suffix++;
|
|
77
|
+
return `${safeBase}-${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Add a visible point to `trackId` at a normalized global rail position.
|
|
82
|
+
* Returns the stable id so the canvas can select/focus the new point after
|
|
83
|
+
* the fence rewrite.
|
|
84
|
+
*/
|
|
85
|
+
export function addTimelineEventOp(
|
|
86
|
+
timeline: AsciiTimeline,
|
|
87
|
+
trackId: string,
|
|
88
|
+
position: number,
|
|
89
|
+
options: AddTimelineEventOptions = {},
|
|
90
|
+
): AddTimelineEventResult | null {
|
|
91
|
+
if (!Number.isFinite(position)) return null;
|
|
92
|
+
const track = timeline.tracks.find((candidate) => candidate.id === trackId);
|
|
93
|
+
if (!track) return null;
|
|
94
|
+
|
|
95
|
+
const column = columnAtPosition(timeline, position);
|
|
96
|
+
// Two coincident markers cannot be independently hit on the canvas. A
|
|
97
|
+
// marker click should select the existing point rather than adding atop it.
|
|
98
|
+
if (track.events.some((event) => Math.abs(event.column - column) <= COLUMN_EPSILON)) return null;
|
|
99
|
+
|
|
100
|
+
const label = sanitizeTimelineText(options.label ?? '') || 'New event';
|
|
101
|
+
const description = sanitizeTimelineText(options.description ?? '');
|
|
102
|
+
const eventId = nextTimelineEventId(timeline, options.id ?? label);
|
|
103
|
+
const insertionIndex = track.events.filter((event) => event.column < column).length;
|
|
104
|
+
const side = options.side ?? (insertionIndex % 2 === 0 ? 'above' : 'below');
|
|
105
|
+
const event: AsciiTimelineEvent = {
|
|
106
|
+
id: eventId,
|
|
107
|
+
label,
|
|
108
|
+
...(description ? { description } : {}),
|
|
109
|
+
column,
|
|
110
|
+
side,
|
|
111
|
+
...(description && options.descriptionSide ? { descriptionSide: options.descriptionSide } : {}),
|
|
112
|
+
...(options.callout === false ? { callout: false } : {}),
|
|
113
|
+
marker: options.marker ?? 'filled',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const events = [...track.events, event].sort(
|
|
117
|
+
(left, right) => left.column - right.column || left.id.localeCompare(right.id),
|
|
118
|
+
);
|
|
119
|
+
const tracks = timeline.tracks.map((candidate) =>
|
|
120
|
+
candidate.id === trackId ? { ...candidate, events } : candidate,
|
|
121
|
+
);
|
|
122
|
+
// ASCII art has no distinct diamond glyph. Promote the authored vocabulary
|
|
123
|
+
// when the inspector explicitly requests one rather than silently folding
|
|
124
|
+
// the user's choice back to a filled point in the renderer.
|
|
125
|
+
const style = options.marker === 'diamond' ? 'unicode' : timeline.style;
|
|
126
|
+
return { timeline: { ...timeline, tracks, style }, eventId };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Update one point without changing its stable id or branch endpoints. */
|
|
130
|
+
export function updateTimelineEventOp(
|
|
131
|
+
timeline: AsciiTimeline,
|
|
132
|
+
eventId: string,
|
|
133
|
+
patch: TimelineEventPatch,
|
|
134
|
+
): AsciiTimeline {
|
|
135
|
+
const location = findEvent(timeline, eventId);
|
|
136
|
+
if (!location || Object.keys(patch).length === 0) return timeline;
|
|
137
|
+
|
|
138
|
+
const hasLabel = Object.prototype.hasOwnProperty.call(patch, 'label');
|
|
139
|
+
const hasDescription = Object.prototype.hasOwnProperty.call(patch, 'description');
|
|
140
|
+
const hasDescriptionSide = Object.prototype.hasOwnProperty.call(patch, 'descriptionSide');
|
|
141
|
+
const hasPosition = Object.prototype.hasOwnProperty.call(patch, 'position');
|
|
142
|
+
const label = hasLabel ? sanitizeTimelineText(patch.label ?? '') : location.event.label;
|
|
143
|
+
if (!label) return timeline;
|
|
144
|
+
|
|
145
|
+
let description = hasDescription
|
|
146
|
+
? sanitizeTimelineText(patch.description ?? '')
|
|
147
|
+
: location.event.description;
|
|
148
|
+
if (!description) description = undefined;
|
|
149
|
+
|
|
150
|
+
let column = location.event.column;
|
|
151
|
+
if (hasPosition) {
|
|
152
|
+
if (!Number.isFinite(patch.position)) return timeline;
|
|
153
|
+
column = columnAtPosition(timeline, patch.position as number);
|
|
154
|
+
const collision = location.track.events.some(
|
|
155
|
+
(event) => event.id !== eventId && Math.abs(event.column - column) <= COLUMN_EPSILON,
|
|
156
|
+
);
|
|
157
|
+
if (collision) return timeline;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const editedText = hasLabel || hasDescription;
|
|
161
|
+
const callout =
|
|
162
|
+
patch.callout !== undefined
|
|
163
|
+
? patch.callout
|
|
164
|
+
: editedText && location.event.callout === false
|
|
165
|
+
? true
|
|
166
|
+
: location.event.callout;
|
|
167
|
+
const descriptionSide = !description
|
|
168
|
+
? undefined
|
|
169
|
+
: hasDescriptionSide
|
|
170
|
+
? (patch.descriptionSide ?? undefined)
|
|
171
|
+
: location.event.descriptionSide;
|
|
172
|
+
|
|
173
|
+
const nextEvent: AsciiTimelineEvent = {
|
|
174
|
+
...location.event,
|
|
175
|
+
label,
|
|
176
|
+
column,
|
|
177
|
+
...(description ? { description } : {}),
|
|
178
|
+
...(patch.side ? { side: patch.side } : {}),
|
|
179
|
+
...(descriptionSide ? { descriptionSide } : {}),
|
|
180
|
+
...(callout !== undefined ? { callout } : {}),
|
|
181
|
+
...(patch.marker ? { marker: patch.marker } : {}),
|
|
182
|
+
};
|
|
183
|
+
if (!description) delete nextEvent.description;
|
|
184
|
+
if (!descriptionSide) delete nextEvent.descriptionSide;
|
|
185
|
+
|
|
186
|
+
if (eventsEqual(location.event, nextEvent)) return timeline;
|
|
187
|
+
const events = location.track.events
|
|
188
|
+
.map((event) => (event.id === eventId ? nextEvent : event))
|
|
189
|
+
.sort((left, right) => left.column - right.column || left.id.localeCompare(right.id));
|
|
190
|
+
// A point authored exactly on a track boundary anchors that edge. Let the
|
|
191
|
+
// old scale remain stable throughout a drag preview, then move/reassign the
|
|
192
|
+
// boundary in the single drop transaction. Crossing the next point hands
|
|
193
|
+
// the edge to that point; moving any point outside a shorter track expands
|
|
194
|
+
// the track so every marker remains inside its authored bounds.
|
|
195
|
+
const movedPosition = hasPosition && nextEvent.column !== location.event.column;
|
|
196
|
+
const anchoredStart =
|
|
197
|
+
movedPosition && Math.abs(location.event.column - location.track.startColumn) <= COLUMN_EPSILON;
|
|
198
|
+
const anchoredEnd =
|
|
199
|
+
movedPosition && Math.abs(location.event.column - location.track.endColumn) <= COLUMN_EPSILON;
|
|
200
|
+
const eventStart = Math.min(...events.map((event) => event.column));
|
|
201
|
+
const eventEnd = Math.max(...events.map((event) => event.column));
|
|
202
|
+
const startColumn = anchoredStart ? eventStart : Math.min(location.track.startColumn, eventStart);
|
|
203
|
+
const endColumn = anchoredEnd ? eventEnd : Math.max(location.track.endColumn, eventEnd);
|
|
204
|
+
const tracks = timeline.tracks.map((track) =>
|
|
205
|
+
track.id === location.track.id ? { ...track, startColumn, endColumn, events } : track,
|
|
206
|
+
);
|
|
207
|
+
const style = patch.marker === 'diamond' ? 'unicode' : timeline.style;
|
|
208
|
+
return { ...timeline, tracks, style };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Remove a point and every incident branch. Empty tracks are removed because
|
|
213
|
+
* the canonical renderer cannot represent them. The last point in the whole
|
|
214
|
+
* timeline is retained so an edit cannot erase its own source fence/widget.
|
|
215
|
+
*/
|
|
216
|
+
export function removeTimelineEventOp(timeline: AsciiTimeline, eventId: string): AsciiTimeline {
|
|
217
|
+
const location = findEvent(timeline, eventId);
|
|
218
|
+
if (!location) return timeline;
|
|
219
|
+
const eventCount = timeline.tracks.reduce((sum, track) => sum + track.events.length, 0);
|
|
220
|
+
if (eventCount <= 1) return timeline;
|
|
221
|
+
|
|
222
|
+
const tracks = timeline.tracks
|
|
223
|
+
.map((track) =>
|
|
224
|
+
track.id === location.track.id
|
|
225
|
+
? { ...track, events: track.events.filter((event) => event.id !== eventId) }
|
|
226
|
+
: track,
|
|
227
|
+
)
|
|
228
|
+
.filter((track) => track.events.length > 0);
|
|
229
|
+
const links = timeline.links.filter((link) => link.source !== eventId && link.target !== eventId);
|
|
230
|
+
return { ...timeline, tracks, links };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function globalBounds(timeline: AsciiTimeline): { start: number; span: number } {
|
|
234
|
+
if (timeline.tracks.length === 0) return { start: 0, span: 1 };
|
|
235
|
+
const start = Math.min(...timeline.tracks.map((track) => track.startColumn));
|
|
236
|
+
const end = Math.max(...timeline.tracks.map((track) => track.endColumn));
|
|
237
|
+
return { start, span: Math.max(1, end - start) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function columnAtPosition(timeline: AsciiTimeline, position: number): number {
|
|
241
|
+
const { start, span } = globalBounds(timeline);
|
|
242
|
+
const clamped = Math.max(0, Math.min(1, position));
|
|
243
|
+
return Math.round((start + clamped * span) * COORDINATE_PRECISION) / COORDINATE_PRECISION;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function safeId(value: string): string {
|
|
247
|
+
return (
|
|
248
|
+
sanitizeTimelineText(value)
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.replace(/[^a-z0-9_.~-]+/g, '-')
|
|
251
|
+
.replace(/^-+|-+$/g, '') || 'event'
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function findEvent(
|
|
256
|
+
timeline: AsciiTimeline,
|
|
257
|
+
eventId: string,
|
|
258
|
+
): { track: AsciiTimeline['tracks'][number]; event: AsciiTimelineEvent } | null {
|
|
259
|
+
for (const track of timeline.tracks) {
|
|
260
|
+
const event = track.events.find((candidate) => candidate.id === eventId);
|
|
261
|
+
if (event) return { track, event };
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function eventsEqual(left: AsciiTimelineEvent, right: AsciiTimelineEvent): boolean {
|
|
267
|
+
return (
|
|
268
|
+
left.id === right.id &&
|
|
269
|
+
left.label === right.label &&
|
|
270
|
+
left.description === right.description &&
|
|
271
|
+
left.column === right.column &&
|
|
272
|
+
left.side === right.side &&
|
|
273
|
+
left.descriptionSide === right.descriptionSide &&
|
|
274
|
+
left.callout === right.callout &&
|
|
275
|
+
left.marker === right.marker
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Paste gate for bare, high-confidence Unicode timeline art. */
|
|
2
|
+
|
|
3
|
+
import { detectAsciiTimeline } from '@bendyline/squisq/doc';
|
|
4
|
+
|
|
5
|
+
export function shouldPasteAsTimelineFence(text: string): boolean {
|
|
6
|
+
if (!text.trim() || /^\s*```/m.test(text)) return false;
|
|
7
|
+
return detectAsciiTimeline(text).isTimeline;
|
|
8
|
+
}
|
package/src/tiptapBridge.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
* using squisq's own parser.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { templateLabel } from './TemplatePicker';
|
|
15
14
|
import { resolveIcon } from '@bendyline/squisq/icons';
|
|
16
15
|
import {
|
|
17
16
|
matchTrailingTemplateAnnotation,
|
|
@@ -251,6 +250,11 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
251
250
|
// tokens are stored raw — quotes included — so tiptapToMarkdown
|
|
252
251
|
// can re-join them into the annotation verbatim.
|
|
253
252
|
const tokens = tokenizeAttrTokens(templateInner);
|
|
253
|
+
if (tokens.length === 0) {
|
|
254
|
+
// Preserve an authored `{[]}` through the editable Tiptap document
|
|
255
|
+
// while keeping the raw marker out of the visible heading text.
|
|
256
|
+
attrs += ' data-template-empty="true"';
|
|
257
|
+
}
|
|
254
258
|
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
255
259
|
if (!firstIsParam && tokens[0]) {
|
|
256
260
|
attrs += ` data-template="${escapeHtml(tokens[0])}"`;
|
|
@@ -409,7 +413,16 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
409
413
|
if (headingMatch) {
|
|
410
414
|
const level = parseInt(headingMatch[1], 10);
|
|
411
415
|
const attrs = headingMatch[2];
|
|
412
|
-
|
|
416
|
+
const headingHtml = headingMatch[3];
|
|
417
|
+
// Template/property badges are editor chrome, not heading content. Strip
|
|
418
|
+
// them structurally before converting the inline HTML. Older builds
|
|
419
|
+
// briefly rendered the template label as real badge text; using the
|
|
420
|
+
// markup boundary preserves that cleanup without mistaking a legitimate
|
|
421
|
+
// heading suffix (for example "A Famous Quote") for the badge label.
|
|
422
|
+
const chromeStart = headingHtml.search(
|
|
423
|
+
/<span\b[^>]*\bclass="[^"]*\bsquisq-(?:template|props)-badge\b[^"]*"[^>]*>/i,
|
|
424
|
+
);
|
|
425
|
+
let text = htmlToInline(chromeStart >= 0 ? headingHtml.slice(0, chromeStart) : headingHtml);
|
|
413
426
|
|
|
414
427
|
// Re-inject heading annotations from data attributes. Canonical
|
|
415
428
|
// emit order: Pandoc `{#…}` first, then squisq `{[…]}` annotation
|
|
@@ -417,15 +430,7 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
417
430
|
const blockAttrsMatch = attrs.match(/data-block-attrs="([^"]*)"/);
|
|
418
431
|
const tmplMatch = attrs.match(/data-template="([^"]+)"/);
|
|
419
432
|
const paramsMatch = attrs.match(/data-template-params="([^"]+)"/);
|
|
420
|
-
|
|
421
|
-
// Strip an accidental trailing copy of the template label that an
|
|
422
|
-
// earlier broken build briefly rendered as real text inside the
|
|
423
|
-
// badge. Existing documents self-heal on save.
|
|
424
|
-
const label = templateLabel(tmplMatch[1]);
|
|
425
|
-
if (label && text.endsWith(label)) {
|
|
426
|
-
text = text.slice(0, -label.length).trimEnd();
|
|
427
|
-
}
|
|
428
|
-
}
|
|
433
|
+
const hasEmptyTemplateAnnotation = /\sdata-template-empty(?:="[^"]*")?/.test(attrs);
|
|
429
434
|
if (blockAttrsMatch) {
|
|
430
435
|
const inner = unescapeHtml(blockAttrsMatch[1]);
|
|
431
436
|
text += ` {${inner}}`;
|
|
@@ -436,6 +441,8 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
436
441
|
annotation += (annotation ? ' ' : '') + unescapeHtml(paramsMatch[1]);
|
|
437
442
|
}
|
|
438
443
|
text += ` {[${annotation}]}`;
|
|
444
|
+
} else if (hasEmptyTemplateAnnotation) {
|
|
445
|
+
text += ' {[]}';
|
|
439
446
|
}
|
|
440
447
|
|
|
441
448
|
lines.push('#'.repeat(level) + ' ' + text);
|
|
@@ -444,12 +451,13 @@ export function tiptapToMarkdown(html: string): string {
|
|
|
444
451
|
continue;
|
|
445
452
|
}
|
|
446
453
|
|
|
447
|
-
// Code blocks
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
454
|
+
// Code blocks — tolerate attributes on <pre> and <code>: real
|
|
455
|
+
// editor.getHTML() output carries class="squisq-code-block" on the
|
|
456
|
+
// <pre> (StarterKit codeBlock HTMLAttributes), which a bare
|
|
457
|
+
// `<pre><code` anchor never matches, silently dropping the fence.
|
|
458
|
+
const codeMatch = remaining.match(/^<pre\b[^>]*><code\b([^>]*)>(.*?)<\/code><\/pre>/s);
|
|
451
459
|
if (codeMatch) {
|
|
452
|
-
const lang = codeMatch[1]
|
|
460
|
+
const lang = /\bclass="language-([^"]*)"/.exec(codeMatch[1] ?? '')?.[1] ?? '';
|
|
453
461
|
const code = unescapeHtml(codeMatch[2]);
|
|
454
462
|
lines.push('```' + lang);
|
|
455
463
|
lines.push(code);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
|
|
2
|
+
|
|
3
|
+
const VALID_TRANSFORM_IDS = new Set(getTransformStyleSummaries().map((summary) => summary.id));
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a transform id read from document frontmatter.
|
|
7
|
+
*
|
|
8
|
+
* `dataDriven` was written by older Squisq versions. It remains accepted as a
|
|
9
|
+
* wire-format compatibility value, but every editor surface exposes and writes
|
|
10
|
+
* only the canonical `data-driven` id.
|
|
11
|
+
*/
|
|
12
|
+
export function resolvePersistedTransformStyleId(value: unknown): string | null {
|
|
13
|
+
if (typeof value !== 'string') return null;
|
|
14
|
+
const normalized = value.trim().toLowerCase().replace(/\s+/g, '-');
|
|
15
|
+
if (normalized === 'datadriven') return 'data-driven';
|
|
16
|
+
return VALID_TRANSFORM_IDS.has(normalized) ? normalized : null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
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 { useCallback, 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
|
+
|
|
21
|
+
interface TreeOutlineWidgetProps {
|
|
22
|
+
editor: Editor;
|
|
23
|
+
blockId: string;
|
|
24
|
+
fallbackPos: number;
|
|
25
|
+
host?: HTMLElement | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TreeOutlineWidget({ editor, blockId }: TreeOutlineWidgetProps) {
|
|
29
|
+
const view = useTreeViewData(editor, blockId);
|
|
30
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
|
|
31
|
+
|
|
32
|
+
const dispatch = useCallback(
|
|
33
|
+
(cmd: TreeCommand) => applyTreeCommand(editor, blockId, cmd),
|
|
34
|
+
[editor, blockId],
|
|
35
|
+
);
|
|
36
|
+
const toggleCollapse = useCallback((id: string) => {
|
|
37
|
+
setCollapsed((prev) => {
|
|
38
|
+
const next = new Set(prev);
|
|
39
|
+
if (next.has(id)) next.delete(id);
|
|
40
|
+
else next.add(id);
|
|
41
|
+
return next;
|
|
42
|
+
});
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
if (!view) return null;
|
|
46
|
+
const roots = view.tree.roots;
|
|
47
|
+
const firstRootId = roots[0]?.id;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="squisq-tree-outline">
|
|
51
|
+
<div className="squisq-tree-toolbar">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className="squisq-tree-btn"
|
|
55
|
+
title="Add file"
|
|
56
|
+
disabled={!firstRootId}
|
|
57
|
+
onClick={() =>
|
|
58
|
+
firstRootId
|
|
59
|
+
? dispatch({ kind: 'addItem', targetId: firstRootId, position: 'siblingAfter' })
|
|
60
|
+
: undefined
|
|
61
|
+
}
|
|
62
|
+
>
|
|
63
|
+
<Icon icon="fa-solid fa-plus" /> Item
|
|
64
|
+
</button>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
className="squisq-tree-btn"
|
|
68
|
+
title="Add folder"
|
|
69
|
+
disabled={!firstRootId}
|
|
70
|
+
onClick={() =>
|
|
71
|
+
firstRootId
|
|
72
|
+
? dispatch({
|
|
73
|
+
kind: 'addItem',
|
|
74
|
+
targetId: firstRootId,
|
|
75
|
+
position: 'siblingAfter',
|
|
76
|
+
isDir: true,
|
|
77
|
+
})
|
|
78
|
+
: undefined
|
|
79
|
+
}
|
|
80
|
+
>
|
|
81
|
+
<Icon icon="fa-solid fa-folder-plus" /> Folder
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<ul className="squisq-tree-rows" role="tree">
|
|
86
|
+
{roots.map((node) => (
|
|
87
|
+
<TreeRowView
|
|
88
|
+
key={node.id}
|
|
89
|
+
node={node}
|
|
90
|
+
depth={0}
|
|
91
|
+
collapsed={collapsed}
|
|
92
|
+
toggleCollapse={toggleCollapse}
|
|
93
|
+
dispatch={dispatch}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
|
|
98
|
+
{view.warnings.length > 0 ? (
|
|
99
|
+
<div className="squisq-tree-warnings">
|
|
100
|
+
{view.warnings.length} parser note{view.warnings.length === 1 ? '' : 's'}
|
|
101
|
+
</div>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function TreeRowView({
|
|
108
|
+
node,
|
|
109
|
+
depth,
|
|
110
|
+
collapsed,
|
|
111
|
+
toggleCollapse,
|
|
112
|
+
dispatch,
|
|
113
|
+
}: {
|
|
114
|
+
node: TreeNode;
|
|
115
|
+
depth: number;
|
|
116
|
+
collapsed: ReadonlySet<string>;
|
|
117
|
+
toggleCollapse: (id: string) => void;
|
|
118
|
+
dispatch: (cmd: TreeCommand) => boolean;
|
|
119
|
+
}) {
|
|
120
|
+
const hasChildren = node.children.length > 0;
|
|
121
|
+
const isCollapsed = collapsed.has(node.id);
|
|
122
|
+
const isDir = node.isDir || hasChildren;
|
|
123
|
+
const [draft, setDraft] = useState(node.label);
|
|
124
|
+
|
|
125
|
+
const commit = () => {
|
|
126
|
+
if (draft !== node.label && draft.trim().length > 0) {
|
|
127
|
+
dispatch({ kind: 'renameItem', id: node.id, label: draft });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<li role="treeitem" style={{ paddingLeft: `${depth * 18}px` }}>
|
|
133
|
+
<div className="squisq-tree-row">
|
|
134
|
+
{hasChildren ? (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="squisq-tree-chevron"
|
|
138
|
+
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
|
139
|
+
onClick={() => toggleCollapse(node.id)}
|
|
140
|
+
>
|
|
141
|
+
<Icon icon={`fa-solid ${isCollapsed ? 'fa-chevron-right' : 'fa-chevron-down'}`} />
|
|
142
|
+
</button>
|
|
143
|
+
) : (
|
|
144
|
+
<span className="squisq-tree-chevron squisq-tree-chevron--empty" />
|
|
145
|
+
)}
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
className="squisq-tree-icon"
|
|
149
|
+
title={isDir ? 'Make a file' : 'Make a folder'}
|
|
150
|
+
onClick={() => dispatch({ kind: 'toggleDir', id: node.id })}
|
|
151
|
+
>
|
|
152
|
+
<Icon icon={`fa-solid ${isDir ? 'fa-folder' : 'fa-file'}`} />
|
|
153
|
+
</button>
|
|
154
|
+
<input
|
|
155
|
+
className="squisq-tree-label"
|
|
156
|
+
value={draft}
|
|
157
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
158
|
+
onBlur={commit}
|
|
159
|
+
onKeyDown={(e) => {
|
|
160
|
+
if (e.key === 'Enter') {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
commit();
|
|
163
|
+
dispatch({ kind: 'addItem', targetId: node.id, position: 'siblingAfter' });
|
|
164
|
+
} else if (e.key === 'Backspace' && draft.length === 0) {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
dispatch({ kind: 'removeItem', id: node.id });
|
|
167
|
+
} else if (e.key === 'Tab') {
|
|
168
|
+
// Structural indent/outdent operate on the committed label's id;
|
|
169
|
+
// commit the draft first so no typing is lost.
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
commit();
|
|
172
|
+
dispatch({ kind: e.shiftKey ? 'outdentItem' : 'indentItem', id: node.id });
|
|
173
|
+
}
|
|
174
|
+
}}
|
|
175
|
+
/>
|
|
176
|
+
<span className="squisq-tree-controls">
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
title="Add child"
|
|
180
|
+
onClick={() => dispatch({ kind: 'addItem', targetId: node.id, position: 'child' })}
|
|
181
|
+
>
|
|
182
|
+
<Icon icon="fa-solid fa-plus" />
|
|
183
|
+
</button>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
title="Outdent"
|
|
187
|
+
onClick={() => dispatch({ kind: 'outdentItem', id: node.id })}
|
|
188
|
+
>
|
|
189
|
+
<Icon icon="fa-solid fa-outdent" />
|
|
190
|
+
</button>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
title="Indent"
|
|
194
|
+
onClick={() => dispatch({ kind: 'indentItem', id: node.id })}
|
|
195
|
+
>
|
|
196
|
+
<Icon icon="fa-solid fa-indent" />
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
title="Move up"
|
|
201
|
+
onClick={() => dispatch({ kind: 'moveItemUp', id: node.id })}
|
|
202
|
+
>
|
|
203
|
+
<Icon icon="fa-solid fa-arrow-up" />
|
|
204
|
+
</button>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
title="Move down"
|
|
208
|
+
onClick={() => dispatch({ kind: 'moveItemDown', id: node.id })}
|
|
209
|
+
>
|
|
210
|
+
<Icon icon="fa-solid fa-arrow-down" />
|
|
211
|
+
</button>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
title="Delete"
|
|
215
|
+
className="squisq-tree-delete"
|
|
216
|
+
onClick={() => dispatch({ kind: 'removeItem', id: node.id })}
|
|
217
|
+
>
|
|
218
|
+
<Icon icon="fa-solid fa-trash" />
|
|
219
|
+
</button>
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
{hasChildren && !isCollapsed ? (
|
|
223
|
+
<ul className="squisq-tree-rows" role="group">
|
|
224
|
+
{node.children.map((child) => (
|
|
225
|
+
<TreeRowView
|
|
226
|
+
key={child.id}
|
|
227
|
+
node={child}
|
|
228
|
+
depth={depth + 1}
|
|
229
|
+
collapsed={collapsed}
|
|
230
|
+
toggleCollapse={toggleCollapse}
|
|
231
|
+
dispatch={dispatch}
|
|
232
|
+
/>
|
|
233
|
+
))}
|
|
234
|
+
</ul>
|
|
235
|
+
) : null}
|
|
236
|
+
</li>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default TreeOutlineWidget;
|