@lalternative/editor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * RichNode is one node of a TipTap document.
3
+ *
4
+ * The index signature is what lets a document cross the boundary with an
5
+ * editor handle typed as a plain object, which is how TipTap hands its JSON
6
+ * back. Without it every call site pays for the mismatch in casts.
7
+ */
8
+ interface RichNode {
9
+ type?: string;
10
+ attrs?: Record<string, unknown>;
11
+ content?: RichNode[];
12
+ text?: string;
13
+ [key: string]: unknown;
14
+ }
15
+ type RichDoc = RichNode;
16
+ interface BlockShape {
17
+ type: "paragraph" | "heading";
18
+ level?: number;
19
+ }
20
+ /**
21
+ * blocksFromDoc flattens a TipTap document to the text of its top-level nodes.
22
+ *
23
+ * Marks are dropped rather than encoded: the diff compares prose, and a bold
24
+ * span that moved inside an otherwise identical sentence is not a change worth
25
+ * asking the reviewer about.
26
+ */
27
+ declare function blocksFromDoc(doc: RichDoc): string[];
28
+ /** shapesFromDoc records what each block was, so a revision can be rebuilt
29
+ * without turning every heading into a paragraph. */
30
+ declare function shapesFromDoc(doc: RichDoc): BlockShape[];
31
+ declare function docFromBlocks(blocks: string[], shapes?: BlockShape[]): RichDoc;
32
+
33
+ type ChangeKind = "keep" | "insert" | "delete" | "replace";
34
+ interface BlockChange {
35
+ id: string;
36
+ kind: ChangeKind;
37
+ before: string;
38
+ after: string;
39
+ }
40
+ /**
41
+ * diffBlocks compares two documents one block at a time.
42
+ *
43
+ * The block is the unit because it is the unit a reviewer accepts or rejects:
44
+ * a finer diff turns one editorial decision into a dozen clicks, and a coarser
45
+ * one makes rejecting a single sentence cost the whole section.
46
+ *
47
+ * Accepting every change rebuilds `after` exactly; rejecting every change
48
+ * rebuilds `before` exactly. The UI relies on both.
49
+ */
50
+ declare function diffBlocks(before: string[], after: string[]): BlockChange[];
51
+
52
+ export { type BlockChange as B, type ChangeKind as C, type RichDoc as R, type BlockShape as a, type RichNode as b, blocksFromDoc as c, diffBlocks as d, docFromBlocks as e, shapesFromDoc as s };
package/dist/editor.css CHANGED
@@ -5,6 +5,99 @@
5
5
  --lalt-editor-insert-bg: rgb(29 78 216 / 0.08);
6
6
  --lalt-editor-delete: #b91c1c;
7
7
  --lalt-editor-delete-bg: rgb(185 28 28 / 0.06);
