leepi 0.0.0 → 0.0.3
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/core/active-marks.d.ts +15 -0
- package/dist/core/active-marks.js +57 -0
- package/dist/core/commands.d.ts +39 -0
- package/dist/core/commands.js +415 -0
- package/dist/core/editor.d.ts +25 -0
- package/dist/core/editor.js +102 -0
- package/dist/core/field-notifier.d.ts +21 -0
- package/dist/core/field-notifier.js +56 -0
- package/dist/core/highlight-style.js +60 -0
- package/dist/core/highlight.d.ts +8 -0
- package/dist/core/highlight.js +34 -0
- package/dist/core/plugins/blockquote.d.ts +11 -0
- package/dist/core/plugins/blockquote.js +78 -0
- package/dist/core/plugins/bracket.d.ts +6 -0
- package/dist/core/plugins/bracket.js +38 -0
- package/dist/core/plugins/code-block.d.ts +27 -0
- package/dist/core/plugins/code-block.js +207 -0
- package/dist/core/plugins/heading.d.ts +13 -0
- package/dist/core/plugins/heading.js +111 -0
- package/dist/core/plugins/inline.d.ts +14 -0
- package/dist/core/plugins/inline.js +103 -0
- package/dist/core/plugins/link.d.ts +25 -0
- package/dist/core/plugins/link.js +104 -0
- package/dist/core/plugins/list.d.ts +14 -0
- package/dist/core/plugins/list.js +91 -0
- package/dist/core/plugins/table.d.ts +12 -0
- package/dist/core/plugins/table.js +161 -0
- package/dist/core/plugins.d.ts +9 -0
- package/dist/core/plugins.js +9 -0
- package/dist/core/popover.d.ts +9 -0
- package/dist/core/popover.js +16 -0
- package/dist/core/registry.d.ts +10 -0
- package/dist/core/registry.js +8 -0
- package/dist/core/types.d.ts +25 -0
- package/dist/core/types.js +0 -0
- package/dist/core/utils.d.ts +13 -0
- package/dist/core/utils.js +32 -0
- package/dist/leepi.css +461 -0
- package/dist/react/code-block-popover.d.ts +76 -0
- package/dist/react/code-block-popover.js +223 -0
- package/dist/react/context.d.ts +42 -0
- package/dist/react/context.js +88 -0
- package/dist/react/editor.d.ts +30 -0
- package/dist/react/editor.js +60 -0
- package/dist/react/floating-toolbar.d.ts +30 -0
- package/dist/react/floating-toolbar.js +87 -0
- package/dist/react/link-popover.d.ts +70 -0
- package/dist/react/link-popover.js +222 -0
- package/dist/react/preview.d.ts +13 -0
- package/dist/react/preview.js +56 -0
- package/dist/react/toolbar.d.ts +51 -0
- package/dist/react/toolbar.js +161 -0
- 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,161 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useState,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
type ComponentProps,
|
|
6
|
+
type JSX,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Toolbar as BaseToolbar } from "@base-ui/react/toolbar";
|
|
10
|
+
import { useEditorContext, useStateField } from "./context";
|
|
11
|
+
import { popoverField } from "../core/popover";
|
|
12
|
+
import { isInsideCodeBlock } from "../core/utils";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function useFloatingPosition() {
|
|
19
|
+
const { view } = useEditorContext();
|
|
20
|
+
const popoverReq = useStateField(popoverField, null);
|
|
21
|
+
const popoverOpen = popoverReq != null;
|
|
22
|
+
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!view) return;
|
|
26
|
+
|
|
27
|
+
const updatePosition = () => {
|
|
28
|
+
const { state } = view;
|
|
29
|
+
const { from, to } = state.selection.main;
|
|
30
|
+
|
|
31
|
+
if (from === to || isInsideCodeBlock(view)) {
|
|
32
|
+
setPosition(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Hide for multiline selections
|
|
37
|
+
const fromLine = state.doc.lineAt(from).number;
|
|
38
|
+
const toLine = state.doc.lineAt(to).number;
|
|
39
|
+
if (fromLine !== toLine) {
|
|
40
|
+
setPosition(null);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fromCoords = view.coordsAtPos(from);
|
|
45
|
+
const toCoords = view.coordsAtPos(to);
|
|
46
|
+
if (!fromCoords || !toCoords) {
|
|
47
|
+
setPosition(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const left = (fromCoords.left + toCoords.left) / 2;
|
|
52
|
+
const top = Math.min(fromCoords.top, toCoords.top) - 8;
|
|
53
|
+
|
|
54
|
+
setPosition({ top, left });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handler = () => requestAnimationFrame(updatePosition);
|
|
58
|
+
view.contentDOM.addEventListener("mouseup", handler);
|
|
59
|
+
view.contentDOM.addEventListener("keyup", handler);
|
|
60
|
+
|
|
61
|
+
// Re-evaluate position when a popover closes while a selection exists
|
|
62
|
+
if (!popoverOpen) {
|
|
63
|
+
updatePosition();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
view.contentDOM.removeEventListener("mouseup", handler);
|
|
68
|
+
view.contentDOM.removeEventListener("keyup", handler);
|
|
69
|
+
};
|
|
70
|
+
}, [view, popoverOpen]);
|
|
71
|
+
|
|
72
|
+
if (popoverOpen) return null;
|
|
73
|
+
|
|
74
|
+
return position;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// FloatingToolbar.Button — generic passthrough
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
type FloatingToolbarButtonProps = ComponentProps<typeof BaseToolbar.Button>;
|
|
82
|
+
|
|
83
|
+
function FloatingToolbarButton({ className, ...props }: FloatingToolbarButtonProps): JSX.Element {
|
|
84
|
+
return <BaseToolbar.Button className={`lp-floating-toolbar-btn ${className || ""}`} {...props} />;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// FloatingToolbar.Separator
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
type FloatingToolbarSeparatorProps = ComponentProps<typeof BaseToolbar.Separator>;
|
|
92
|
+
|
|
93
|
+
function FloatingToolbarSeparator({
|
|
94
|
+
className,
|
|
95
|
+
...props
|
|
96
|
+
}: FloatingToolbarSeparatorProps): JSX.Element {
|
|
97
|
+
return (
|
|
98
|
+
<BaseToolbar.Separator
|
|
99
|
+
className={`lp-floating-toolbar-separator ${className || ""}`}
|
|
100
|
+
{...props}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// FloatingToolbar.Group
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
type FloatingToolbarGroupProps = ComponentProps<typeof BaseToolbar.Group>;
|
|
110
|
+
|
|
111
|
+
function FloatingToolbarGroup({ className, ...props }: FloatingToolbarGroupProps): JSX.Element {
|
|
112
|
+
return (
|
|
113
|
+
<BaseToolbar.Group className={`lp-floating-toolbar-group ${className || ""}`} {...props} />
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// FloatingToolbar root
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export type FloatingToolbarProps = {
|
|
122
|
+
className?: string;
|
|
123
|
+
children?: ReactNode;
|
|
124
|
+
} & Omit<ComponentProps<typeof BaseToolbar.Root>, "children">;
|
|
125
|
+
|
|
126
|
+
function FloatingToolbarRoot({
|
|
127
|
+
className,
|
|
128
|
+
children,
|
|
129
|
+
style: propStyle,
|
|
130
|
+
}: FloatingToolbarProps): JSX.Element | null {
|
|
131
|
+
const position = useFloatingPosition();
|
|
132
|
+
const style = useMemo(() => {
|
|
133
|
+
if (!position) return propStyle;
|
|
134
|
+
return {
|
|
135
|
+
...propStyle,
|
|
136
|
+
position: "fixed",
|
|
137
|
+
top: position.top,
|
|
138
|
+
left: position.left,
|
|
139
|
+
transform: "translate(-50%, -100%)",
|
|
140
|
+
} satisfies React.CSSProperties;
|
|
141
|
+
}, [position, propStyle]);
|
|
142
|
+
|
|
143
|
+
if (!position) return null;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<BaseToolbar.Root className={`lp-floating-toolbar ${className || ""}`} style={style}>
|
|
147
|
+
{children}
|
|
148
|
+
</BaseToolbar.Root>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Namespace exports
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export {
|
|
157
|
+
FloatingToolbarRoot as Root,
|
|
158
|
+
FloatingToolbarButton as Button,
|
|
159
|
+
FloatingToolbarSeparator as Separator,
|
|
160
|
+
FloatingToolbarGroup as Group,
|
|
161
|
+
};
|
|
@@ -0,0 +1,354 @@
|
|
|
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 { EditorSelection } from "@codemirror/state";
|
|
12
|
+
import { Popover } from "@base-ui/react/popover";
|
|
13
|
+
import { Form } from "@base-ui/react/form";
|
|
14
|
+
import { Field } from "@base-ui/react/field";
|
|
15
|
+
import { useRender, type useRender as UseRender } from "@base-ui/react/use-render";
|
|
16
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
17
|
+
import { useEditorContext, useStateField, useVirtualAnchor } from "./context";
|
|
18
|
+
import { popoverField, closePopover } from "../core/popover";
|
|
19
|
+
import type { LinkRequest } from "../core/plugins/link";
|
|
20
|
+
import { insertLink } from "../core/commands";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Context
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
interface LinkPopoverContextValue {
|
|
27
|
+
label: string;
|
|
28
|
+
setLabel: (label: string) => void;
|
|
29
|
+
url: string;
|
|
30
|
+
setUrl: (url: string) => void;
|
|
31
|
+
isEditing: boolean;
|
|
32
|
+
onRemove: () => void;
|
|
33
|
+
onClose: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LinkPopoverCtx = createContext<LinkPopoverContextValue | null>(null);
|
|
37
|
+
|
|
38
|
+
function useLinkPopoverCtx() {
|
|
39
|
+
const ctx = useContext(LinkPopoverCtx);
|
|
40
|
+
if (!ctx) {
|
|
41
|
+
throw new Error("leepi: LinkPopover sub-components must be used inside LinkPopover.Root");
|
|
42
|
+
}
|
|
43
|
+
return ctx;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Sub-components
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
interface LabelInputProps extends UseRender.ComponentProps<"input"> {
|
|
51
|
+
autoFocus?: boolean;
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
children?: ReactNode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function LabelInput({
|
|
57
|
+
autoFocus,
|
|
58
|
+
placeholder,
|
|
59
|
+
render,
|
|
60
|
+
children,
|
|
61
|
+
...restProps
|
|
62
|
+
}: LabelInputProps): JSX.Element {
|
|
63
|
+
const { label, setLabel } = useLinkPopoverCtx();
|
|
64
|
+
const id = useId();
|
|
65
|
+
|
|
66
|
+
const element = useRender({
|
|
67
|
+
render,
|
|
68
|
+
defaultTagName: "input",
|
|
69
|
+
props: mergeProps<"input">(
|
|
70
|
+
{
|
|
71
|
+
id,
|
|
72
|
+
value: label,
|
|
73
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setLabel(e.target.value),
|
|
74
|
+
autoFocus,
|
|
75
|
+
placeholder,
|
|
76
|
+
className: "lp-popover-input",
|
|
77
|
+
},
|
|
78
|
+
restProps,
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Field.Root className="lp-popover-field">
|
|
84
|
+
<Field.Label htmlFor={id} className="lp-popover-label">
|
|
85
|
+
{children}
|
|
86
|
+
</Field.Label>
|
|
87
|
+
{element}
|
|
88
|
+
</Field.Root>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface UrlInputProps extends UseRender.ComponentProps<"input"> {
|
|
93
|
+
autoFocus?: boolean;
|
|
94
|
+
placeholder?: string;
|
|
95
|
+
children?: ReactNode;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function UrlInput({
|
|
99
|
+
autoFocus,
|
|
100
|
+
placeholder,
|
|
101
|
+
render,
|
|
102
|
+
children,
|
|
103
|
+
...restProps
|
|
104
|
+
}: UrlInputProps): JSX.Element {
|
|
105
|
+
const { url, setUrl } = useLinkPopoverCtx();
|
|
106
|
+
const id = useId();
|
|
107
|
+
|
|
108
|
+
const element = useRender({
|
|
109
|
+
render,
|
|
110
|
+
defaultTagName: "input",
|
|
111
|
+
props: mergeProps<"input">(
|
|
112
|
+
{
|
|
113
|
+
id,
|
|
114
|
+
type: "url",
|
|
115
|
+
value: url,
|
|
116
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value),
|
|
117
|
+
autoFocus,
|
|
118
|
+
placeholder,
|
|
119
|
+
className: "lp-popover-input",
|
|
120
|
+
},
|
|
121
|
+
restProps,
|
|
122
|
+
),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Field.Root className="lp-popover-field">
|
|
127
|
+
<Field.Label htmlFor={id} className="lp-popover-label">
|
|
128
|
+
{children}
|
|
129
|
+
</Field.Label>
|
|
130
|
+
{element}
|
|
131
|
+
</Field.Root>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function Actions({
|
|
136
|
+
cancelLabel,
|
|
137
|
+
submitLabel,
|
|
138
|
+
removeLabel,
|
|
139
|
+
}: {
|
|
140
|
+
cancelLabel?: ReactNode;
|
|
141
|
+
submitLabel?: ReactNode;
|
|
142
|
+
removeLabel?: ReactNode;
|
|
143
|
+
}): JSX.Element {
|
|
144
|
+
const { isEditing, url, onRemove, onClose } = useLinkPopoverCtx();
|
|
145
|
+
return (
|
|
146
|
+
<div className="lp-popover-actions">
|
|
147
|
+
{isEditing ? (
|
|
148
|
+
<button type="button" onClick={onRemove} className="lp-popover-btn lp-popover-btn--danger">
|
|
149
|
+
{removeLabel}
|
|
150
|
+
</button>
|
|
151
|
+
) : (
|
|
152
|
+
<span />
|
|
153
|
+
)}
|
|
154
|
+
<div className="lp-popover-actions-end">
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={onClose}
|
|
158
|
+
className="lp-popover-btn lp-popover-btn--secondary"
|
|
159
|
+
>
|
|
160
|
+
{cancelLabel}
|
|
161
|
+
</button>
|
|
162
|
+
<button
|
|
163
|
+
type="submit"
|
|
164
|
+
disabled={!url.trim()}
|
|
165
|
+
className="lp-popover-btn lp-popover-btn--primary"
|
|
166
|
+
>
|
|
167
|
+
{submitLabel}
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Content — form content without Popover wrapper
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
export interface LinkPopoverContentProps {
|
|
179
|
+
initialLabel?: string;
|
|
180
|
+
initialUrl?: string;
|
|
181
|
+
onSubmit: (label: string, url: string) => void;
|
|
182
|
+
onRemove: () => void;
|
|
183
|
+
onClose: () => void;
|
|
184
|
+
children: ReactNode;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function LinkPopoverContent({
|
|
188
|
+
initialLabel,
|
|
189
|
+
initialUrl,
|
|
190
|
+
onSubmit,
|
|
191
|
+
onRemove,
|
|
192
|
+
onClose,
|
|
193
|
+
children,
|
|
194
|
+
}: LinkPopoverContentProps): JSX.Element {
|
|
195
|
+
const [label, setLabel] = useState(initialLabel || "");
|
|
196
|
+
const [url, setUrl] = useState(initialUrl || "");
|
|
197
|
+
const isEditing = initialUrl !== "";
|
|
198
|
+
|
|
199
|
+
const handleSubmit = useCallback(
|
|
200
|
+
(e: React.SubmitEvent) => {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
if (!url.trim()) return;
|
|
203
|
+
onSubmit(label || url, url);
|
|
204
|
+
},
|
|
205
|
+
[label, url, onSubmit],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const ctxValue = useMemo(
|
|
209
|
+
() => ({ label, setLabel, url, setUrl, isEditing, onRemove, onClose }),
|
|
210
|
+
[label, url, isEditing, onRemove, onClose],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<LinkPopoverCtx value={ctxValue}>
|
|
215
|
+
<Form onSubmit={handleSubmit} className="lp-popover-form">
|
|
216
|
+
{children}
|
|
217
|
+
</Form>
|
|
218
|
+
</LinkPopoverCtx>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Root — standalone with its own Popover wrapper
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
export interface LinkPopoverProps extends LinkPopoverContentProps {
|
|
227
|
+
x: number;
|
|
228
|
+
y: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function LinkPopoverRoot({ x, y, onClose, ...contentProps }: LinkPopoverProps): JSX.Element {
|
|
232
|
+
const virtualAnchor = useMemo(
|
|
233
|
+
() => ({
|
|
234
|
+
getBoundingClientRect: () => ({
|
|
235
|
+
x,
|
|
236
|
+
y,
|
|
237
|
+
width: 0,
|
|
238
|
+
height: 0,
|
|
239
|
+
top: y,
|
|
240
|
+
left: x,
|
|
241
|
+
right: x,
|
|
242
|
+
bottom: y,
|
|
243
|
+
toJSON() {
|
|
244
|
+
return this;
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
}),
|
|
248
|
+
[x, y],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<Popover.Root
|
|
253
|
+
open
|
|
254
|
+
onOpenChange={(open) => {
|
|
255
|
+
if (!open) onClose();
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<Popover.Portal>
|
|
259
|
+
<Popover.Positioner
|
|
260
|
+
anchor={virtualAnchor}
|
|
261
|
+
positionMethod="fixed"
|
|
262
|
+
side="bottom"
|
|
263
|
+
align="start"
|
|
264
|
+
sideOffset={8}
|
|
265
|
+
className="lp-popover-positioner"
|
|
266
|
+
>
|
|
267
|
+
<Popover.Popup className="lp-popover">
|
|
268
|
+
<LinkPopoverContent onClose={onClose} {...contentProps} />
|
|
269
|
+
</Popover.Popup>
|
|
270
|
+
</Popover.Positioner>
|
|
271
|
+
</Popover.Portal>
|
|
272
|
+
</Popover.Root>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Connected — subscribes to StateField, owns its own Popover.Root
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
export function LinkPopoverConnected({ children }: { children: ReactNode }): JSX.Element {
|
|
281
|
+
const { view } = useEditorContext();
|
|
282
|
+
const popoverReq = useStateField(popoverField, null);
|
|
283
|
+
const linkReq = popoverReq?.type === "link" ? (popoverReq as LinkRequest) : null;
|
|
284
|
+
|
|
285
|
+
const handleSubmit = useCallback(
|
|
286
|
+
(label: string, url: string) => {
|
|
287
|
+
if (!view || !linkReq) return;
|
|
288
|
+
const { from, to } = linkReq.data;
|
|
289
|
+
insertLink(view, from, to, label, url);
|
|
290
|
+
view.dispatch({ effects: closePopover.of() });
|
|
291
|
+
},
|
|
292
|
+
[view, linkReq],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const handleRemove = useCallback(() => {
|
|
296
|
+
if (!view || !linkReq) return;
|
|
297
|
+
const { from, to, text } = linkReq.data;
|
|
298
|
+
view.dispatch({
|
|
299
|
+
changes: { from, to, insert: text },
|
|
300
|
+
selection: EditorSelection.cursor(from + text.length),
|
|
301
|
+
});
|
|
302
|
+
view.dispatch({ effects: closePopover.of() });
|
|
303
|
+
view.focus();
|
|
304
|
+
}, [view, linkReq]);
|
|
305
|
+
|
|
306
|
+
const handleClose = useCallback(() => {
|
|
307
|
+
if (!view) return;
|
|
308
|
+
view.dispatch({ effects: closePopover.of() });
|
|
309
|
+
view.focus();
|
|
310
|
+
}, [view]);
|
|
311
|
+
|
|
312
|
+
const virtualAnchor = useVirtualAnchor(linkReq?.data.from ?? null);
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<Popover.Root
|
|
316
|
+
open={!!linkReq}
|
|
317
|
+
onOpenChange={(open) => {
|
|
318
|
+
if (!open) handleClose();
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
<Popover.Portal>
|
|
322
|
+
<Popover.Positioner
|
|
323
|
+
anchor={virtualAnchor}
|
|
324
|
+
positionMethod="fixed"
|
|
325
|
+
side="bottom"
|
|
326
|
+
align="start"
|
|
327
|
+
sideOffset={8}
|
|
328
|
+
className="lp-popover-positioner"
|
|
329
|
+
>
|
|
330
|
+
<Popover.Popup className="lp-popover">
|
|
331
|
+
{linkReq && (
|
|
332
|
+
<LinkPopoverContent
|
|
333
|
+
key={`${linkReq.data.from}-${linkReq.data.to}`}
|
|
334
|
+
initialLabel={linkReq.data.text}
|
|
335
|
+
initialUrl={linkReq.data.url}
|
|
336
|
+
onSubmit={handleSubmit}
|
|
337
|
+
onRemove={handleRemove}
|
|
338
|
+
onClose={handleClose}
|
|
339
|
+
>
|
|
340
|
+
{children}
|
|
341
|
+
</LinkPopoverContent>
|
|
342
|
+
)}
|
|
343
|
+
</Popover.Popup>
|
|
344
|
+
</Popover.Positioner>
|
|
345
|
+
</Popover.Portal>
|
|
346
|
+
</Popover.Root>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Namespace exports
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
export { LinkPopoverRoot as Root, LinkPopoverContent as Content, LabelInput, UrlInput, Actions };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect, useState, type JSX } from "react";
|
|
2
|
+
import { Marked } from "marked";
|
|
3
|
+
import { markedHighlight } from "marked-highlight";
|
|
4
|
+
import { highlightCode } from "../core/highlight";
|
|
5
|
+
|
|
6
|
+
function parseFenceInfo(info: string): { lang: string; filename: string } {
|
|
7
|
+
const parts = info.trim().split(/\s+/);
|
|
8
|
+
let lang = "";
|
|
9
|
+
let filename = "";
|
|
10
|
+
for (const part of parts) {
|
|
11
|
+
if (part.startsWith("filename=")) {
|
|
12
|
+
filename = part.slice("filename=".length);
|
|
13
|
+
} else if (!lang) {
|
|
14
|
+
lang = part;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { lang, filename };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const renderer = {
|
|
21
|
+
code({ text, lang: rawLang }: { text: string; lang?: string }) {
|
|
22
|
+
const { lang, filename } = parseFenceInfo(rawLang || "");
|
|
23
|
+
let header = "";
|
|
24
|
+
if (filename || lang) {
|
|
25
|
+
const left = filename || lang;
|
|
26
|
+
const right = filename && lang ? `<span class="lp-code-header-lang">${lang}</span>` : "";
|
|
27
|
+
header = `<div class="lp-code-header"><span>${left}</span>${right}</div>`;
|
|
28
|
+
}
|
|
29
|
+
const lines = text.split("\n");
|
|
30
|
+
if (lines.at(-1) === "") lines.pop();
|
|
31
|
+
const rows = lines
|
|
32
|
+
.map(
|
|
33
|
+
(line, i) =>
|
|
34
|
+
`<span class="lp-code-row"><span class="lp-code-line-number">${i + 1}</span><span class="lp-code-line">${line || " "}</span></span>`,
|
|
35
|
+
)
|
|
36
|
+
.join("");
|
|
37
|
+
return `<div class="lp-code-block">${header}<pre><code class="language-${lang}">${rows}</code></pre></div>`;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const marked = new Marked(
|
|
42
|
+
{ gfm: true },
|
|
43
|
+
markedHighlight({
|
|
44
|
+
async: true,
|
|
45
|
+
langPrefix: "language-",
|
|
46
|
+
async highlight(code, lang) {
|
|
47
|
+
const actualLang = lang.split(/\s+/)[0];
|
|
48
|
+
if (!actualLang) return code;
|
|
49
|
+
return highlightCode(code, actualLang);
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
{ renderer },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export interface PreviewProps {
|
|
56
|
+
value: string;
|
|
57
|
+
className?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Preview({ value, className }: PreviewProps): JSX.Element {
|
|
61
|
+
const [html, setHtml] = useState("");
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!value) {
|
|
65
|
+
setHtml("");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let cancelled = false;
|
|
69
|
+
(marked.parse(value) as Promise<string>).then((result) => {
|
|
70
|
+
if (!cancelled) setHtml(result);
|
|
71
|
+
});
|
|
72
|
+
return () => {
|
|
73
|
+
cancelled = true;
|
|
74
|
+
};
|
|
75
|
+
}, [value]);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={`lp-preview ${className || ""}`} dangerouslySetInnerHTML={{ __html: html }} />
|
|
79
|
+
);
|
|
80
|
+
}
|