leepi 0.0.0 → 0.0.2
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/package.json +90 -1
- package/src/core/active-marks.ts +89 -0
- package/src/core/commands.ts +461 -0
- package/src/core/editor.ts +139 -0
- package/src/core/field-notifier.ts +71 -0
- package/src/core/highlight-style.ts +66 -0
- package/src/core/highlight.ts +50 -0
- package/src/core/plugins/blockquote.ts +108 -0
- package/src/core/plugins/bracket.ts +34 -0
- package/src/core/plugins/code-block.ts +195 -0
- package/src/core/plugins/heading.ts +95 -0
- package/src/core/plugins/index.ts +16 -0
- package/src/core/plugins/inline.ts +62 -0
- package/src/core/plugins/link.ts +124 -0
- package/src/core/plugins/list.ts +68 -0
- package/src/core/plugins/table.ts +217 -0
- package/src/core/popover.ts +17 -0
- package/src/core/registry.ts +18 -0
- package/src/core/types.ts +25 -0
- package/src/core/utils.ts +38 -0
- package/src/react/code-block-popover.tsx +387 -0
- package/src/react/context.tsx +153 -0
- package/src/react/editor.tsx +106 -0
- package/src/react/floating-toolbar.tsx +161 -0
- package/src/react/link-popover.tsx +354 -0
- package/src/react/preview.tsx +80 -0
- package/src/react/toolbar.tsx +294 -0
- package/src/styles/floating-toolbar.css +52 -0
- package/src/styles/leepi.css +2 -0
- package/src/styles/popover.css +93 -0
- package/src/styles/preview.css +191 -0
- package/src/styles/theme.css +99 -0
- package/src/styles/tokens.css +63 -0
- package/src/styles/toolbar.css +55 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useId,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type JSX,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { Popover } from "@base-ui/react/popover";
|
|
12
|
+
import { Form } from "@base-ui/react/form";
|
|
13
|
+
import { Field } from "@base-ui/react/field";
|
|
14
|
+
import { useRender, type useRender as UseRender } from "@base-ui/react/use-render";
|
|
15
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
16
|
+
import { useEditorContext, useStateField, useVirtualAnchor } from "./context";
|
|
17
|
+
import { popoverField, closePopover } from "../core/popover";
|
|
18
|
+
import type { CodeBlockRequest } from "../core/plugins/code-block";
|
|
19
|
+
import { applyCodeBlock } from "../core/commands";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Context
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface CodeBlockPopoverContextValue {
|
|
26
|
+
lang: string;
|
|
27
|
+
setLang: (lang: string) => void;
|
|
28
|
+
filename: string;
|
|
29
|
+
setFilename: (filename: string) => void;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CodeBlockPopoverCtx = createContext<CodeBlockPopoverContextValue | null>(null);
|
|
34
|
+
|
|
35
|
+
function useCodeBlockPopoverCtx() {
|
|
36
|
+
const ctx = useContext(CodeBlockPopoverCtx);
|
|
37
|
+
if (!ctx) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"leepi: CodeBlockPopover sub-components must be used inside CodeBlockPopover.Root",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return ctx;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Sub-components
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
interface LanguageSelectProps extends UseRender.ComponentProps<"select"> {
|
|
50
|
+
autoFocus?: boolean;
|
|
51
|
+
label?: ReactNode;
|
|
52
|
+
children?: ReactNode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function LanguageSelect({
|
|
56
|
+
autoFocus,
|
|
57
|
+
render,
|
|
58
|
+
label,
|
|
59
|
+
children,
|
|
60
|
+
...restProps
|
|
61
|
+
}: LanguageSelectProps): JSX.Element {
|
|
62
|
+
const { lang, setLang } = useCodeBlockPopoverCtx();
|
|
63
|
+
const id = useId();
|
|
64
|
+
|
|
65
|
+
const element = useRender({
|
|
66
|
+
render,
|
|
67
|
+
defaultTagName: "select",
|
|
68
|
+
props: mergeProps<"select">(
|
|
69
|
+
{
|
|
70
|
+
id,
|
|
71
|
+
value: lang,
|
|
72
|
+
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setLang(e.target.value),
|
|
73
|
+
autoFocus,
|
|
74
|
+
className: "lp-popover-select",
|
|
75
|
+
},
|
|
76
|
+
restProps,
|
|
77
|
+
),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const defaultSelect = !render ? (
|
|
81
|
+
<select
|
|
82
|
+
id={id}
|
|
83
|
+
value={lang}
|
|
84
|
+
onChange={(e) => setLang(e.target.value)}
|
|
85
|
+
autoFocus={autoFocus}
|
|
86
|
+
className="lp-popover-select"
|
|
87
|
+
{...restProps}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</select>
|
|
91
|
+
) : null;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Field.Root className="lp-popover-field">
|
|
95
|
+
<Field.Label htmlFor={id} className="lp-popover-label">
|
|
96
|
+
{label}
|
|
97
|
+
</Field.Label>
|
|
98
|
+
{render ? element : defaultSelect}
|
|
99
|
+
</Field.Root>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface LanguageInputProps extends UseRender.ComponentProps<"input"> {
|
|
104
|
+
autoFocus?: boolean;
|
|
105
|
+
placeholder?: string;
|
|
106
|
+
children?: ReactNode;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function LanguageInput({
|
|
110
|
+
autoFocus,
|
|
111
|
+
placeholder,
|
|
112
|
+
render,
|
|
113
|
+
children,
|
|
114
|
+
...restProps
|
|
115
|
+
}: LanguageInputProps): JSX.Element {
|
|
116
|
+
const { lang, setLang } = useCodeBlockPopoverCtx();
|
|
117
|
+
const id = useId();
|
|
118
|
+
|
|
119
|
+
const element = useRender({
|
|
120
|
+
render,
|
|
121
|
+
defaultTagName: "input",
|
|
122
|
+
props: mergeProps<"input">(
|
|
123
|
+
{
|
|
124
|
+
id,
|
|
125
|
+
type: "text",
|
|
126
|
+
value: lang,
|
|
127
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setLang(e.target.value),
|
|
128
|
+
autoFocus,
|
|
129
|
+
placeholder,
|
|
130
|
+
className: "lp-popover-input",
|
|
131
|
+
},
|
|
132
|
+
restProps,
|
|
133
|
+
),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Field.Root className="lp-popover-field">
|
|
138
|
+
<Field.Label htmlFor={id} className="lp-popover-label">
|
|
139
|
+
{children}
|
|
140
|
+
</Field.Label>
|
|
141
|
+
{element}
|
|
142
|
+
</Field.Root>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface FilenameInputProps extends UseRender.ComponentProps<"input"> {
|
|
147
|
+
placeholder?: string;
|
|
148
|
+
children?: ReactNode;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function FilenameInput({
|
|
152
|
+
placeholder,
|
|
153
|
+
render,
|
|
154
|
+
children,
|
|
155
|
+
...restProps
|
|
156
|
+
}: FilenameInputProps): JSX.Element {
|
|
157
|
+
const { filename, setFilename } = useCodeBlockPopoverCtx();
|
|
158
|
+
const id = useId();
|
|
159
|
+
|
|
160
|
+
const element = useRender({
|
|
161
|
+
render,
|
|
162
|
+
defaultTagName: "input",
|
|
163
|
+
props: mergeProps<"input">(
|
|
164
|
+
{
|
|
165
|
+
id,
|
|
166
|
+
type: "text",
|
|
167
|
+
value: filename,
|
|
168
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setFilename(e.target.value),
|
|
169
|
+
placeholder,
|
|
170
|
+
className: "lp-popover-input",
|
|
171
|
+
},
|
|
172
|
+
restProps,
|
|
173
|
+
),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<Field.Root className="lp-popover-field">
|
|
178
|
+
<Field.Label htmlFor={id} className="lp-popover-label">
|
|
179
|
+
{children}
|
|
180
|
+
</Field.Label>
|
|
181
|
+
{element}
|
|
182
|
+
</Field.Root>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function Actions({
|
|
187
|
+
cancelLabel,
|
|
188
|
+
submitLabel,
|
|
189
|
+
}: {
|
|
190
|
+
cancelLabel?: ReactNode;
|
|
191
|
+
submitLabel?: ReactNode;
|
|
192
|
+
}): JSX.Element {
|
|
193
|
+
const { onClose } = useCodeBlockPopoverCtx();
|
|
194
|
+
return (
|
|
195
|
+
<div className="lp-popover-actions">
|
|
196
|
+
<span />
|
|
197
|
+
<div className="lp-popover-actions-end">
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={onClose}
|
|
201
|
+
className="lp-popover-btn lp-popover-btn--secondary"
|
|
202
|
+
>
|
|
203
|
+
{cancelLabel}
|
|
204
|
+
</button>
|
|
205
|
+
<button type="submit" className="lp-popover-btn lp-popover-btn--primary">
|
|
206
|
+
{submitLabel}
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Content — form content without Popover wrapper
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
export interface CodeBlockPopoverContentProps {
|
|
218
|
+
initialLang?: string;
|
|
219
|
+
initialFilename?: string;
|
|
220
|
+
onSubmit: (lang: string, filename: string) => void;
|
|
221
|
+
onClose: () => void;
|
|
222
|
+
children: ReactNode;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function CodeBlockPopoverContent({
|
|
226
|
+
initialLang,
|
|
227
|
+
initialFilename,
|
|
228
|
+
onSubmit,
|
|
229
|
+
onClose,
|
|
230
|
+
children,
|
|
231
|
+
}: CodeBlockPopoverContentProps): JSX.Element {
|
|
232
|
+
const [lang, setLang] = useState(initialLang || "");
|
|
233
|
+
const [filename, setFilename] = useState(initialFilename || "");
|
|
234
|
+
|
|
235
|
+
const handleSubmit = useCallback(
|
|
236
|
+
(e: React.SubmitEvent) => {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
onSubmit(lang, filename);
|
|
239
|
+
},
|
|
240
|
+
[lang, filename, onSubmit],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const ctxValue = useMemo(
|
|
244
|
+
() => ({ lang, setLang, filename, setFilename, onClose }),
|
|
245
|
+
[lang, filename, onClose],
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<CodeBlockPopoverCtx value={ctxValue}>
|
|
250
|
+
<Form onSubmit={handleSubmit} className="lp-popover-form">
|
|
251
|
+
{children}
|
|
252
|
+
</Form>
|
|
253
|
+
</CodeBlockPopoverCtx>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Root — standalone with its own Popover wrapper
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
export interface CodeBlockPopoverProps extends CodeBlockPopoverContentProps {
|
|
262
|
+
x: number;
|
|
263
|
+
y: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function CodeBlockPopoverRoot({
|
|
267
|
+
x,
|
|
268
|
+
y,
|
|
269
|
+
onClose,
|
|
270
|
+
...contentProps
|
|
271
|
+
}: CodeBlockPopoverProps): JSX.Element {
|
|
272
|
+
const virtualAnchor = useMemo(
|
|
273
|
+
() => ({
|
|
274
|
+
getBoundingClientRect: () => ({
|
|
275
|
+
x,
|
|
276
|
+
y,
|
|
277
|
+
width: 0,
|
|
278
|
+
height: 0,
|
|
279
|
+
top: y,
|
|
280
|
+
left: x,
|
|
281
|
+
right: x,
|
|
282
|
+
bottom: y,
|
|
283
|
+
toJSON() {
|
|
284
|
+
return this;
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
}),
|
|
288
|
+
[x, y],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<Popover.Root open onOpenChange={onClose}>
|
|
293
|
+
<Popover.Portal>
|
|
294
|
+
<Popover.Positioner
|
|
295
|
+
anchor={virtualAnchor}
|
|
296
|
+
positionMethod="fixed"
|
|
297
|
+
side="bottom"
|
|
298
|
+
align="start"
|
|
299
|
+
sideOffset={8}
|
|
300
|
+
className="lp-popover-positioner"
|
|
301
|
+
>
|
|
302
|
+
<Popover.Popup className="lp-popover">
|
|
303
|
+
<CodeBlockPopoverContent onClose={onClose} {...contentProps} />
|
|
304
|
+
</Popover.Popup>
|
|
305
|
+
</Popover.Positioner>
|
|
306
|
+
</Popover.Portal>
|
|
307
|
+
</Popover.Root>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Connected — subscribes to StateField, owns its own Popover.Root
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
export function CodeBlockPopoverConnected({ children }: { children: ReactNode }): JSX.Element {
|
|
316
|
+
const { view } = useEditorContext();
|
|
317
|
+
const popoverReq = useStateField(popoverField, null);
|
|
318
|
+
const codeBlockReq = popoverReq?.type === "codeblock" ? (popoverReq as CodeBlockRequest) : null;
|
|
319
|
+
|
|
320
|
+
const handleSubmit = useCallback(
|
|
321
|
+
(lang: string, filename: string) => {
|
|
322
|
+
if (!view || !codeBlockReq) return;
|
|
323
|
+
applyCodeBlock(view, codeBlockReq.data, lang, filename);
|
|
324
|
+
view.dispatch({ effects: closePopover.of() });
|
|
325
|
+
},
|
|
326
|
+
[view, codeBlockReq],
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const handleClose = useCallback(() => {
|
|
330
|
+
if (!view) return;
|
|
331
|
+
view.dispatch({ effects: closePopover.of() });
|
|
332
|
+
view.focus();
|
|
333
|
+
}, [view]);
|
|
334
|
+
|
|
335
|
+
const anchorPos = codeBlockReq
|
|
336
|
+
? codeBlockReq.data.isNew
|
|
337
|
+
? (codeBlockReq.data.insertPos ?? 0)
|
|
338
|
+
: codeBlockReq.data.fenceFrom
|
|
339
|
+
: null;
|
|
340
|
+
const virtualAnchor = useVirtualAnchor(anchorPos);
|
|
341
|
+
return (
|
|
342
|
+
<Popover.Root
|
|
343
|
+
open={!!codeBlockReq}
|
|
344
|
+
onOpenChange={(open) => {
|
|
345
|
+
if (!open) handleClose();
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
<Popover.Portal>
|
|
349
|
+
<Popover.Positioner
|
|
350
|
+
anchor={virtualAnchor}
|
|
351
|
+
positionMethod="fixed"
|
|
352
|
+
side="bottom"
|
|
353
|
+
align="start"
|
|
354
|
+
sideOffset={8}
|
|
355
|
+
className="lp-popover-positioner"
|
|
356
|
+
>
|
|
357
|
+
<Popover.Popup className="lp-popover">
|
|
358
|
+
{codeBlockReq && (
|
|
359
|
+
<CodeBlockPopoverContent
|
|
360
|
+
key={`${codeBlockReq.data.fenceFrom}-${codeBlockReq.data.isNew}`}
|
|
361
|
+
initialLang={codeBlockReq.data.lang}
|
|
362
|
+
initialFilename={codeBlockReq.data.filename}
|
|
363
|
+
onSubmit={handleSubmit}
|
|
364
|
+
onClose={handleClose}
|
|
365
|
+
>
|
|
366
|
+
{children}
|
|
367
|
+
</CodeBlockPopoverContent>
|
|
368
|
+
)}
|
|
369
|
+
</Popover.Popup>
|
|
370
|
+
</Popover.Positioner>
|
|
371
|
+
</Popover.Portal>
|
|
372
|
+
</Popover.Root>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Namespace exports
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
export {
|
|
381
|
+
CodeBlockPopoverRoot as Root,
|
|
382
|
+
CodeBlockPopoverContent as Content,
|
|
383
|
+
LanguageSelect,
|
|
384
|
+
LanguageInput,
|
|
385
|
+
FilenameInput,
|
|
386
|
+
Actions,
|
|
387
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useState,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type JSX,
|
|
9
|
+
useMemo,
|
|
10
|
+
} from "react";
|
|
11
|
+
import type { EditorView } from "@codemirror/view";
|
|
12
|
+
import type { StateField } from "@codemirror/state";
|
|
13
|
+
import {
|
|
14
|
+
emptyMarks,
|
|
15
|
+
subscribeToMarks,
|
|
16
|
+
getMarksSnapshot,
|
|
17
|
+
type ActiveMarks,
|
|
18
|
+
} from "../core/active-marks";
|
|
19
|
+
import { subscribeToField, getFieldSnapshot } from "../core/field-notifier";
|
|
20
|
+
import { shortcutRegistry } from "../core/registry";
|
|
21
|
+
import type { ShortcutEntry } from "../core/types";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Context
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface EditorContextValue {
|
|
28
|
+
view: EditorView | null;
|
|
29
|
+
setView: (view: EditorView | null) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EditorContext = createContext<EditorContextValue | null>(null);
|
|
33
|
+
|
|
34
|
+
export function useEditorContext(): EditorContextValue {
|
|
35
|
+
const ctx = useContext(EditorContext);
|
|
36
|
+
if (!ctx) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"leepi: <Toolbar />, <FloatingToolbar /> must be rendered inside <EditorProvider>",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return ctx;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// EditorProvider
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface EditorProviderProps {
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function EditorProvider({ children }: EditorProviderProps): JSX.Element {
|
|
53
|
+
const [view, setView] = useState<EditorView | null>(null);
|
|
54
|
+
|
|
55
|
+
const editorCtx = useMemo(() => ({ view, setView }), [view]);
|
|
56
|
+
|
|
57
|
+
return <EditorContext.Provider value={editorCtx}>{children}</EditorContext.Provider>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Hooks
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns the currently active formatting marks at the cursor position.
|
|
66
|
+
* Updates reactively when the cursor moves or the document changes.
|
|
67
|
+
*/
|
|
68
|
+
export function useActiveMarks(): ActiveMarks {
|
|
69
|
+
const { view } = useEditorContext();
|
|
70
|
+
|
|
71
|
+
const subscribe = useCallback(
|
|
72
|
+
(onStoreChange: () => void) => {
|
|
73
|
+
if (!view) return () => {};
|
|
74
|
+
return subscribeToMarks(view, onStoreChange);
|
|
75
|
+
},
|
|
76
|
+
[view],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const getSnapshot = useCallback(() => {
|
|
80
|
+
if (!view) return emptyMarks;
|
|
81
|
+
return getMarksSnapshot(view);
|
|
82
|
+
}, [view]);
|
|
83
|
+
|
|
84
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Subscribe to a CM6 StateField and re-render when its value changes.
|
|
89
|
+
*/
|
|
90
|
+
export function useStateField<T>(field: StateField<T>, defaultValue: T): T {
|
|
91
|
+
const { view } = useEditorContext();
|
|
92
|
+
|
|
93
|
+
const subscribe = useCallback(
|
|
94
|
+
(onStoreChange: () => void) => {
|
|
95
|
+
if (!view) return () => {};
|
|
96
|
+
return subscribeToField(view, field, onStoreChange);
|
|
97
|
+
},
|
|
98
|
+
[view, field],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const getSnapshot = useCallback(() => {
|
|
102
|
+
if (!view) return defaultValue;
|
|
103
|
+
return getFieldSnapshot(view, field) ?? defaultValue;
|
|
104
|
+
}, [view, field, defaultValue]);
|
|
105
|
+
|
|
106
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns a virtual anchor element that dynamically resolves its position
|
|
111
|
+
* from a CodeMirror document position. Useful for anchoring popovers to
|
|
112
|
+
* editor content that may scroll after the popover opens.
|
|
113
|
+
*/
|
|
114
|
+
export interface VirtualAnchor {
|
|
115
|
+
getBoundingClientRect: () => DOMRect;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function useVirtualAnchor(pos: number | null): VirtualAnchor | undefined {
|
|
119
|
+
const { view } = useEditorContext();
|
|
120
|
+
|
|
121
|
+
return useMemo(() => {
|
|
122
|
+
if (pos == null || !view) return undefined;
|
|
123
|
+
return {
|
|
124
|
+
getBoundingClientRect: () => {
|
|
125
|
+
const coords = view.coordsAtPos(pos);
|
|
126
|
+
const x = coords?.left ?? 0;
|
|
127
|
+
const y = coords?.bottom ?? 0;
|
|
128
|
+
return {
|
|
129
|
+
x,
|
|
130
|
+
y,
|
|
131
|
+
width: 0,
|
|
132
|
+
height: 0,
|
|
133
|
+
top: y,
|
|
134
|
+
left: x,
|
|
135
|
+
right: x,
|
|
136
|
+
bottom: y,
|
|
137
|
+
toJSON() {
|
|
138
|
+
return this;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}, [pos, view]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Returns all shortcuts registered by plugins via the shortcutRegistry Facet.
|
|
148
|
+
*/
|
|
149
|
+
export function useShortcuts(): ShortcutEntry[] {
|
|
150
|
+
const { view } = useEditorContext();
|
|
151
|
+
if (!view) return [];
|
|
152
|
+
return view.state.facet(shortcutRegistry);
|
|
153
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode, type JSX, type ComponentProps } from "react";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import type { Extension } from "@codemirror/state";
|
|
4
|
+
import { createEditorState, loadLanguageSupport, editableCompartment } from "../core/editor";
|
|
5
|
+
import { useEditorContext } from "./context";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Editor
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export type EditorProps = {
|
|
12
|
+
onChange?: (value: string) => void;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
/** Additional CM6 extensions / plugins */
|
|
15
|
+
plugins?: Extension[];
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
} & Pick<ComponentProps<"textarea">, "className" | "style" | "readOnly" | "autoFocus"> &
|
|
18
|
+
({ value: string; defaultValue?: never } | { value?: never; defaultValue: string });
|
|
19
|
+
|
|
20
|
+
function EditorRoot({
|
|
21
|
+
value,
|
|
22
|
+
defaultValue,
|
|
23
|
+
onChange,
|
|
24
|
+
placeholder = "",
|
|
25
|
+
plugins,
|
|
26
|
+
readOnly = false,
|
|
27
|
+
autoFocus = false,
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
style,
|
|
31
|
+
}: EditorProps): JSX.Element {
|
|
32
|
+
const isControlled = value !== undefined;
|
|
33
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
const viewRef = useRef<EditorView | null>(null);
|
|
35
|
+
const onChangeRef = useRef(onChange);
|
|
36
|
+
onChangeRef.current = onChange;
|
|
37
|
+
|
|
38
|
+
const { setView } = useEditorContext();
|
|
39
|
+
|
|
40
|
+
// Initialize CodeMirror
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!containerRef.current) return;
|
|
43
|
+
|
|
44
|
+
containerRef.current.style.display = "contents";
|
|
45
|
+
|
|
46
|
+
const state = createEditorState({
|
|
47
|
+
doc: value ?? defaultValue ?? "",
|
|
48
|
+
onUpdate: (doc) => onChangeRef.current?.(doc),
|
|
49
|
+
plugins,
|
|
50
|
+
placeholder,
|
|
51
|
+
readOnly,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const view = new EditorView({
|
|
55
|
+
state,
|
|
56
|
+
parent: containerRef.current,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
viewRef.current = view;
|
|
60
|
+
setView(view);
|
|
61
|
+
|
|
62
|
+
if (autoFocus) {
|
|
63
|
+
view.focus();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
loadLanguageSupport(view);
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
view.destroy();
|
|
70
|
+
viewRef.current = null;
|
|
71
|
+
setView(null);
|
|
72
|
+
};
|
|
73
|
+
}, [setView]);
|
|
74
|
+
|
|
75
|
+
// Sync external value changes (controlled mode only)
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!isControlled) return;
|
|
78
|
+
const view = viewRef.current;
|
|
79
|
+
if (!view) return;
|
|
80
|
+
|
|
81
|
+
const currentDoc = view.state.doc.toString();
|
|
82
|
+
if (currentDoc !== value) {
|
|
83
|
+
view.dispatch({
|
|
84
|
+
changes: { from: 0, to: currentDoc.length, insert: value ?? "" },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}, [isControlled, value]);
|
|
88
|
+
|
|
89
|
+
// Sync readOnly
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const view = viewRef.current;
|
|
92
|
+
if (!view) return;
|
|
93
|
+
view.dispatch({
|
|
94
|
+
effects: editableCompartment.reconfigure(EditorView.editable.of(!readOnly)),
|
|
95
|
+
});
|
|
96
|
+
}, [readOnly]);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
<div ref={containerRef} className={className} style={style} />
|
|
101
|
+
{children}
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { EditorRoot as Editor };
|