@eventuras/scribo 0.8.2 → 0.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/App.d.ts +11 -0
- package/dist/App.d.ts.map +1 -0
- package/dist/MarkdownEditor.d.ts +18 -0
- package/dist/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor2.js +111 -0
- package/dist/MarkdownEditor2.js.map +1 -0
- package/dist/MarkdownInput.d.ts +27 -0
- package/dist/MarkdownInput.d.ts.map +1 -0
- package/dist/MarkdownInput.js +57 -0
- package/dist/MarkdownInput.js.map +1 -0
- package/dist/callout.js +5 -0
- package/dist/context/SharedHistoryContext.d.ts +11 -0
- package/dist/context/SharedHistoryContext.d.ts.map +1 -0
- package/dist/context/SharedHistoryContext.js +12 -0
- package/dist/context/SharedHistoryContext.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +4 -0
- package/dist/nodes/EditorNodes.d.ts +4 -0
- package/dist/nodes/EditorNodes.d.ts.map +1 -0
- package/dist/nodes/EditorNodes.js +39 -0
- package/dist/nodes/EditorNodes.js.map +1 -0
- package/dist/nodes/callout/CalloutComponent.d.ts +6 -0
- package/dist/nodes/callout/CalloutComponent.d.ts.map +1 -0
- package/dist/nodes/callout/CalloutComponent.js +53 -0
- package/dist/nodes/callout/CalloutComponent.js.map +1 -0
- package/dist/nodes/callout/CalloutNode.d.ts +27 -0
- package/dist/nodes/callout/CalloutNode.d.ts.map +1 -0
- package/dist/nodes/callout/CalloutNode.js +153 -0
- package/dist/nodes/callout/CalloutNode.js.map +1 -0
- package/dist/nodes/callout/CalloutTransformer.d.ts +3 -0
- package/dist/nodes/callout/CalloutTransformer.d.ts.map +1 -0
- package/dist/nodes/callout/CalloutTransformer.js +48 -0
- package/dist/nodes/callout/CalloutTransformer.js.map +1 -0
- package/dist/nodes/callout/calloutPlugin.d.ts +3 -0
- package/dist/nodes/callout/calloutPlugin.d.ts.map +1 -0
- package/dist/nodes/callout/calloutPlugin.js +34 -0
- package/dist/nodes/callout/calloutPlugin.js.map +1 -0
- package/dist/nodes/callout/index.d.ts +6 -0
- package/dist/nodes/callout/index.d.ts.map +1 -0
- package/dist/nodes/schedule/ScheduleItemComponent.d.ts +9 -0
- package/dist/nodes/schedule/ScheduleItemComponent.d.ts.map +1 -0
- package/dist/nodes/schedule/ScheduleItemComponent.js +167 -0
- package/dist/nodes/schedule/ScheduleItemComponent.js.map +1 -0
- package/dist/nodes/schedule/ScheduleItemNode.d.ts +31 -0
- package/dist/nodes/schedule/ScheduleItemNode.d.ts.map +1 -0
- package/dist/nodes/schedule/ScheduleItemNode.js +94 -0
- package/dist/nodes/schedule/ScheduleItemNode.js.map +1 -0
- package/dist/nodes/schedule/ScheduleTransformer.d.ts +22 -0
- package/dist/nodes/schedule/ScheduleTransformer.d.ts.map +1 -0
- package/dist/nodes/schedule/ScheduleTransformer.js +60 -0
- package/dist/nodes/schedule/ScheduleTransformer.js.map +1 -0
- package/dist/nodes/schedule/index.d.ts +5 -0
- package/dist/nodes/schedule/index.d.ts.map +1 -0
- package/dist/nodes/schedule/schedulePlugin.d.ts +3 -0
- package/dist/nodes/schedule/schedulePlugin.d.ts.map +1 -0
- package/dist/nodes/schedule/schedulePlugin.js +33 -0
- package/dist/nodes/schedule/schedulePlugin.js.map +1 -0
- package/dist/plugins/AutoLinkPlugin.d.ts +3 -0
- package/dist/plugins/AutoLinkPlugin.d.ts.map +1 -0
- package/dist/plugins/AutoLinkPlugin.js +22 -0
- package/dist/plugins/AutoLinkPlugin.js.map +1 -0
- package/dist/plugins/FloatingLinkEditorPlugin.d.ts +7 -0
- package/dist/plugins/FloatingLinkEditorPlugin.d.ts.map +1 -0
- package/dist/plugins/FloatingLinkEditorPlugin2.js +302 -0
- package/dist/plugins/FloatingLinkEditorPlugin2.js.map +1 -0
- package/dist/plugins/HistoryPlugin.d.ts +26 -0
- package/dist/plugins/HistoryPlugin.d.ts.map +1 -0
- package/dist/plugins/LinkPlugin.d.ts +3 -0
- package/dist/plugins/LinkPlugin.d.ts.map +1 -0
- package/dist/plugins/LinkPlugin.js +18 -0
- package/dist/plugins/LinkPlugin.js.map +1 -0
- package/dist/plugins/ToolbarPlugin.d.ts +7 -0
- package/dist/plugins/ToolbarPlugin.d.ts.map +1 -0
- package/dist/plugins/ToolbarPlugin.js +372 -0
- package/dist/plugins/ToolbarPlugin.js.map +1 -0
- package/dist/schedule.js +4 -0
- package/dist/scribo.css +1977 -0
- package/dist/themes/EditorTheme.d.ts +4 -0
- package/dist/themes/EditorTheme.d.ts.map +1 -0
- package/dist/themes/EditorTheme2.js +107 -0
- package/dist/themes/EditorTheme2.js.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/ui/ContentEditable.d.ts +6 -0
- package/dist/ui/ContentEditable.d.ts.map +1 -0
- package/dist/ui/ContentEditable2.js +14 -0
- package/dist/ui/ContentEditable2.js.map +1 -0
- package/dist/ui/DropDown.d.ts +18 -0
- package/dist/ui/DropDown.d.ts.map +1 -0
- package/dist/ui/DropDown.js +162 -0
- package/dist/ui/DropDown.js.map +1 -0
- package/dist/ui/Placeholder.d.ts +6 -0
- package/dist/ui/Placeholder.d.ts.map +1 -0
- package/dist/ui/Placeholder2.js +13 -0
- package/dist/ui/Placeholder2.js.map +1 -0
- package/dist/utils/environment.d.ts +24 -0
- package/dist/utils/environment.d.ts.map +1 -0
- package/dist/utils/environment.js +20 -0
- package/dist/utils/environment.js.map +1 -0
- package/dist/utils/getSelectedNode.d.ts +3 -0
- package/dist/utils/getSelectedNode.d.ts.map +1 -0
- package/dist/utils/getSelectedNode.js +23 -0
- package/dist/utils/getSelectedNode.js.map +1 -0
- package/dist/utils/setFloatingElemPosition.d.ts +2 -0
- package/dist/utils/setFloatingElemPosition.d.ts.map +1 -0
- package/dist/utils/setFloatingElemPositionForLinkEditor.d.ts +2 -0
- package/dist/utils/setFloatingElemPositionForLinkEditor.d.ts.map +1 -0
- package/dist/utils/setFloatingElemPositionForLinkEditor.js +33 -0
- package/dist/utils/setFloatingElemPositionForLinkEditor.js.map +1 -0
- package/dist/utils/url.d.ts +11 -0
- package/dist/utils/url.d.ts.map +1 -0
- package/dist/utils/url.js +33 -0
- package/dist/utils/url.js.map +1 -0
- package/dist/vite.svg +1 -0
- package/package.json +57 -39
- package/src/MarkdownEditor.css +130 -27
- package/src/MarkdownEditor.tsx +82 -31
- package/src/MarkdownInput.tsx +9 -10
- package/src/context/SharedHistoryContext.tsx +8 -16
- package/src/main.tsx +3 -3
- package/src/nodes/EditorNodes.ts +10 -10
- package/src/nodes/callout/Callout.css +94 -0
- package/src/nodes/callout/CalloutComponent.tsx +61 -0
- package/src/nodes/callout/CalloutNode.ts +197 -0
- package/src/nodes/callout/CalloutTransformer.ts +87 -0
- package/src/nodes/callout/calloutPlugin.ts +37 -0
- package/src/nodes/callout/index.ts +5 -0
- package/src/nodes/schedule/ScheduleItem.css +140 -0
- package/src/nodes/schedule/ScheduleItemComponent.tsx +195 -0
- package/src/nodes/schedule/ScheduleItemNode.ts +135 -0
- package/src/nodes/schedule/ScheduleTransformer.ts +80 -0
- package/src/nodes/schedule/index.ts +4 -0
- package/src/nodes/schedule/schedulePlugin.ts +33 -0
- package/src/plugins/AutoLinkPlugin.tsx +5 -10
- package/src/plugins/FloatingLinkEditorPlugin.tsx +42 -66
- package/src/plugins/HistoryPlugin.tsx +20 -30
- package/src/plugins/LinkPlugin.tsx +2 -3
- package/src/plugins/ToolbarPlugin.tsx +129 -141
- package/src/themes/EditorTheme.css +82 -62
- package/src/themes/EditorTheme.ts +89 -90
- package/src/types.ts +27 -0
- package/src/ui/ContentEditable.tsx +5 -5
- package/src/ui/DropDown.tsx +23 -41
- package/src/ui/Placeholder.tsx +21 -21
- package/src/utils/environment.ts +6 -12
- package/src/utils/getSelectedNode.ts +3 -5
- package/src/utils/setFloatingElemPosition.ts +2 -5
- package/src/utils/setFloatingElemPositionForLinkEditor.ts +1 -1
- package/src/utils/url.ts +4 -10
- package/tsconfig.json +8 -0
- package/CHANGELOG.md +0 -59
- package/README.md +0 -126
- package/RELEASE.md +0 -102
- package/eslint.config.js +0 -4
- package/src/App.css +0 -35
- package/src/App.tsx +0 -42
- package/src/index.tsx +0 -13
- package/tsconfig.node.json +0 -11
- package/vite.config.site.ts +0 -19
- package/vite.config.ts +0 -33
- /package/{src → dist}/vite-env.d.ts +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$convertFromMarkdownString,
|
|
3
|
+
type MultilineElementTransformer,
|
|
4
|
+
TRANSFORMERS,
|
|
5
|
+
} from '@lexical/markdown';
|
|
6
|
+
import { $createParagraphNode } from 'lexical';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
$createCalloutNode,
|
|
10
|
+
$isCalloutNode,
|
|
11
|
+
CALLOUT_TYPES,
|
|
12
|
+
CalloutNode,
|
|
13
|
+
type CalloutType,
|
|
14
|
+
} from './CalloutNode';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GitHub-flavored callout syntax:
|
|
18
|
+
*
|
|
19
|
+
* ```markdown
|
|
20
|
+
* > [!NOTE]
|
|
21
|
+
* > Content line 1 with **bold** and [links](url)
|
|
22
|
+
* > Content line 2
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Supports: NOTE, TIP, IMPORTANT, WARNING, CAUTION
|
|
26
|
+
*/
|
|
27
|
+
const CALLOUT_START_REGEX = /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/;
|
|
28
|
+
const BLOCKQUOTE_LINE_REGEX = /^>\s?(.*)$/;
|
|
29
|
+
|
|
30
|
+
export const CALLOUT_TRANSFORMER: MultilineElementTransformer = {
|
|
31
|
+
type: 'multiline-element',
|
|
32
|
+
dependencies: [CalloutNode],
|
|
33
|
+
|
|
34
|
+
regExpStart: CALLOUT_START_REGEX,
|
|
35
|
+
|
|
36
|
+
regExpEnd: {
|
|
37
|
+
regExp: /^(?!>\s?)/, // Ends when a line doesn't start with >
|
|
38
|
+
optional: true,
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
export: (node, exportChildren) => {
|
|
42
|
+
if (!$isCalloutNode(node)) return null;
|
|
43
|
+
|
|
44
|
+
const calloutType = node.getCalloutType();
|
|
45
|
+
const childContent = exportChildren(node);
|
|
46
|
+
const lines = childContent.split('\n');
|
|
47
|
+
const quoted = lines.map((line) => `> ${line}`).join('\n');
|
|
48
|
+
return `> [!${calloutType}]\n${quoted}`;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
replace: (parentNode, _children, startMatch, _endMatch, linesInBetween, isImport) => {
|
|
52
|
+
const calloutType = startMatch[1] as CalloutType;
|
|
53
|
+
if (!CALLOUT_TYPES.includes(calloutType)) return false;
|
|
54
|
+
|
|
55
|
+
// Strip blockquote prefixes to get plain markdown content
|
|
56
|
+
const contentLines = (linesInBetween ?? [])
|
|
57
|
+
.map((line) => {
|
|
58
|
+
const match = line.match(BLOCKQUOTE_LINE_REGEX);
|
|
59
|
+
return match ? (match[1] ?? '') : null;
|
|
60
|
+
})
|
|
61
|
+
.filter((line): line is string => line !== null);
|
|
62
|
+
|
|
63
|
+
const innerMarkdown = contentLines.join('\n').trim();
|
|
64
|
+
|
|
65
|
+
const calloutNode = $createCalloutNode(calloutType);
|
|
66
|
+
|
|
67
|
+
if (innerMarkdown) {
|
|
68
|
+
// Parse inner markdown directly into calloutNode to avoid corrupting
|
|
69
|
+
// the outer root state with a global root.clear()
|
|
70
|
+
$convertFromMarkdownString(innerMarkdown, TRANSFORMERS, calloutNode);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Ensure at least one empty paragraph for editing
|
|
74
|
+
if (calloutNode.getChildrenSize() === 0) {
|
|
75
|
+
calloutNode.append($createParagraphNode());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// On import, parentNode is the RootNode (which can't be replaced — Lexical #53).
|
|
79
|
+
// On keyboard-shortcut, parentNode is a ParagraphNode we should replace.
|
|
80
|
+
if (isImport) {
|
|
81
|
+
parentNode.append(calloutNode);
|
|
82
|
+
} else {
|
|
83
|
+
parentNode.replace(calloutNode);
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { $findMatchingParent } from '@lexical/utils';
|
|
2
|
+
import { $createParagraphNode, $getSelection, $isRangeSelection } from 'lexical';
|
|
3
|
+
|
|
4
|
+
import type { ScriboPlugin } from '../../types';
|
|
5
|
+
import { CalloutPlugin } from './CalloutComponent';
|
|
6
|
+
import { $createCalloutNode, CalloutNode } from './CalloutNode';
|
|
7
|
+
import { CALLOUT_TRANSFORMER } from './CalloutTransformer';
|
|
8
|
+
|
|
9
|
+
export const calloutPlugin: ScriboPlugin = {
|
|
10
|
+
name: 'callout',
|
|
11
|
+
nodes: [CalloutNode],
|
|
12
|
+
transformers: [CALLOUT_TRANSFORMER],
|
|
13
|
+
editorPlugins: [CalloutPlugin],
|
|
14
|
+
toolbarButtons: [
|
|
15
|
+
{
|
|
16
|
+
key: 'callout',
|
|
17
|
+
label: 'Callout',
|
|
18
|
+
iconClass: 'alert',
|
|
19
|
+
onSelect: (editor) => {
|
|
20
|
+
editor.update(() => {
|
|
21
|
+
const selection = $getSelection();
|
|
22
|
+
if (!$isRangeSelection(selection)) return;
|
|
23
|
+
|
|
24
|
+
const anchor = selection.anchor.getNode();
|
|
25
|
+
const topLevel =
|
|
26
|
+
$findMatchingParent(anchor, (n) => n.getParent()?.getType() === 'root') ?? anchor;
|
|
27
|
+
|
|
28
|
+
const calloutNode = $createCalloutNode('NOTE');
|
|
29
|
+
const p = $createParagraphNode();
|
|
30
|
+
calloutNode.append(p);
|
|
31
|
+
topLevel.insertAfter(calloutNode);
|
|
32
|
+
p.selectEnd();
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CalloutPlugin } from './CalloutComponent';
|
|
2
|
+
export type { CalloutType, SerializedCalloutNode } from './CalloutNode';
|
|
3
|
+
export { $createCalloutNode, $isCalloutNode, CALLOUT_TYPES, CalloutNode } from './CalloutNode';
|
|
4
|
+
export { CALLOUT_TRANSFORMER } from './CalloutTransformer';
|
|
5
|
+
export { calloutPlugin } from './calloutPlugin';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* Schedule item wrapper */
|
|
2
|
+
.schedule-item-wrapper + .schedule-item-wrapper {
|
|
3
|
+
margin-top: 0;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/* Main editor row */
|
|
7
|
+
.schedule-item-editor {
|
|
8
|
+
display: flex;
|
|
9
|
+
gap: 0.5rem;
|
|
10
|
+
align-items: center;
|
|
11
|
+
padding: 0.375rem 0.5rem;
|
|
12
|
+
border-radius: 0.375rem;
|
|
13
|
+
background: rgba(74, 142, 161, 0.06);
|
|
14
|
+
border: 1px solid rgba(74, 142, 161, 0.15);
|
|
15
|
+
transition:
|
|
16
|
+
border-color 0.15s,
|
|
17
|
+
opacity 0.15s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.schedule-item-editor--selected {
|
|
21
|
+
border-color: rgba(74, 142, 161, 0.4);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.schedule-item-editor--dragging {
|
|
25
|
+
opacity: 0.4;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Drag handle */
|
|
29
|
+
.schedule-item-drag {
|
|
30
|
+
cursor: grab;
|
|
31
|
+
border: none;
|
|
32
|
+
background: transparent;
|
|
33
|
+
padding: 0.125rem;
|
|
34
|
+
color: rgba(0, 0, 0, 0.25);
|
|
35
|
+
font-size: 0.875rem;
|
|
36
|
+
line-height: 1;
|
|
37
|
+
letter-spacing: -0.05em;
|
|
38
|
+
flex-shrink: 0;
|
|
39
|
+
user-select: none;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.schedule-item-drag:hover {
|
|
43
|
+
color: rgba(0, 0, 0, 0.5);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.schedule-item-drag:active {
|
|
47
|
+
cursor: grabbing;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Input fields */
|
|
51
|
+
.schedule-item-editor input {
|
|
52
|
+
border: none;
|
|
53
|
+
background: transparent;
|
|
54
|
+
font: inherit;
|
|
55
|
+
padding: 0.125rem 0.25rem;
|
|
56
|
+
border-radius: 0.25rem;
|
|
57
|
+
outline: none;
|
|
58
|
+
min-width: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.schedule-item-editor input:focus {
|
|
62
|
+
background: rgba(255, 255, 255, 0.8);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.schedule-item-time {
|
|
66
|
+
font-weight: 700;
|
|
67
|
+
font-variant-numeric: tabular-nums;
|
|
68
|
+
width: 8rem;
|
|
69
|
+
flex-shrink: 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.schedule-item-title {
|
|
73
|
+
font-weight: 600;
|
|
74
|
+
flex: 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.schedule-item-speaker {
|
|
78
|
+
flex: 1;
|
|
79
|
+
color: rgba(0, 0, 0, 0.6);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.schedule-item-description {
|
|
83
|
+
flex: 2;
|
|
84
|
+
color: rgba(0, 0, 0, 0.5);
|
|
85
|
+
font-size: 0.9em;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.schedule-item-editor input::placeholder {
|
|
89
|
+
color: rgba(0, 0, 0, 0.25);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Remove button */
|
|
93
|
+
.schedule-item-remove {
|
|
94
|
+
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
95
|
+
background: #f5f5f5;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
padding: 0.25rem 0.5rem;
|
|
98
|
+
color: #666666;
|
|
99
|
+
font-size: 1rem;
|
|
100
|
+
line-height: 1;
|
|
101
|
+
flex-shrink: 0;
|
|
102
|
+
border-radius: 0.25rem;
|
|
103
|
+
transition:
|
|
104
|
+
color 0.15s,
|
|
105
|
+
background 0.15s,
|
|
106
|
+
border-color 0.15s;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.schedule-item-remove:hover {
|
|
110
|
+
color: #b91c1c;
|
|
111
|
+
background: #f8e8e8;
|
|
112
|
+
border-color: rgba(185, 28, 28, 0.3);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Add button — only on last item */
|
|
116
|
+
.schedule-item-add {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
width: 100%;
|
|
121
|
+
margin-top: 0.375rem;
|
|
122
|
+
padding: 0.375rem;
|
|
123
|
+
border: 1px dashed rgba(74, 142, 161, 0.35);
|
|
124
|
+
border-radius: 0.375rem;
|
|
125
|
+
background: #f4f8f9;
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
color: #3a7287;
|
|
128
|
+
font-size: 1rem;
|
|
129
|
+
font-weight: 600;
|
|
130
|
+
transition:
|
|
131
|
+
border-color 0.15s,
|
|
132
|
+
color 0.15s,
|
|
133
|
+
background 0.15s;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.schedule-item-add:hover {
|
|
137
|
+
border-color: rgba(74, 142, 161, 0.6);
|
|
138
|
+
color: #2f5f6d;
|
|
139
|
+
background: #e9f1f4;
|
|
140
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import './ScheduleItem.css';
|
|
2
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
3
|
+
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
|
4
|
+
import { $createParagraphNode, $getNodeByKey, type NodeKey } from 'lexical';
|
|
5
|
+
import { type DragEvent, useCallback, useEffect, useRef, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
$createScheduleItemNode,
|
|
9
|
+
$isScheduleItemNode,
|
|
10
|
+
type ScheduleItemData,
|
|
11
|
+
} from './ScheduleItemNode';
|
|
12
|
+
|
|
13
|
+
interface ScheduleItemComponentProps {
|
|
14
|
+
nodeKey: NodeKey;
|
|
15
|
+
data: ScheduleItemData;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ScheduleItemComponent = ({ nodeKey, data }: ScheduleItemComponentProps) => {
|
|
19
|
+
const [editor] = useLexicalComposerContext();
|
|
20
|
+
const [isSelected, _setSelected, _clearSelection] = useLexicalNodeSelection(nodeKey);
|
|
21
|
+
const [localData, setLocalData] = useState(data);
|
|
22
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
23
|
+
const [isLast, setIsLast] = useState(false);
|
|
24
|
+
const timeRef = useRef<HTMLInputElement>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
setLocalData(data);
|
|
28
|
+
}, [data]);
|
|
29
|
+
|
|
30
|
+
// Check if this is the last schedule item (show + button only on last)
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
return editor.registerUpdateListener(() => {
|
|
33
|
+
editor.getEditorState().read(() => {
|
|
34
|
+
const node = $getNodeByKey(nodeKey);
|
|
35
|
+
if (!node) return;
|
|
36
|
+
const next = node.getNextSibling();
|
|
37
|
+
setIsLast(!next || !$isScheduleItemNode(next));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}, [editor, nodeKey]);
|
|
41
|
+
|
|
42
|
+
const updateNode = useCallback(
|
|
43
|
+
(updated: ScheduleItemData) => {
|
|
44
|
+
editor.update(() => {
|
|
45
|
+
const node = $getNodeByKey(nodeKey);
|
|
46
|
+
if ($isScheduleItemNode(node)) {
|
|
47
|
+
node.setData(updated);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
[editor, nodeKey]
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const handleChange = (field: keyof ScheduleItemData, value: string) => {
|
|
55
|
+
const nextRaw: ScheduleItemData = {
|
|
56
|
+
...localData,
|
|
57
|
+
time: field === 'time' ? value : localData.time,
|
|
58
|
+
title: field === 'title' ? value : localData.title,
|
|
59
|
+
speaker: field === 'speaker' ? value || undefined : localData.speaker,
|
|
60
|
+
description: field === 'description' ? value || undefined : localData.description,
|
|
61
|
+
};
|
|
62
|
+
const persisted: ScheduleItemData = {
|
|
63
|
+
...nextRaw,
|
|
64
|
+
time: nextRaw.time.trim(),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
setLocalData(nextRaw);
|
|
68
|
+
updateNode(persisted);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const insertAfter = () => {
|
|
72
|
+
editor.update(() => {
|
|
73
|
+
const node = $getNodeByKey(nodeKey);
|
|
74
|
+
if (!node) return;
|
|
75
|
+
const newNode = $createScheduleItemNode({ time: '', title: '' });
|
|
76
|
+
node.insertAfter(newNode);
|
|
77
|
+
newNode.selectEnd();
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const removeNode = () => {
|
|
82
|
+
editor.update(() => {
|
|
83
|
+
const node = $getNodeByKey(nodeKey);
|
|
84
|
+
if (!node) return;
|
|
85
|
+
const next = node.getNextSibling();
|
|
86
|
+
const prev = node.getPreviousSibling();
|
|
87
|
+
if (!next && !prev) {
|
|
88
|
+
const p = $createParagraphNode();
|
|
89
|
+
node.replace(p);
|
|
90
|
+
p.selectEnd();
|
|
91
|
+
} else {
|
|
92
|
+
node.remove();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleDragStart = (e: DragEvent) => {
|
|
98
|
+
e.dataTransfer.setData('text/plain', nodeKey);
|
|
99
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
100
|
+
setIsDragging(true);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleDragEnd = () => setIsDragging(false);
|
|
104
|
+
|
|
105
|
+
const handleDragOver = (e: DragEvent) => {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
e.dataTransfer.dropEffect = 'move';
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleDrop = (e: DragEvent) => {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
const sourceKey = e.dataTransfer.getData('text/plain');
|
|
113
|
+
if (!sourceKey || sourceKey === nodeKey) return;
|
|
114
|
+
editor.update(() => {
|
|
115
|
+
const sourceNode = $getNodeByKey(sourceKey);
|
|
116
|
+
const targetNode = $getNodeByKey(nodeKey);
|
|
117
|
+
if (!sourceNode || !targetNode || !$isScheduleItemNode(sourceNode)) return;
|
|
118
|
+
targetNode.insertBefore(sourceNode);
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="schedule-item-wrapper">
|
|
124
|
+
<div
|
|
125
|
+
role="group"
|
|
126
|
+
aria-label="Schedule item"
|
|
127
|
+
className={`schedule-item-editor ${isSelected ? 'schedule-item-editor--selected' : ''} ${isDragging ? 'schedule-item-editor--dragging' : ''}`}
|
|
128
|
+
onDragOver={handleDragOver}
|
|
129
|
+
onDrop={handleDrop}
|
|
130
|
+
>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className="schedule-item-drag"
|
|
134
|
+
draggable
|
|
135
|
+
onDragStart={handleDragStart}
|
|
136
|
+
onDragEnd={handleDragEnd}
|
|
137
|
+
title="Drag to reorder"
|
|
138
|
+
aria-label="Drag to reorder"
|
|
139
|
+
>
|
|
140
|
+
⠿
|
|
141
|
+
</button>
|
|
142
|
+
<input
|
|
143
|
+
ref={timeRef}
|
|
144
|
+
className="schedule-item-time"
|
|
145
|
+
type="text"
|
|
146
|
+
value={localData.time}
|
|
147
|
+
onChange={(e) => handleChange('time', e.target.value)}
|
|
148
|
+
placeholder="09:00–10:00"
|
|
149
|
+
size={13}
|
|
150
|
+
/>
|
|
151
|
+
<input
|
|
152
|
+
className="schedule-item-title"
|
|
153
|
+
type="text"
|
|
154
|
+
value={localData.title}
|
|
155
|
+
onChange={(e) => handleChange('title', e.target.value)}
|
|
156
|
+
placeholder="Title"
|
|
157
|
+
/>
|
|
158
|
+
<input
|
|
159
|
+
className="schedule-item-speaker"
|
|
160
|
+
type="text"
|
|
161
|
+
value={localData.speaker ?? ''}
|
|
162
|
+
onChange={(e) => handleChange('speaker', e.target.value)}
|
|
163
|
+
placeholder="Speaker"
|
|
164
|
+
/>
|
|
165
|
+
<input
|
|
166
|
+
className="schedule-item-description"
|
|
167
|
+
type="text"
|
|
168
|
+
value={localData.description ?? ''}
|
|
169
|
+
onChange={(e) => handleChange('description', e.target.value)}
|
|
170
|
+
placeholder="Description"
|
|
171
|
+
/>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
className="schedule-item-remove"
|
|
175
|
+
onClick={removeNode}
|
|
176
|
+
title="Remove item"
|
|
177
|
+
aria-label="Remove schedule item"
|
|
178
|
+
>
|
|
179
|
+
×
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
{isLast && (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="schedule-item-add"
|
|
186
|
+
onClick={insertAfter}
|
|
187
|
+
title="Add item"
|
|
188
|
+
aria-label="Add schedule item"
|
|
189
|
+
>
|
|
190
|
+
+
|
|
191
|
+
</button>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$applyNodeReplacement,
|
|
3
|
+
DecoratorNode,
|
|
4
|
+
type DOMConversionMap,
|
|
5
|
+
type DOMExportOutput,
|
|
6
|
+
type LexicalNode,
|
|
7
|
+
type NodeKey,
|
|
8
|
+
type SerializedLexicalNode,
|
|
9
|
+
type Spread,
|
|
10
|
+
} from 'lexical';
|
|
11
|
+
import { createElement, type ReactElement } from 'react';
|
|
12
|
+
|
|
13
|
+
import { ScheduleItemComponent } from './ScheduleItemComponent';
|
|
14
|
+
|
|
15
|
+
export interface ScheduleItemData {
|
|
16
|
+
time: string;
|
|
17
|
+
title: string;
|
|
18
|
+
speaker?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SerializedScheduleItemNode = Spread<{ data: ScheduleItemData }, SerializedLexicalNode>;
|
|
23
|
+
|
|
24
|
+
export class ScheduleItemNode extends DecoratorNode<ReactElement> {
|
|
25
|
+
__data: ScheduleItemData;
|
|
26
|
+
|
|
27
|
+
static getType(): string {
|
|
28
|
+
return 'schedule-item';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static clone(node: ScheduleItemNode): ScheduleItemNode {
|
|
32
|
+
return new ScheduleItemNode({ ...node.__data }, node.__key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
constructor(data: ScheduleItemData, key?: NodeKey) {
|
|
36
|
+
super(key);
|
|
37
|
+
this.__data = data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getData(): ScheduleItemData {
|
|
41
|
+
return this.getLatest().__data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setData(data: ScheduleItemData): void {
|
|
45
|
+
const self = this.getWritable();
|
|
46
|
+
self.__data = data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- DOM ---
|
|
50
|
+
|
|
51
|
+
createDOM(): HTMLElement {
|
|
52
|
+
const div = document.createElement('div');
|
|
53
|
+
div.className = 'schedule-item';
|
|
54
|
+
return div;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
updateDOM(): boolean {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static importDOM(): DOMConversionMap | null {
|
|
62
|
+
return {
|
|
63
|
+
div: (node: HTMLElement) => {
|
|
64
|
+
if (!node.classList.contains('schedule-item')) return null;
|
|
65
|
+
return {
|
|
66
|
+
conversion: () => {
|
|
67
|
+
const data = {
|
|
68
|
+
time: node.dataset.time ?? '',
|
|
69
|
+
title: node.dataset.title ?? '',
|
|
70
|
+
speaker: node.dataset.speaker || undefined,
|
|
71
|
+
description: node.dataset.description || undefined,
|
|
72
|
+
};
|
|
73
|
+
return { node: $createScheduleItemNode(data) };
|
|
74
|
+
},
|
|
75
|
+
priority: 1,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
exportDOM(): DOMExportOutput {
|
|
82
|
+
const el = document.createElement('div');
|
|
83
|
+
el.className = 'schedule-item';
|
|
84
|
+
el.dataset.time = this.__data.time;
|
|
85
|
+
el.dataset.title = this.__data.title;
|
|
86
|
+
if (this.__data.speaker) el.dataset.speaker = this.__data.speaker;
|
|
87
|
+
if (this.__data.description) el.dataset.description = this.__data.description;
|
|
88
|
+
return { element: el };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Serialization ---
|
|
92
|
+
|
|
93
|
+
static importJSON(json: SerializedScheduleItemNode): ScheduleItemNode {
|
|
94
|
+
return $createScheduleItemNode(json.data);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
exportJSON(): SerializedScheduleItemNode {
|
|
98
|
+
return {
|
|
99
|
+
...super.exportJSON(),
|
|
100
|
+
type: 'schedule-item',
|
|
101
|
+
data: this.__data,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Text ---
|
|
106
|
+
|
|
107
|
+
getTextContent(): string {
|
|
108
|
+
const { time, title, speaker, description } = this.__data;
|
|
109
|
+
const parts = [title, speaker, description].filter(Boolean);
|
|
110
|
+
return `${time} ${parts.join(' | ')}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
isInline(): boolean {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Decorator (React rendering is handled by the component) ---
|
|
118
|
+
|
|
119
|
+
decorate(): ReactElement {
|
|
120
|
+
return createElement(ScheduleItemComponent, {
|
|
121
|
+
nodeKey: this.__key,
|
|
122
|
+
data: this.__data,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function $createScheduleItemNode(data: ScheduleItemData): ScheduleItemNode {
|
|
128
|
+
return $applyNodeReplacement(new ScheduleItemNode(data));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function $isScheduleItemNode(
|
|
132
|
+
node: LexicalNode | null | undefined
|
|
133
|
+
): node is ScheduleItemNode {
|
|
134
|
+
return node instanceof ScheduleItemNode;
|
|
135
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ElementTransformer } from '@lexical/markdown';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
$createScheduleItemNode,
|
|
5
|
+
$isScheduleItemNode,
|
|
6
|
+
type ScheduleItemData,
|
|
7
|
+
ScheduleItemNode,
|
|
8
|
+
} from './ScheduleItemNode';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Markdown format: `- **09:00–10:00** Title | Speaker | Description`
|
|
12
|
+
*
|
|
13
|
+
* This matches the format expected by the remarkSchedule plugin in
|
|
14
|
+
* markdown-plugin-happening, so the same markdown renders correctly
|
|
15
|
+
* both in the editor and on the frontend.
|
|
16
|
+
*/
|
|
17
|
+
const SCHEDULE_ITEM_REGEX =
|
|
18
|
+
/^-\s+\*\*(\d{1,2}[.:]\d{2}(?:\s*[–-]\s*\d{1,2}[.:]\d{2})?)\s*\*\*\s+(.+)$/;
|
|
19
|
+
|
|
20
|
+
export function normalizeScheduleItemData(data: {
|
|
21
|
+
time: string;
|
|
22
|
+
title: string;
|
|
23
|
+
speaker?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
}) {
|
|
26
|
+
return {
|
|
27
|
+
time: data.time.trim(),
|
|
28
|
+
title: data.title.trim(),
|
|
29
|
+
speaker: data.speaker?.trim() || undefined,
|
|
30
|
+
description: data.description?.trim() || undefined,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseScheduleItemMarkdown(markdown: string) {
|
|
35
|
+
const match = markdown.match(SCHEDULE_ITEM_REGEX);
|
|
36
|
+
if (!match) return null;
|
|
37
|
+
|
|
38
|
+
const time = (match[1] ?? '').replaceAll('.', ':').trim();
|
|
39
|
+
const rest = (match[2] ?? '').trim();
|
|
40
|
+
const parts = rest.split('|').map((s) => s.trim());
|
|
41
|
+
|
|
42
|
+
return normalizeScheduleItemData({
|
|
43
|
+
time,
|
|
44
|
+
title: parts[0] ?? '',
|
|
45
|
+
speaker: parts[1] || undefined,
|
|
46
|
+
description: parts[2] || undefined,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatScheduleItemMarkdown(data: ScheduleItemData): string {
|
|
51
|
+
const normalized = normalizeScheduleItemData(data);
|
|
52
|
+
const parts = [normalized.title, normalized.speaker, normalized.description].filter(Boolean);
|
|
53
|
+
return `- **${normalized.time}** ${parts.join(' | ')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const SCHEDULE_ITEM_TRANSFORMER: ElementTransformer = {
|
|
57
|
+
type: 'element',
|
|
58
|
+
dependencies: [ScheduleItemNode],
|
|
59
|
+
|
|
60
|
+
regExp: SCHEDULE_ITEM_REGEX,
|
|
61
|
+
|
|
62
|
+
export: (node) => {
|
|
63
|
+
if (!$isScheduleItemNode(node)) return null;
|
|
64
|
+
|
|
65
|
+
return formatScheduleItemMarkdown(node.getData());
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
replace: (parentNode, _children, match) => {
|
|
69
|
+
const parsed = parseScheduleItemMarkdown(match[0] ?? '');
|
|
70
|
+
|
|
71
|
+
const node = $createScheduleItemNode(
|
|
72
|
+
parsed ?? {
|
|
73
|
+
time: ((match[1] ?? '').replaceAll('.', ':').trim() || '').trim(),
|
|
74
|
+
title: '',
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
parentNode.replace(node);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { ScheduleItemData, SerializedScheduleItemNode } from './ScheduleItemNode';
|
|
2
|
+
export { $createScheduleItemNode, $isScheduleItemNode, ScheduleItemNode } from './ScheduleItemNode';
|
|
3
|
+
export { SCHEDULE_ITEM_TRANSFORMER } from './ScheduleTransformer';
|
|
4
|
+
export { schedulePlugin } from './schedulePlugin';
|