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.
@@ -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 };