8
+ --lalt-editor-surface: #ffffff;
9
+ --lalt-editor-border: rgb(0 0 0 / 0.1);
10
+ --lalt-editor-hover: rgb(0 0 0 / 0.06);
11
+ }
12
+
13
+ /* Defaults only: a host that owns a theme overrides these on :root and never
14
+ reaches this block. Without it the toolbar is a white slab on a dark page. */
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --lalt-editor-insert: #93c5fd;
18
+ --lalt-editor-insert-bg: rgb(147 197 253 / 0.12);
19
+ --lalt-editor-delete: #fca5a5;
20
+ --lalt-editor-delete-bg: rgb(252 165 165 / 0.1);
21
+ --lalt-editor-surface: #1f2937;
22
+ --lalt-editor-border: rgb(255 255 255 / 0.14);
23
+ --lalt-editor-hover: rgb(255 255 255 / 0.08);
24
+ }
25
+ }
26
+
27
+ .lalt-toolbar {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 0.125rem;
31
+ padding: 0.25rem;
32
+ border-radius: 0.5rem;
33
+ background: var(--lalt-editor-surface, #ffffff);
34
+ border: 1px solid var(--lalt-editor-border, rgb(0 0 0 / 0.1));
35
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.12);
36
+ user-select: none;
37
+ }
38
+
39
+ .lalt-toolbar__button {
40
+ display: inline-flex;
41
+ align-items: center;
42
+ gap: 0.375rem;
43
+ padding: 0.25rem 0.5rem;
44
+ border-radius: 0.375rem;
45
+ background: none;
46
+ border: none;
47
+ font: inherit;
48
+ font-size: 0.875rem;
49
+ cursor: pointer;
50
+ color: inherit;
51
+ }
52
+
53
+ .lalt-toolbar__button:hover {
54
+ background: var(--lalt-editor-hover, rgb(0 0 0 / 0.06));
55
+ }
56
+
57
+ .lalt-toolbar__formats {
58
+ position: relative;
59
+ }
60
+
61
+ .lalt-toolbar__menu {
62
+ position: absolute;
63
+ top: calc(100% + 0.375rem);
64
+ right: 0;
65
+ min-width: 14rem;
66
+ padding: 0.25rem;
67
+ border-radius: 0.5rem;
68
+ background: var(--lalt-editor-surface, #ffffff);
69
+ border: 1px solid var(--lalt-editor-border, rgb(0 0 0 / 0.1));
70
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
71
+ }
72
+
73
+ .lalt-toolbar__item {
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: space-between;
77
+ gap: 1.5rem;
78
+ width: 100%;
79
+ padding: 0.375rem 0.5rem;
80
+ border-radius: 0.375rem;
81
+ background: none;
82
+ border: none;
83
+ font: inherit;
84
+ font-size: 0.875rem;
85
+ text-align: left;
86
+ cursor: pointer;
87
+ color: inherit;
88
+ }
89
+
90
+ .lalt-toolbar__item:hover {
91
+ background: var(--lalt-editor-hover, rgb(0 0 0 / 0.06));
92
+ }
93
+
94
+ .lalt-toolbar__item[aria-current="true"] {
95
+ font-weight: 600;
96
+ }
97
+
98
+ .lalt-toolbar__shortcut {
99
+ font-size: 0.75rem;
100
+ opacity: 0.5;
8
101
  }
9
102
 
10
103
  .lalt-revision {
package/dist/index.d.ts CHANGED
@@ -1,52 +1,92 @@
1
+ export { B as BlockChange, a as BlockShape, C as ChangeKind, R as RichDoc, b as RichNode, c as blocksFromDoc, d as diffBlocks, e as docFromBlocks, s as shapesFromDoc } from './diff-uFiP8hD5.js';
2
+ import * as react from 'react';
3
+ import { Editor } from '@tiptap/react';
4
+
5
+ interface SelectionRect {
6
+ /** Horizontal centre of the selection. */
7
+ x: number;
8
+ top: number;
9
+ bottom: number;
10
+ }
11
+ interface Size {
12
+ width: number;
13
+ height: number;
14
+ }
15
+ interface PlaceToolbarInput {
16
+ selection: SelectionRect;
17
+ toolbar: Size;
18
+ viewport: Size;
19
+ /** iOS renders its own callout above a selection, which covers a toolbar
20
+ * placed there, so the caller asks for below on that platform. */
21
+ preferBelow?: boolean;
22
+ }
23
+ interface ToolbarPlacement {
24
+ left: number;
25
+ top: number;
26
+ below: boolean;
27
+ }
1
28
  /**
2
- * RichNode is one node of a TipTap document.
29
+ * placeToolbar positions a floating toolbar against a selection.
3
30
  *
4
- * The index signature is what lets a document cross the boundary with an
5
- * editor handle typed as a plain object, which is how TipTap hands its JSON
6
- * back. Without it every call site pays for the mismatch in casts.
31
+ * Kept apart from the component because it is the part that is wrong in subtle
32
+ * ways — off-screen at the edges, hidden under a system callout — and the part
33
+ * a test can pin down without a browser.
7
34
  */
8
- interface RichNode {
9
- type?: string;
10
- attrs?: Record<string, unknown>;
11
- content?: RichNode[];
12
- text?: string;
13
- [key: string]: unknown;
14
- }
15
- type RichDoc = RichNode;
16
- interface BlockShape {
17
- type: "paragraph" | "heading";
18
- level?: number;
35
+ declare function placeToolbar({ selection, toolbar, viewport, preferBelow, }: PlaceToolbarInput): ToolbarPlacement;
36
+
37
+ interface ToolbarAction {
38
+ id: string;
39
+ label: string;
40
+ icon?: React.ReactNode;
41
+ onSelect: (selectedText: string) => void;
42
+ }
43
+ interface BlockFormat {
44
+ id: string;
45
+ label: string;
46
+ shortcut?: string;
47
+ isActive?: boolean;
48
+ onSelect: () => void;
49
+ }
50
+ interface SelectionToolbarProps {
51
+ selectedText: string;
52
+ selection: SelectionRect;
53
+ actions?: ToolbarAction[];
54
+ /** Heading levels and list kinds, rendered behind a single trigger. */
55
+ formats?: BlockFormat[];
56
+ formatsLabel?: string;
57
+ /** iOS draws its own callout above a selection; the host detects it. */
58
+ preferBelow?: boolean;
59
+ container?: HTMLElement | null;
60
+ }
61
+ declare function SelectionToolbar({ selectedText, selection, actions, formats, formatsLabel, preferBelow, container, }: SelectionToolbarProps): react.ReactPortal | null;
62
+
63
+ interface SelectionInfo {
64
+ text: string;
65
+ rect: SelectionRect;
19
66
  }
20
67
  /**
21
- * blocksFromDoc flattens a TipTap document to the text of its top-level nodes.
68
+ * useSelectionToolbar reports the current non-empty selection of an editor.
22
69
  *
23
- * Marks are dropped rather than encoded: the diff compares prose, and a bold
24
- * span that moved inside an otherwise identical sentence is not a change worth
25
- * asking the reviewer about.
70
+ * Returns null whenever there is nothing to act on, so the caller renders the
71
+ * toolbar only while it has a target.
26
72
  */
27
- declare function blocksFromDoc(doc: RichDoc): string[];
28
- /** shapesFromDoc records what each block was, so a revision can be rebuilt
29
- * without turning every heading into a paragraph. */
30
- declare function shapesFromDoc(doc: RichDoc): BlockShape[];
31
- declare function docFromBlocks(blocks: string[], shapes?: BlockShape[]): RichDoc;
73
+ declare function useSelectionToolbar(editor: Editor | null): SelectionInfo | null;
32
74
 
33
- type ChangeKind = "keep" | "insert" | "delete" | "replace";
34
- interface BlockChange {
35
- id: string;
36
- kind: ChangeKind;
37
- before: string;
38
- after: string;
75
+ interface FormatLabels {
76
+ paragraph: string;
77
+ heading1: string;
78
+ heading2: string;
79
+ heading3: string;
80
+ orderedList: string;
81
+ bulletList: string;
39
82
  }
83
+ declare const defaultFormatLabels: FormatLabels;
40
84
  /**
41
- * diffBlocks compares two documents one block at a time.
42
- *
43
- * The block is the unit because it is the unit a reviewer accepts or rejects:
44
- * a finer diff turns one editorial decision into a dozen clicks, and a coarser
45
- * one makes rejecting a single sentence cost the whole section.
85
+ * blockFormats is the set a writer reaches for on a selected passage.
46
86
  *
47
- * Accepting every change rebuilds `after` exactly; rejecting every change
48
- * rebuilds `before` exactly. The UI relies on both.
87
+ * Labels are passed in rather than translated here: the package carries no
88
+ * i18n runtime, so the host names things in whatever language it speaks.
49
89
  */
50
- declare function diffBlocks(before: string[], after: string[]): BlockChange[];
90
+ declare function blockFormats(editor: Editor | null, labels?: FormatLabels): BlockFormat[];
51
91
 
52
- export { type BlockChange, type BlockShape, type ChangeKind, type RichDoc, type RichNode, blocksFromDoc, diffBlocks, docFromBlocks, shapesFromDoc };
92
+ export { type BlockFormat, type FormatLabels, type PlaceToolbarInput, type SelectionInfo, type SelectionRect, SelectionToolbar, type SelectionToolbarProps, type ToolbarAction, type ToolbarPlacement, blockFormats, defaultFormatLabels, placeToolbar, useSelectionToolbar };
package/dist/index.js CHANGED
@@ -4,10 +4,218 @@ import {
4
4
  docFromBlocks,
5
5
  shapesFromDoc
6
6
  } from "./chunk-7GOSVZTI.js";
7
+
8
+ // src/placement.ts
9
+ var GAP = 8;
10
+ function placeToolbar({
11
+ selection,
12
+ toolbar,
13
+ viewport,
14
+ preferBelow = false
15
+ }) {
16
+ const fitsAbove = selection.top - GAP - toolbar.height >= 0;
17
+ const fitsBelow = selection.bottom + GAP + toolbar.height <= viewport.height;
18
+ const below = preferBelow ? fitsBelow || !fitsAbove : !fitsAbove && fitsBelow;
19
+ const top = below ? selection.bottom + GAP : Math.max(0, selection.top - GAP - toolbar.height);
20
+ const centred = selection.x - toolbar.width / 2;
21
+ const left = Math.max(0, Math.min(centred, viewport.width - toolbar.width));
22
+ return { left, top, below };
23
+ }
24
+
25
+ // src/SelectionToolbar.tsx
26
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
27
+ import { createPortal } from "react-dom";
28
+ import { jsx, jsxs } from "react/jsx-runtime";
29
+ var FALLBACK = { width: 320, height: 40 };
30
+ function SelectionToolbar({
31
+ selectedText,
32
+ selection,
33
+ actions = [],
34
+ formats = [],
35
+ formatsLabel = "Format",
36
+ preferBelow = false,
37
+ container
38
+ }) {
39
+ const ref = useRef(null);
40
+ const [size, setSize] = useState(FALLBACK);
41
+ const [formatsOpen, setFormatsOpen] = useState(false);
42
+ useLayoutEffect(() => {
43
+ const el = ref.current;
44
+ if (!el) return;
45
+ const rect = el.getBoundingClientRect();
46
+ if (rect.width > 0) setSize({ width: rect.width, height: rect.height });
47
+ }, [selectedText, actions.length, formats.length]);
48
+ useEffect(() => {
49
+ if (!formatsOpen) return;
50
+ const close = (e) => {
51
+ if (ref.current && !ref.current.contains(e.target)) setFormatsOpen(false);
52
+ };
53
+ document.addEventListener("mousedown", close);
54
+ return () => document.removeEventListener("mousedown", close);
55
+ }, [formatsOpen]);
56
+ if (typeof document === "undefined") return null;
57
+ const place = placeToolbar({
58
+ selection,
59
+ toolbar: size,
60
+ viewport: { width: window.innerWidth, height: window.innerHeight },
61
+ preferBelow
62
+ });
63
+ return createPortal(
64
+ /* @__PURE__ */ jsxs(
65
+ "div",
66
+ {
67
+ ref,
68
+ role: "toolbar",
69
+ className: "lalt-toolbar",
70
+ style: { position: "fixed", left: place.left, top: place.top, zIndex: 100 },
71
+ children: [
72
+ actions.map((action) => /* @__PURE__ */ jsxs(
73
+ "button",
74
+ {
75
+ type: "button",
76
+ className: "lalt-toolbar__button",
77
+ onMouseDown: (e) => e.preventDefault(),
78
+ onClick: () => action.onSelect(selectedText),
79
+ children: [
80
+ action.icon,
81
+ /* @__PURE__ */ jsx("span", { children: action.label })
82
+ ]
83
+ },
84
+ action.id
85
+ )),
86
+ formats.length > 0 && /* @__PURE__ */ jsxs("div", { className: "lalt-toolbar__formats", children: [
87
+ /* @__PURE__ */ jsx(
88
+ "button",
89
+ {
90
+ type: "button",
91
+ className: "lalt-toolbar__button",
92
+ "aria-haspopup": "menu",
93
+ "aria-expanded": formatsOpen,
94
+ onMouseDown: (e) => e.preventDefault(),
95
+ onClick: () => setFormatsOpen((open) => !open),
96
+ children: formatsLabel
97
+ }
98
+ ),
99
+ formatsOpen && /* @__PURE__ */ jsx("div", { role: "menu", className: "lalt-toolbar__menu", children: formats.map((format) => /* @__PURE__ */ jsxs(
100
+ "button",
101
+ {
102
+ type: "button",
103
+ role: "menuitem",
104
+ className: "lalt-toolbar__item",
105
+ "aria-current": format.isActive,
106
+ onMouseDown: (e) => e.preventDefault(),
107
+ onClick: () => {
108
+ format.onSelect();
109
+ setFormatsOpen(false);
110
+ },
111
+ children: [
112
+ /* @__PURE__ */ jsx("span", { children: format.label }),
113
+ format.shortcut && /* @__PURE__ */ jsx("kbd", { className: "lalt-toolbar__shortcut", children: format.shortcut })
114
+ ]
115
+ },
116
+ format.id
117
+ )) })
118
+ ] })
119
+ ]
120
+ }
121
+ ),
122
+ container ?? document.body
123
+ );
124
+ }
125
+
126
+ // src/useSelectionToolbar.ts
127
+ import { useEffect as useEffect2, useState as useState2 } from "react";
128
+ function useSelectionToolbar(editor) {
129
+ const [selection, setSelection] = useState2(null);
130
+ useEffect2(() => {
131
+ if (!editor) return;
132
+ const read = () => {
133
+ const { state } = editor;
134
+ const { from, to, empty } = state.selection;
135
+ if (empty || !editor.isFocused) {
136
+ setSelection(null);
137
+ return;
138
+ }
139
+ const text = state.doc.textBetween(from, to, " ").trim();
140
+ if (text === "") {
141
+ setSelection(null);
142
+ return;
143
+ }
144
+ const start = editor.view.coordsAtPos(from);
145
+ const end = editor.view.coordsAtPos(to);
146
+ setSelection({
147
+ text,
148
+ rect: {
149
+ x: (start.left + end.right) / 2,
150
+ top: Math.min(start.top, end.top),
151
+ bottom: Math.max(start.bottom, end.bottom)
152
+ }
153
+ });
154
+ };
155
+ read();
156
+ editor.on("selectionUpdate", read);
157
+ editor.on("blur", read);
158
+ editor.on("focus", read);
159
+ return () => {
160
+ editor.off("selectionUpdate", read);
161
+ editor.off("blur", read);
162
+ editor.off("focus", read);
163
+ };
164
+ }, [editor]);
165
+ return selection;
166
+ }
167
+
168
+ // src/formats.ts
169
+ var defaultFormatLabels = {
170
+ paragraph: "Texte",
171
+ heading1: "Titre 1",
172
+ heading2: "Titre 2",
173
+ heading3: "Titre 3",
174
+ orderedList: "Liste num\xE9rot\xE9e",
175
+ bulletList: "Liste \xE0 puces"
176
+ };
177
+ function blockFormats(editor, labels = defaultFormatLabels) {
178
+ if (!editor) return [];
179
+ return [
180
+ {
181
+ id: "paragraph",
182
+ label: labels.paragraph,
183
+ shortcut: "Ctrl+Alt+0",
184
+ isActive: editor.isActive("paragraph"),
185
+ onSelect: () => editor.chain().focus().setParagraph().run()
186
+ },
187
+ ...[1, 2, 3].map((level) => ({
188
+ id: `heading${level}`,
189
+ label: labels[`heading${level}`],
190
+ shortcut: `Ctrl+Alt+${level}`,
191
+ isActive: editor.isActive("heading", { level }),
192
+ onSelect: () => editor.chain().focus().toggleHeading({ level }).run()
193
+ })),
194
+ {
195
+ id: "orderedList",
196
+ label: labels.orderedList,
197
+ shortcut: "Ctrl+Alt+4",
198
+ isActive: editor.isActive("orderedList"),
199
+ onSelect: () => editor.chain().focus().toggleOrderedList().run()
200
+ },
201
+ {
202
+ id: "bulletList",
203
+ label: labels.bulletList,
204
+ shortcut: "Ctrl+Alt+5",
205
+ isActive: editor.isActive("bulletList"),
206
+ onSelect: () => editor.chain().focus().toggleBulletList().run()
207
+ }
208
+ ];
209
+ }
7
210
  export {
211
+ SelectionToolbar,
212
+ blockFormats,
8
213
  blocksFromDoc,
214
+ defaultFormatLabels,
9
215
  diffBlocks,
10
216
  docFromBlocks,
11
- shapesFromDoc
217
+ placeToolbar,
218
+ shapesFromDoc,
219
+ useSelectionToolbar
12
220
  };
13
221
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
1
+ {"version":3,"sources":["../src/placement.ts","../src/SelectionToolbar.tsx","../src/useSelectionToolbar.ts","../src/formats.ts"],"sourcesContent":["export interface SelectionRect {\n /** Horizontal centre of the selection. */\n x: number;\n top: number;\n bottom: number;\n}\n\nexport interface Size {\n width: number;\n height: number;\n}\n\nexport interface PlaceToolbarInput {\n selection: SelectionRect;\n toolbar: Size;\n viewport: Size;\n /** iOS renders its own callout above a selection, which covers a toolbar\n * placed there, so the caller asks for below on that platform. */\n preferBelow?: boolean;\n}\n\nexport interface ToolbarPlacement {\n left: number;\n top: number;\n below: boolean;\n}\n\nconst GAP = 8;\n\n/**\n * placeToolbar positions a floating toolbar against a selection.\n *\n * Kept apart from the component because it is the part that is wrong in subtle\n * ways — off-screen at the edges, hidden under a system callout — and the part\n * a test can pin down without a browser.\n */\nexport function placeToolbar({\n selection,\n toolbar,\n viewport,\n preferBelow = false,\n}: PlaceToolbarInput): ToolbarPlacement {\n const fitsAbove = selection.top - GAP - toolbar.height >= 0;\n const fitsBelow = selection.bottom + GAP + toolbar.height <= viewport.height;\n\n const below = preferBelow ? fitsBelow || !fitsAbove : !fitsAbove && fitsBelow;\n\n const top = below\n ? selection.bottom + GAP\n : Math.max(0, selection.top - GAP - toolbar.height);\n\n const centred = selection.x - toolbar.width / 2;\n const left = Math.max(0, Math.min(centred, viewport.width - toolbar.width));\n\n return { left, top, below };\n}\n","import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\n\nimport { placeToolbar, type SelectionRect } from \"./placement\";\n\nexport interface ToolbarAction {\n id: string;\n label: string;\n icon?: React.ReactNode;\n onSelect: (selectedText: string) => void;\n}\n\nexport interface BlockFormat {\n id: string;\n label: string;\n shortcut?: string;\n isActive?: boolean;\n onSelect: () => void;\n}\n\nexport interface SelectionToolbarProps {\n selectedText: string;\n selection: SelectionRect;\n actions?: ToolbarAction[];\n /** Heading levels and list kinds, rendered behind a single trigger. */\n formats?: BlockFormat[];\n formatsLabel?: string;\n /** iOS draws its own callout above a selection; the host detects it. */\n preferBelow?: boolean;\n container?: HTMLElement | null;\n}\n\nconst FALLBACK = { width: 320, height: 40 };\n\nexport function SelectionToolbar({\n selectedText,\n selection,\n actions = [],\n formats = [],\n formatsLabel = \"Format\",\n preferBelow = false,\n container,\n}: SelectionToolbarProps) {\n const ref = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(FALLBACK);\n const [formatsOpen, setFormatsOpen] = useState(false);\n\n // Measured after paint rather than assumed: the toolbar's width depends on\n // labels the host supplies, and guessing it puts the toolbar off-centre.\n useLayoutEffect(() => {\n const el = ref.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width > 0) setSize({ width: rect.width, height: rect.height });\n }, [selectedText, actions.length, formats.length]);\n\n useEffect(() => {\n if (!formatsOpen) return;\n const close = (e: MouseEvent) => {\n if (ref.current && !ref.current.contains(e.target as Node)) setFormatsOpen(false);\n };\n document.addEventListener(\"mousedown\", close);\n return () => document.removeEventListener(\"mousedown\", close);\n }, [formatsOpen]);\n\n if (typeof document === \"undefined\") return null;\n\n const place = placeToolbar({\n selection,\n toolbar: size,\n viewport: { width: window.innerWidth, height: window.innerHeight },\n preferBelow,\n });\n\n return createPortal(\n <div\n ref={ref}\n role=\"toolbar\"\n className=\"lalt-toolbar\"\n style={{ position: \"fixed\", left: place.left, top: place.top, zIndex: 100 }}\n >\n {actions.map((action) => (\n <button\n key={action.id}\n type=\"button\"\n className=\"lalt-toolbar__button\"\n onMouseDown={(e) => e.preventDefault()}\n onClick={() => action.onSelect(selectedText)}\n >\n {action.icon}\n <span>{action.label}</span>\n </button>\n ))}\n\n {formats.length > 0 && (\n <div className=\"lalt-toolbar__formats\">\n <button\n type=\"button\"\n className=\"lalt-toolbar__button\"\n aria-haspopup=\"menu\"\n aria-expanded={formatsOpen}\n onMouseDown={(e) => e.preventDefault()}\n onClick={() => setFormatsOpen((open) => !open)}\n >\n {formatsLabel}\n </button>\n {formatsOpen && (\n <div role=\"menu\" className=\"lalt-toolbar__menu\">\n {formats.map((format) => (\n <button\n key={format.id}\n type=\"button\"\n role=\"menuitem\"\n className=\"lalt-toolbar__item\"\n aria-current={format.isActive}\n onMouseDown={(e) => e.preventDefault()}\n onClick={() => {\n format.onSelect();\n setFormatsOpen(false);\n }}\n >\n <span>{format.label}</span>\n {format.shortcut && (\n <kbd className=\"lalt-toolbar__shortcut\">{format.shortcut}</kbd>\n )}\n </button>\n ))}\n </div>\n )}\n </div>\n )}\n </div>,\n container ?? document.body,\n );\n}\n","import { useEffect, useState } from \"react\";\nimport type { Editor } from \"@tiptap/react\";\n\nimport type { SelectionRect } from \"./placement\";\n\nexport interface SelectionInfo {\n text: string;\n rect: SelectionRect;\n}\n\n/**\n * useSelectionToolbar reports the current non-empty selection of an editor.\n *\n * Returns null whenever there is nothing to act on, so the caller renders the\n * toolbar only while it has a target.\n */\nexport function useSelectionToolbar(editor: Editor | null): SelectionInfo | null {\n const [selection, setSelection] = useState<SelectionInfo | null>(null);\n\n useEffect(() => {\n if (!editor) return;\n\n const read = () => {\n const { state } = editor;\n const { from, to, empty } = state.selection;\n if (empty || !editor.isFocused) {\n setSelection(null);\n return;\n }\n\n const text = state.doc.textBetween(from, to, \" \").trim();\n if (text === \"\") {\n setSelection(null);\n return;\n }\n\n const start = editor.view.coordsAtPos(from);\n const end = editor.view.coordsAtPos(to);\n setSelection({\n text,\n rect: {\n x: (start.left + end.right) / 2,\n top: Math.min(start.top, end.top),\n bottom: Math.max(start.bottom, end.bottom),\n },\n });\n };\n\n read();\n editor.on(\"selectionUpdate\", read);\n editor.on(\"blur\", read);\n editor.on(\"focus\", read);\n return () => {\n editor.off(\"selectionUpdate\", read);\n editor.off(\"blur\", read);\n editor.off(\"focus\", read);\n };\n }, [editor]);\n\n return selection;\n}\n","import type { Editor } from \"@tiptap/react\";\n\n// Type-only: these commands are contributed by StarterKit's extensions, and\n// without the reference TypeScript does not know they exist on a chain. No\n// runtime import, so the host stays in charge of which extensions it mounts —\n// blockFormats simply returns entries whose commands no-op if one is missing.\nimport type {} from \"@tiptap/starter-kit\";\n\nimport type { BlockFormat } from \"./SelectionToolbar\";\n\nexport interface FormatLabels {\n paragraph: string;\n heading1: string;\n heading2: string;\n heading3: string;\n orderedList: string;\n bulletList: string;\n}\n\nexport const defaultFormatLabels: FormatLabels = {\n paragraph: \"Texte\",\n heading1: \"Titre 1\",\n heading2: \"Titre 2\",\n heading3: \"Titre 3\",\n orderedList: \"Liste numérotée\",\n bulletList: \"Liste à puces\",\n};\n\n/**\n * blockFormats is the set a writer reaches for on a selected passage.\n *\n * Labels are passed in rather than translated here: the package carries no\n * i18n runtime, so the host names things in whatever language it speaks.\n */\nexport function blockFormats(\n editor: Editor | null,\n labels: FormatLabels = defaultFormatLabels,\n): BlockFormat[] {\n if (!editor) return [];\n\n return [\n {\n id: \"paragraph\",\n label: labels.paragraph,\n shortcut: \"Ctrl+Alt+0\",\n isActive: editor.isActive(\"paragraph\"),\n onSelect: () => editor.chain().focus().setParagraph().run(),\n },\n ...([1, 2, 3] as const).map((level) => ({\n id: `heading${level}`,\n label: labels[`heading${level}` as keyof FormatLabels],\n shortcut: `Ctrl+Alt+${level}`,\n isActive: editor.isActive(\"heading\", { level }),\n onSelect: () => editor.chain().focus().toggleHeading({ level }).run(),\n })),\n {\n id: \"orderedList\",\n label: labels.orderedList,\n shortcut: \"Ctrl+Alt+4\",\n isActive: editor.isActive(\"orderedList\"),\n onSelect: () => editor.chain().focus().toggleOrderedList().run(),\n },\n {\n id: \"bulletList\",\n label: labels.bulletList,\n shortcut: \"Ctrl+Alt+5\",\n isActive: editor.isActive(\"bulletList\"),\n onSelect: () => editor.chain().focus().toggleBulletList().run(),\n },\n ];\n}\n"],"mappings":"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","names":["useEffect","useState"]}
@@ -1,5 +1,5 @@
1
- import { BlockChange, RichDoc } from './index.js';
2
- export { ChangeKind } from './index.js';
1
+ import { B as BlockChange, R as RichDoc } from './diff-uFiP8hD5.js';
2
+ export { C as ChangeKind } from './diff-uFiP8hD5.js';
3
3
 
4
4
  type Decision = "pending" | "accepted" | "rejected";
5
5
  interface RevisionState {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalternative/editor",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shared TipTap editor for L'Alternative apps: rich text, format menu, selection toolbar and inline AI revisions",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,5 +54,10 @@
54
54
  "type": "git",
55
55
  "url": "https://github.com/lalternative/packages.git",
56
56
  "directory": "editor"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@tiptap/starter-kit": {
60
+ "optional": true
61
+ }
57
62
  }
58
63
  }