@lalternative/editor 0.1.0 → 0.4.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,179 @@
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-text: #111827;
10
+ --lalt-editor-border: rgb(0 0 0 / 0.1);
11
+ --lalt-editor-hover: rgb(0 0 0 / 0.06);
12
+ }
13
+
14
+ /* Defaults only, and only for a host with no theme of its own: an app that
15
+ toggles dark by class must override these tokens instead, or the toolbar
16
+ follows the system while the page follows the class. */
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --lalt-editor-insert: #93c5fd;
20
+ --lalt-editor-insert-bg: rgb(147 197 253 / 0.12);
21
+ --lalt-editor-delete: #fca5a5;
22
+ --lalt-editor-delete-bg: rgb(252 165 165 / 0.1);
23
+ --lalt-editor-surface: #1f2937;
24
+ --lalt-editor-text: #f3f4f6;
25
+ --lalt-editor-border: rgb(255 255 255 / 0.14);
26
+ --lalt-editor-hover: rgb(255 255 255 / 0.08);
27
+ }
28
+ }
29
+
30
+ /* Docked to the bottom of the screen by default, floating beside the selection
31
+ only where there is a pointer and room for it.
32
+
33
+ A bar floating next to a selection is the worst place for it on a phone: the
34
+ finger that made the selection covers it, and iOS draws its own callout in
35
+ the same spot. Bottom-docked also puts it in thumb reach — and unlike a
36
+ floating bar, it cannot be pushed off-screen by a selection near an edge. */
37
+ .lalt-toolbar {
38
+ position: fixed;
39
+ left: 0;
40
+ right: 0;
41
+ bottom: 0;
42
+ z-index: 100;
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 0.25rem;
46
+ padding: 0.5rem;
47
+ padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
48
+ background: var(--lalt-editor-surface, #ffffff);
49
+ border-top: 1px solid var(--lalt-editor-border, rgb(0 0 0 / 0.1));
50
+ box-shadow: 0 -4px 16px rgb(0 0 0 / 0.12);
51
+ color: var(--lalt-editor-text, #111827);
52
+ user-select: none;
53
+ overflow-x: auto;
54
+ }
55
+
56
+ @media (min-width: 640px) {
57
+ .lalt-toolbar {
58
+ left: var(--lalt-toolbar-left, 0);
59
+ top: var(--lalt-toolbar-top, 0);
60
+ right: auto;
61
+ bottom: auto;
62
+ gap: 0.125rem;
63
+ padding: 0.25rem;
64
+ padding-bottom: 0.25rem;
65
+ border: 1px solid var(--lalt-editor-border, rgb(0 0 0 / 0.1));
66
+ border-radius: 0.5rem;
67
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.12);
68
+ overflow-x: visible;
69
+ }
70
+ }
71
+
72
+ .lalt-toolbar__button {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ gap: 0.375rem;
77
+ /* 44px: what a finger needs. Shrinks to a compact control only where a
78
+ pointer is doing the aiming. */
79
+ min-height: 2.75rem;
80
+ padding: 0.5rem 0.75rem;
81
+ border-radius: 0.375rem;
82
+ background: none;
83
+ border: none;
84
+ font: inherit;
85
+ font-size: 0.875rem;
86
+ white-space: nowrap;
87
+ cursor: pointer;
88
+ color: inherit;
89
+ }
90
+
91
+ @media (min-width: 640px) {
92
+ .lalt-toolbar__button {
93
+ min-height: 0;
94
+ padding: 0.25rem 0.5rem;
95
+ }
96
+ }
97
+
98
+ .lalt-toolbar__button:hover {
99
+ background: var(--lalt-editor-hover, rgb(0 0 0 / 0.06));
100
+ }
101
+
102
+ .lalt-toolbar__formats {
103
+ position: relative;
104
+ }
105
+
106
+ /* Opens upward from a bottom-docked bar, full width: a menu anchored below it
107
+ would open off the bottom of the screen. */
108
+ .lalt-toolbar__menu {
109
+ position: fixed;
110
+ left: 0;
111
+ right: 0;
112
+ bottom: 100%;
113
+ max-height: 60vh;
114
+ overflow-y: auto;
115
+ padding: 0.25rem;
116
+ background: var(--lalt-editor-surface, #ffffff);
117
+ border-top: 1px solid var(--lalt-editor-border, rgb(0 0 0 / 0.1));
118
+ box-shadow: 0 -8px 24px rgb(0 0 0 / 0.16);
119
+ color: var(--lalt-editor-text, #111827);
120
+ }
121
+
122
+ @media (min-width: 640px) {
123
+ .lalt-toolbar__menu {
124
+ position: absolute;
125
+ left: auto;
126
+ right: 0;
127
+ bottom: auto;
128
+ top: calc(100% + 0.375rem);
129
+ min-width: 14rem;
130
+ border: 1px solid var(--lalt-editor-border, rgb(0 0 0 / 0.1));
131
+ border-radius: 0.5rem;
132
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
133
+ }
134
+ }
135
+
136
+ .lalt-toolbar__item {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: space-between;
140
+ gap: 1.5rem;
141
+ width: 100%;
142
+ min-height: 2.75rem;
143
+ padding: 0.5rem 0.75rem;
144
+ border-radius: 0.375rem;
145
+ background: none;
146
+ border: none;
147
+ font: inherit;
148
+ font-size: 0.9375rem;
149
+ text-align: left;
150
+ cursor: pointer;
151
+ color: inherit;
152
+ }
153
+
154
+ @media (min-width: 640px) {
155
+ .lalt-toolbar__item {
156
+ min-height: 0;
157
+ padding: 0.375rem 0.5rem;
158
+ font-size: 0.875rem;
159
+ }
160
+ }
161
+
162
+ .lalt-toolbar__item:hover {
163
+ background: var(--lalt-editor-hover, rgb(0 0 0 / 0.06));
164
+ }
165
+
166
+ .lalt-toolbar__item[aria-current="true"] {
167
+ font-weight: 600;
168
+ }
169
+
170
+ /* Hidden where there is no keyboard to press them with. */
171
+ .lalt-toolbar__shortcut {
172
+ display: none;
173
+ font-size: 0.75rem;
174
+ opacity: 0.5;
175
+ }
176
+
177
+ @media (min-width: 640px) {
178
+ .lalt-toolbar__shortcut {
179
+ display: inline;
180
+ }
8
181
  }
9
182
 
10
183
  .lalt-revision {
@@ -30,3 +203,62 @@
30
203
  opacity: 0.45;
31
204
  background: none;
32
205
  }
206
+
207
+ /* An edit shown where it happens, rather than in a panel the author has to
208
+ map back onto the text. The old passage stays visible struck through, the
209
+ proposal follows it, and the two decisions sit within thumb reach of both. */
210
+ .lalt-suggestion__old {
211
+ color: var(--lalt-editor-delete);
212
+ background: var(--lalt-editor-delete-bg);
213
+ text-decoration: line-through;
214
+ }
215
+
216
+ .lalt-suggestion {
217
+ display: inline;
218
+ white-space: normal;
219
+ }
220
+
221
+ .lalt-suggestion__new {
222
+ color: var(--lalt-editor-insert);
223
+ background: var(--lalt-editor-insert-bg);
224
+ border-radius: 0.25rem;
225
+ padding: 0.05em 0.15em;
226
+ }
227
+
228
+ .lalt-suggestion__action {
229
+ display: inline-flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ width: 1.75rem;
233
+ height: 1.75rem;
234
+ margin-left: 0.25rem;
235
+ vertical-align: middle;
236
+ border: 1px solid var(--lalt-editor-border, rgb(0 0 0 / 0.1));
237
+ border-radius: 999px;
238
+ background: var(--lalt-editor-surface, #ffffff);
239
+ color: var(--lalt-editor-text, #111827);
240
+ font-size: 0.8125rem;
241
+ line-height: 1;
242
+ cursor: pointer;
243
+ }
244
+
245
+ /* Bigger where a finger is doing the aiming. */
246
+ @media (max-width: 639px) {
247
+ .lalt-suggestion__action {
248
+ width: 2.25rem;
249
+ height: 2.25rem;
250
+ font-size: 1rem;
251
+ }
252
+ }
253
+
254
+ .lalt-suggestion__action--accept {
255
+ color: var(--lalt-editor-insert);
256
+ }
257
+
258
+ .lalt-suggestion__action--reject {
259
+ color: var(--lalt-editor-delete);
260
+ }
261
+
262
+ .lalt-suggestion__action:hover {
263
+ background: var(--lalt-editor-hover, rgb(0 0 0 / 0.06));
264
+ }
package/dist/index.d.ts CHANGED
@@ -1,52 +1,149 @@
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
+ import { Extension } from '@tiptap/core';
5
+ import { PluginKey } from '@tiptap/pm/state';
6
+ import { DecorationSet } from '@tiptap/pm/view';
7
+
8
+ interface SelectionRect {
9
+ /** Horizontal centre of the selection. */
10
+ x: number;
11
+ top: number;
12
+ bottom: number;
13
+ }
14
+ interface Size {
15
+ width: number;
16
+ height: number;
17
+ }
18
+ interface PlaceToolbarInput {
19
+ selection: SelectionRect;
20
+ toolbar: Size;
21
+ viewport: Size;
22
+ /** iOS renders its own callout above a selection, which covers a toolbar
23
+ * placed there, so the caller asks for below on that platform. */
24
+ preferBelow?: boolean;
25
+ }
26
+ interface ToolbarPlacement {
27
+ left: number;
28
+ top: number;
29
+ below: boolean;
30
+ }
1
31
  /**
2
- * RichNode is one node of a TipTap document.
32
+ * placeToolbar positions a floating toolbar against a selection.
3
33
  *
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.
34
+ * Kept apart from the component because it is the part that is wrong in subtle
35
+ * ways — off-screen at the edges, hidden under a system callout — and the part
36
+ * a test can pin down without a browser.
7
37
  */
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;
38
+ declare function placeToolbar({ selection, toolbar, viewport, preferBelow, }: PlaceToolbarInput): ToolbarPlacement;
39
+
40
+ interface ToolbarAction {
41
+ id: string;
42
+ label: string;
43
+ icon?: React.ReactNode;
44
+ onSelect: (selectedText: string) => void;
45
+ }
46
+ interface BlockFormat {
47
+ id: string;
48
+ label: string;
49
+ shortcut?: string;
50
+ isActive?: boolean;
51
+ onSelect: () => void;
52
+ }
53
+ interface SelectionToolbarProps {
54
+ selectedText: string;
55
+ selection: SelectionRect;
56
+ actions?: ToolbarAction[];
57
+ /** Heading levels and list kinds, rendered behind a single trigger. */
58
+ formats?: BlockFormat[];
59
+ formatsLabel?: string;
60
+ /** iOS draws its own callout above a selection; the host detects it. */
61
+ preferBelow?: boolean;
62
+ container?: HTMLElement | null;
63
+ }
64
+ declare function SelectionToolbar({ selectedText, selection, actions, formats, formatsLabel, preferBelow, container, }: SelectionToolbarProps): react.ReactPortal | null;
65
+
66
+ interface SelectionInfo {
67
+ text: string;
68
+ rect: SelectionRect;
19
69
  }
20
70
  /**
21
- * blocksFromDoc flattens a TipTap document to the text of its top-level nodes.
71
+ * useSelectionToolbar reports the current non-empty selection of an editor.
22
72
  *
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.
73
+ * Returns null whenever there is nothing to act on, so the caller renders the
74
+ * toolbar only while it has a target.
26
75
  */
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;
76
+ declare function useSelectionToolbar(editor: Editor | null): SelectionInfo | null;
32
77
 
33
- type ChangeKind = "keep" | "insert" | "delete" | "replace";
34
- interface BlockChange {
78
+ interface FormatLabels {
79
+ paragraph: string;
80
+ heading1: string;
81
+ heading2: string;
82
+ heading3: string;
83
+ orderedList: string;
84
+ bulletList: string;
85
+ }
86
+ declare const defaultFormatLabels: FormatLabels;
87
+ /**
88
+ * blockFormats is the set a writer reaches for on a selected passage.
89
+ *
90
+ * Labels are passed in rather than translated here: the package carries no
91
+ * i18n runtime, so the host names things in whatever language it speaks.
92
+ */
93
+ declare function blockFormats(editor: Editor | null, labels?: FormatLabels): BlockFormat[];
94
+
95
+ interface InlineSuggestion {
35
96
  id: string;
36
- kind: ChangeKind;
97
+ /** Document positions of the passage this replaces. */
98
+ from: number;
99
+ to: number;
100
+ after: string;
101
+ }
102
+ interface SuggestionActions {
103
+ onAccept: (id: string) => void;
104
+ onReject: (id: string) => void;
105
+ acceptLabel: string;
106
+ rejectLabel: string;
107
+ }
108
+ declare const inlineSuggestionsKey: PluginKey<DecorationSet>;
109
+ /**
110
+ * InlineSuggestions renders proposed edits where they belong: over the passage
111
+ * they replace, with the new text right after it.
112
+ *
113
+ * Decorations, never document changes. The editor's content stays exactly what
114
+ * the author wrote until they accept, so the draft is saveable mid-review and
115
+ * a rejected suggestion leaves nothing behind — which a document-level insert
116
+ * could not promise.
117
+ */
118
+ declare const InlineSuggestions: Extension<{
119
+ actions: SuggestionActions | null;
120
+ }, any>;
121
+
122
+ interface TextRange {
123
+ from: number;
124
+ to: number;
125
+ }
126
+ interface RevisionPart {
37
127
  before: string;
38
128
  after: string;
39
129
  }
40
130
  /**
41
- * diffBlocks compares two documents one block at a time.
131
+ * expandToWords grows a range out to whole words.
42
132
  *
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.
133
+ * A selection dragged across a line lands mid-word more often than not, and a
134
+ * model handed "ns de production" answers for a fragment — which then reads as
135
+ * a truncated diff the author has to decode.
136
+ */
137
+ declare function expandToWords(text: string, from: number, to: number): TextRange;
138
+ /**
139
+ * splitRevision turns one model answer into the decisions it really contains.
46
140
  *
47
- * Accepting every change rebuilds `after` exactly; rejecting every change
48
- * rebuilds `before` exactly. The UI relies on both.
141
+ * A three-paragraph rewrite accepted or rejected as a whole forces the author
142
+ * to keep a weak paragraph for a strong one, so each paragraph becomes its own
143
+ * decision — but only when both sides line up. When the model merged or split
144
+ * paragraphs, position no longer maps them, and pairing by index would show a
145
+ * diff that never existed.
49
146
  */
50
- declare function diffBlocks(before: string[], after: string[]): BlockChange[];
147
+ declare function splitRevision(before: string, after: string): RevisionPart[];
51
148
 
52
- export { type BlockChange, type BlockShape, type ChangeKind, type RichDoc, type RichNode, blocksFromDoc, diffBlocks, docFromBlocks, shapesFromDoc };
149
+ export { type BlockFormat, type FormatLabels, type InlineSuggestion, InlineSuggestions, type PlaceToolbarInput, type RevisionPart, type SelectionInfo, type SelectionRect, SelectionToolbar, type SelectionToolbarProps, type SuggestionActions, type TextRange, type ToolbarAction, type ToolbarPlacement, blockFormats, defaultFormatLabels, expandToWords, inlineSuggestionsKey, placeToolbar, splitRevision, useSelectionToolbar };
package/dist/index.js CHANGED
@@ -4,10 +4,332 @@ 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: {
71
+ "--lalt-toolbar-left": `${place.left}px`,
72
+ "--lalt-toolbar-top": `${place.top}px`
73
+ },
74
+ children: [
75
+ actions.map((action) => /* @__PURE__ */ jsxs(
76
+ "button",
77
+ {
78
+ type: "button",
79
+ className: "lalt-toolbar__button",
80
+ onMouseDown: (e) => e.preventDefault(),
81
+ onClick: () => action.onSelect(selectedText),
82
+ children: [
83
+ action.icon,
84
+ /* @__PURE__ */ jsx("span", { children: action.label })
85
+ ]
86
+ },
87
+ action.id
88
+ )),
89
+ formats.length > 0 && /* @__PURE__ */ jsxs("div", { className: "lalt-toolbar__formats", children: [
90
+ /* @__PURE__ */ jsx(
91
+ "button",
92
+ {
93
+ type: "button",
94
+ className: "lalt-toolbar__button",
95
+ "aria-haspopup": "menu",
96
+ "aria-expanded": formatsOpen,
97
+ onMouseDown: (e) => e.preventDefault(),
98
+ onClick: () => setFormatsOpen((open) => !open),
99
+ children: formatsLabel
100
+ }
101
+ ),
102
+ formatsOpen && /* @__PURE__ */ jsx("div", { role: "menu", className: "lalt-toolbar__menu", children: formats.map((format) => /* @__PURE__ */ jsxs(
103
+ "button",
104
+ {
105
+ type: "button",
106
+ role: "menuitem",
107
+ className: "lalt-toolbar__item",
108
+ "aria-current": format.isActive,
109
+ onMouseDown: (e) => e.preventDefault(),
110
+ onClick: () => {
111
+ format.onSelect();
112
+ setFormatsOpen(false);
113
+ },
114
+ children: [
115
+ /* @__PURE__ */ jsx("span", { children: format.label }),
116
+ format.shortcut && /* @__PURE__ */ jsx("kbd", { className: "lalt-toolbar__shortcut", children: format.shortcut })
117
+ ]
118
+ },
119
+ format.id
120
+ )) })
121
+ ] })
122
+ ]
123
+ }
124
+ ),
125
+ container ?? document.body
126
+ );
127
+ }
128
+
129
+ // src/useSelectionToolbar.ts
130
+ import { useEffect as useEffect2, useState as useState2 } from "react";
131
+ function useSelectionToolbar(editor) {
132
+ const [selection, setSelection] = useState2(null);
133
+ useEffect2(() => {
134
+ if (!editor) return;
135
+ const read = () => {
136
+ const { state } = editor;
137
+ const { from, to, empty } = state.selection;
138
+ if (empty || !editor.isFocused) {
139
+ setSelection(null);
140
+ return;
141
+ }
142
+ const text = state.doc.textBetween(from, to, " ").trim();
143
+ if (text === "") {
144
+ setSelection(null);
145
+ return;
146
+ }
147
+ const start = editor.view.coordsAtPos(from);
148
+ const end = editor.view.coordsAtPos(to);
149
+ setSelection({
150
+ text,
151
+ rect: {
152
+ x: (start.left + end.right) / 2,
153
+ top: Math.min(start.top, end.top),
154
+ bottom: Math.max(start.bottom, end.bottom)
155
+ }
156
+ });
157
+ };
158
+ read();
159
+ editor.on("selectionUpdate", read);
160
+ editor.on("blur", read);
161
+ editor.on("focus", read);
162
+ return () => {
163
+ editor.off("selectionUpdate", read);
164
+ editor.off("blur", read);
165
+ editor.off("focus", read);
166
+ };
167
+ }, [editor]);
168
+ return selection;
169
+ }
170
+
171
+ // src/formats.ts
172
+ var defaultFormatLabels = {
173
+ paragraph: "Texte",
174
+ heading1: "Titre 1",
175
+ heading2: "Titre 2",
176
+ heading3: "Titre 3",
177
+ orderedList: "Liste num\xE9rot\xE9e",
178
+ bulletList: "Liste \xE0 puces"
179
+ };
180
+ function blockFormats(editor, labels = defaultFormatLabels) {
181
+ if (!editor) return [];
182
+ const mod = isApple() ? "\u2318" : "Ctrl";
183
+ return [
184
+ {
185
+ id: "paragraph",
186
+ label: labels.paragraph,
187
+ isActive: editor.isActive("paragraph"),
188
+ onSelect: () => editor.chain().focus().setParagraph().run()
189
+ },
190
+ ...[1, 2, 3].map((level) => ({
191
+ id: `heading${level}`,
192
+ label: labels[`heading${level}`],
193
+ shortcut: `${mod}+Alt+${level}`,
194
+ isActive: editor.isActive("heading", { level }),
195
+ onSelect: () => editor.chain().focus().toggleHeading({ level }).run()
196
+ })),
197
+ {
198
+ id: "orderedList",
199
+ label: labels.orderedList,
200
+ shortcut: `${mod}+Shift+7`,
201
+ isActive: editor.isActive("orderedList"),
202
+ onSelect: () => editor.chain().focus().toggleOrderedList().run()
203
+ },
204
+ {
205
+ id: "bulletList",
206
+ label: labels.bulletList,
207
+ shortcut: `${mod}+Shift+8`,
208
+ isActive: editor.isActive("bulletList"),
209
+ onSelect: () => editor.chain().focus().toggleBulletList().run()
210
+ }
211
+ ];
212
+ }
213
+ function isApple() {
214
+ if (typeof navigator === "undefined") return false;
215
+ return /Mac|iPad|iPhone|iPod/.test(navigator.platform || navigator.userAgent);
216
+ }
217
+
218
+ // src/InlineSuggestions.ts
219
+ import { Extension } from "@tiptap/core";
220
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
221
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
222
+ var inlineSuggestionsKey = new PluginKey("lalt-inline-suggestions");
223
+ var InlineSuggestions = Extension.create({
224
+ name: "lalt-inline-suggestions",
225
+ addOptions() {
226
+ return { actions: null };
227
+ },
228
+ addProseMirrorPlugins() {
229
+ const options = this.options;
230
+ return [
231
+ new Plugin({
232
+ key: inlineSuggestionsKey,
233
+ state: {
234
+ init: () => DecorationSet.empty,
235
+ apply(tr, old) {
236
+ const set = tr.getMeta(inlineSuggestionsKey);
237
+ if (set) return build(tr.doc, set, options.actions);
238
+ return old.map(tr.mapping, tr.doc);
239
+ }
240
+ },
241
+ props: {
242
+ decorations(state) {
243
+ return inlineSuggestionsKey.getState(state) ?? DecorationSet.empty;
244
+ }
245
+ }
246
+ })
247
+ ];
248
+ }
249
+ });
250
+ function build(doc, suggestions, actions) {
251
+ const decorations = [];
252
+ for (const s of suggestions) {
253
+ if (s.from < 0 || s.to > doc.content.size || s.from >= s.to) continue;
254
+ decorations.push(
255
+ Decoration.inline(s.from, s.to, { class: "lalt-suggestion__old" }),
256
+ Decoration.widget(s.to, () => renderProposal(s, actions), {
257
+ // The widget belongs to the suggestion that precedes it, so typing at
258
+ // the boundary does not push it around.
259
+ side: 1,
260
+ ignoreSelection: true
261
+ })
262
+ );
263
+ }
264
+ return DecorationSet.create(doc, decorations);
265
+ }
266
+ function renderProposal(s, actions) {
267
+ const wrap = document.createElement("span");
268
+ wrap.className = "lalt-suggestion";
269
+ wrap.setAttribute("contenteditable", "false");
270
+ const text = document.createElement("span");
271
+ text.className = "lalt-suggestion__new";
272
+ text.textContent = s.after;
273
+ wrap.appendChild(text);
274
+ if (actions) {
275
+ wrap.appendChild(button("reject", actions.rejectLabel, "\u2715", () => actions.onReject(s.id)));
276
+ wrap.appendChild(button("accept", actions.acceptLabel, "\u2713", () => actions.onAccept(s.id)));
277
+ }
278
+ return wrap;
279
+ }
280
+ function button(kind, label, glyph, onClick) {
281
+ const el = document.createElement("button");
282
+ el.type = "button";
283
+ el.className = `lalt-suggestion__action lalt-suggestion__action--${kind}`;
284
+ el.setAttribute("aria-label", label);
285
+ el.textContent = glyph;
286
+ el.addEventListener("mousedown", (e) => e.preventDefault());
287
+ el.addEventListener("click", (e) => {
288
+ e.preventDefault();
289
+ onClick();
290
+ });
291
+ return el;
292
+ }
293
+
294
+ // src/passage.ts
295
+ var WORD = /[\p{L}\p{N}'’-]/u;
296
+ function expandToWords(text, from, to) {
297
+ if (from >= to) return { from, to };
298
+ let start = from;
299
+ while (start > 0 && WORD.test(text[start - 1]) && WORD.test(text[start])) start--;
300
+ let end = to;
301
+ while (end < text.length && WORD.test(text[end]) && WORD.test(text[end - 1])) end++;
302
+ return { from: start, to: end };
303
+ }
304
+ function splitRevision(before, after) {
305
+ if (before.trim() === after.trim()) return [];
306
+ const olds = paragraphs(before);
307
+ const news = paragraphs(after);
308
+ if (olds.length !== news.length || olds.length === 0) {
309
+ return [{ before: before.trim(), after: after.trim() }];
310
+ }
311
+ const parts = [];
312
+ for (let i = 0; i < olds.length; i++) {
313
+ if (olds[i] !== news[i]) parts.push({ before: olds[i], after: news[i] });
314
+ }
315
+ return parts;
316
+ }
317
+ function paragraphs(s) {
318
+ return s.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p !== "");
319
+ }
7
320
  export {
321
+ InlineSuggestions,
322
+ SelectionToolbar,
323
+ blockFormats,
8
324
  blocksFromDoc,
325
+ defaultFormatLabels,
9
326
  diffBlocks,
10
327
  docFromBlocks,
11
- shapesFromDoc
328
+ expandToWords,
329
+ inlineSuggestionsKey,
330
+ placeToolbar,
331
+ shapesFromDoc,
332
+ splitRevision,
333
+ useSelectionToolbar
12
334
  };
13
335
  //# 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","../src/InlineSuggestions.ts","../src/passage.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 // Custom properties rather than left/top directly: under the mobile\n // breakpoint the stylesheet docks the toolbar to the bottom of the\n // screen and ignores these, which inline left/top would override.\n style={\n {\n \"--lalt-toolbar-left\": `${place.left}px`,\n \"--lalt-toolbar-top\": `${place.top}px`,\n } as React.CSSProperties\n }\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 // Shortcuts are the ones TipTap's own extensions bind, read from their\n // source rather than invented: heading binds Mod-Alt-<level>, the list\n // extensions bind Mod-Shift-7/8, and setParagraph binds nothing at all.\n // A label promising a shortcut that does nothing is worse than no label.\n const mod = isApple() ? \"⌘\" : \"Ctrl\";\n\n return [\n {\n id: \"paragraph\",\n label: labels.paragraph,\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: `${mod}+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: `${mod}+Shift+7`,\n isActive: editor.isActive(\"orderedList\"),\n onSelect: () => editor.chain().focus().toggleOrderedList().run(),\n },\n {\n id: \"bulletList\",\n label: labels.bulletList,\n shortcut: `${mod}+Shift+8`,\n isActive: editor.isActive(\"bulletList\"),\n onSelect: () => editor.chain().focus().toggleBulletList().run(),\n },\n ];\n}\n\nfunction isApple(): boolean {\n if (typeof navigator === \"undefined\") return false;\n return /Mac|iPad|iPhone|iPod/.test(navigator.platform || navigator.userAgent);\n}\n","import { Extension } from \"@tiptap/core\";\nimport { Plugin, PluginKey } from \"@tiptap/pm/state\";\nimport { Decoration, DecorationSet } from \"@tiptap/pm/view\";\nimport type { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\n\nexport interface InlineSuggestion {\n id: string;\n /** Document positions of the passage this replaces. */\n from: number;\n to: number;\n after: string;\n}\n\nexport interface SuggestionActions {\n onAccept: (id: string) => void;\n onReject: (id: string) => void;\n acceptLabel: string;\n rejectLabel: string;\n}\n\nexport const inlineSuggestionsKey = new PluginKey<DecorationSet>(\"lalt-inline-suggestions\");\n\n/**\n * InlineSuggestions renders proposed edits where they belong: over the passage\n * they replace, with the new text right after it.\n *\n * Decorations, never document changes. The editor's content stays exactly what\n * the author wrote until they accept, so the draft is saveable mid-review and\n * a rejected suggestion leaves nothing behind — which a document-level insert\n * could not promise.\n */\nexport const InlineSuggestions = Extension.create<{ actions: SuggestionActions | null }>({\n name: \"lalt-inline-suggestions\",\n\n addOptions() {\n return { actions: null };\n },\n\n addProseMirrorPlugins() {\n const options = this.options;\n\n return [\n new Plugin<DecorationSet>({\n key: inlineSuggestionsKey,\n\n state: {\n init: () => DecorationSet.empty,\n apply(tr, old) {\n const set = tr.getMeta(inlineSuggestionsKey) as InlineSuggestion[] | undefined;\n if (set) return build(tr.doc, set, options.actions);\n // Positions follow the document as the author keeps typing around\n // a pending suggestion.\n return old.map(tr.mapping, tr.doc);\n },\n },\n\n props: {\n decorations(state) {\n return inlineSuggestionsKey.getState(state) ?? DecorationSet.empty;\n },\n },\n }),\n ];\n },\n});\n\nfunction build(\n doc: ProseMirrorNode,\n suggestions: InlineSuggestion[],\n actions: SuggestionActions | null,\n): DecorationSet {\n const decorations: Decoration[] = [];\n\n for (const s of suggestions) {\n if (s.from < 0 || s.to > doc.content.size || s.from >= s.to) continue;\n\n decorations.push(\n Decoration.inline(s.from, s.to, { class: \"lalt-suggestion__old\" }),\n Decoration.widget(s.to, () => renderProposal(s, actions), {\n // The widget belongs to the suggestion that precedes it, so typing at\n // the boundary does not push it around.\n side: 1,\n ignoreSelection: true,\n }),\n );\n }\n return DecorationSet.create(doc, decorations);\n}\n\nfunction renderProposal(s: InlineSuggestion, actions: SuggestionActions | null): HTMLElement {\n const wrap = document.createElement(\"span\");\n wrap.className = \"lalt-suggestion\";\n wrap.setAttribute(\"contenteditable\", \"false\");\n\n const text = document.createElement(\"span\");\n text.className = \"lalt-suggestion__new\";\n text.textContent = s.after;\n wrap.appendChild(text);\n\n if (actions) {\n wrap.appendChild(button(\"reject\", actions.rejectLabel, \"✕\", () => actions.onReject(s.id)));\n wrap.appendChild(button(\"accept\", actions.acceptLabel, \"✓\", () => actions.onAccept(s.id)));\n }\n return wrap;\n}\n\nfunction button(kind: string, label: string, glyph: string, onClick: () => void): HTMLElement {\n const el = document.createElement(\"button\");\n el.type = \"button\";\n el.className = `lalt-suggestion__action lalt-suggestion__action--${kind}`;\n el.setAttribute(\"aria-label\", label);\n el.textContent = glyph;\n // mousedown would move the selection into the widget before the click lands.\n el.addEventListener(\"mousedown\", (e) => e.preventDefault());\n el.addEventListener(\"click\", (e) => {\n e.preventDefault();\n onClick();\n });\n return el;\n}\n","export interface TextRange {\n from: number;\n to: number;\n}\n\nexport interface RevisionPart {\n before: string;\n after: string;\n}\n\n// Letters, digits, and the marks that belong inside a French word.\nconst WORD = /[\\p{L}\\p{N}'’-]/u;\n\n/**\n * expandToWords grows a range out to whole words.\n *\n * A selection dragged across a line lands mid-word more often than not, and a\n * model handed \"ns de production\" answers for a fragment — which then reads as\n * a truncated diff the author has to decode.\n */\nexport function expandToWords(text: string, from: number, to: number): TextRange {\n if (from >= to) return { from, to };\n\n let start = from;\n while (start > 0 && WORD.test(text[start - 1]) && WORD.test(text[start])) start--;\n\n let end = to;\n while (end < text.length && WORD.test(text[end]) && WORD.test(text[end - 1])) end++;\n\n return { from: start, to: end };\n}\n\n/**\n * splitRevision turns one model answer into the decisions it really contains.\n *\n * A three-paragraph rewrite accepted or rejected as a whole forces the author\n * to keep a weak paragraph for a strong one, so each paragraph becomes its own\n * decision — but only when both sides line up. When the model merged or split\n * paragraphs, position no longer maps them, and pairing by index would show a\n * diff that never existed.\n */\nexport function splitRevision(before: string, after: string): RevisionPart[] {\n if (before.trim() === after.trim()) return [];\n\n const olds = paragraphs(before);\n const news = paragraphs(after);\n\n if (olds.length !== news.length || olds.length === 0) {\n return [{ before: before.trim(), after: after.trim() }];\n }\n\n const parts: RevisionPart[] = [];\n for (let i = 0; i < olds.length; i++) {\n if (olds[i] !== news[i]) parts.push({ before: olds[i], after: news[i] });\n }\n return parts;\n}\n\nfunction paragraphs(s: string): string[] {\n return s\n .split(/\\n\\s*\\n/)\n .map((p) => p.trim())\n .filter((p) => p !== \"\");\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.4.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
  }