@ai-matrx/rich-editor 0.2.9 → 0.2.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.10
4
+ ### The caret survives a view switch
5
+
6
+ - Switching Visual <-> Source (and back) carries the caret or selection: read from the leaving view as text around each end (`getCaret`, `CaretCarry`), placed in the new view once it is ready (`placeCaret(context, to)`). It used to land at offset 0.
7
+
3
8
  ## 0.2.9
4
9
 
5
10
  - Outline "Copy link" shows ONE toast (AP-2): the words ("Link to … copied.") ride the kit copy instead
@@ -22,3 +22,17 @@ export declare function domPointAt(doc: Document, x: number, y: number): {
22
22
  node: Node;
23
23
  offset: number;
24
24
  } | null;
25
+ /** The caret or selection of one view, as text around its two ends — carried across a view switch. */
26
+ export interface CaretCarry {
27
+ from: CaretContext;
28
+ /** The selection's other end; null for a plain caret. */
29
+ to: CaretContext | null;
30
+ }
31
+ /** The text around `index` of `flat` (a view's own text). */
32
+ export declare function contextAtIndex(flat: string, index: number): CaretContext;
33
+ /**
34
+ * A context read from markdown SOURCE, made comparable with the rendered text: the markup characters a
35
+ * rendered view does not show (`**`, `_`, backticks, heading and quote marks) are dropped, so Source ->
36
+ * Visual finds the same words. (Visual -> Source needs no help: the locator falls back to the words.)
37
+ */
38
+ export declare function withoutMarkup(context: CaretContext): CaretContext;
@@ -65,9 +65,19 @@ function domPointAt(doc, x, y) {
65
65
  if (range) return { node: range.startContainer, offset: range.startOffset };
66
66
  return null;
67
67
  }
68
+ function contextAtIndex(flat, index) {
69
+ const at = Math.max(0, Math.min(index, flat.length));
70
+ return { before: flat.slice(Math.max(0, at - CONTEXT_CHARS), at), after: flat.slice(at, at + CONTEXT_CHARS) };
71
+ }
72
+ function withoutMarkup(context) {
73
+ const clean = (text) => text.replace(/[*_~`]+/g, "").replace(/^[ \t]*(?:#{1,6}|>|[-+*]|\d+[.)])[ \t]+/gm, "");
74
+ return { before: clean(context.before), after: clean(context.after) };
75
+ }
68
76
  export {
69
77
  CONTEXT_CHARS,
70
78
  caretContextAt,
79
+ contextAtIndex,
71
80
  domPointAt,
72
- locateCaret
81
+ locateCaret,
82
+ withoutMarkup
73
83
  };
@@ -131,6 +131,7 @@ function RichEditorImpl({
131
131
  const [kindRequest, setKindRequest] = useState(null);
132
132
  const approved = useRef(/* @__PURE__ */ new Set());
133
133
  const handle = useRef(null);
134
+ const pendingCaret = useRef(null);
134
135
  const imageInput = useRef(null);
135
136
  const { upload } = useFileUpload();
136
137
  const lastValue = useRef(value);
@@ -183,10 +184,26 @@ function RichEditorImpl({
183
184
  return;
184
185
  }
185
186
  handle.current?.clearFind();
187
+ pendingCaret.current = handle.current?.getCaret?.() ?? null;
186
188
  setView(next);
187
189
  setMountKey((key) => key + 1);
188
190
  if (next !== controlledView) onViewChange?.(next);
189
191
  };
192
+ useEffect(() => {
193
+ const carry = pendingCaret.current;
194
+ if (!carry || view === "preview") return;
195
+ pendingCaret.current = null;
196
+ let tries = 0;
197
+ let timer = null;
198
+ const attempt = () => {
199
+ if (handle.current?.placeCaret(carry.from, carry.to) || ++tries > 20) return;
200
+ timer = setTimeout(attempt, 50);
201
+ };
202
+ timer = setTimeout(attempt, 0);
203
+ return () => {
204
+ if (timer) clearTimeout(timer);
205
+ };
206
+ }, [view, mountKey]);
190
207
  useEffect(() => {
191
208
  if (controlledView !== void 0 && controlledView !== view) switchView(controlledView);
192
209
  }, [controlledView]);
@@ -21,7 +21,7 @@ import { getSchema } from "@tiptap/core";
21
21
  import { cn } from "@ai-matrx/design-system";
22
22
  import { toast } from "@ai-matrx/rich-content/host/toast";
23
23
  import { createRichEditorExtensions } from "../core/extensions.js";
24
- import { locateCaret } from "../core/caret-context.js";
24
+ import { contextAtIndex, locateCaret, withoutMarkup } from "../core/caret-context.js";
25
25
  import { htmlToMarkdown } from "../core/html-to-markdown.js";
26
26
  import { mergedCellsNotice, normalizePastedHtml } from "../core/paste-html.js";
27
27
  import { findMatches, replaceMatches } from "../core/find-replace.js";
@@ -314,15 +314,30 @@ ${text}`;
314
314
  const v = view.current;
315
315
  return v ? { undo: undoDepth(v.state), redo: redoDepth(v.state) } : { undo: 0, redo: 0 };
316
316
  },
317
- placeCaret: (context2) => {
317
+ placeCaret: (context2, to) => {
318
318
  const v = view.current;
319
319
  if (!v) return false;
320
- const index = locateCaret(v.state.doc.toString(), context2);
320
+ const text = v.state.doc.toString();
321
+ const index = locateCaret(text, context2);
321
322
  if (index === null) return false;
322
- v.dispatch({ selection: { anchor: index }, effects: EditorView.scrollIntoView(index, { y: "center" }) });
323
+ const toIndex = to ? locateCaret(text, to) : null;
324
+ v.dispatch({
325
+ selection: toIndex !== null && toIndex !== index ? { anchor: index, head: toIndex } : { anchor: index },
326
+ effects: EditorView.scrollIntoView(index, { y: "center" })
327
+ });
323
328
  v.focus();
324
329
  return true;
325
330
  },
331
+ getCaret: () => {
332
+ const v = view.current;
333
+ if (!v) return null;
334
+ const text = v.state.doc.toString();
335
+ const { from, to, empty } = v.state.selection.main;
336
+ return {
337
+ from: withoutMarkup(contextAtIndex(text, from)),
338
+ to: empty ? null : withoutMarkup(contextAtIndex(text, to))
339
+ };
340
+ },
326
341
  scrollToHeading: (_slug, offset) => {
327
342
  const v = view.current;
328
343
  if (!v) return;
@@ -1,6 +1,6 @@
1
1
  import { type Ref } from "react";
2
2
  import { type VisualLoadStats } from "../core/visual-document.js";
3
- import { type CaretContext } from "../core/caret-context.js";
3
+ import { type CaretCarry, type CaretContext } from "../core/caret-context.js";
4
4
  import type { FindOptions } from "../core/find-replace.js";
5
5
  import { type RichShellActions } from "./visual-extensions.js";
6
6
  import type { FormatTarget } from "../format/format-target.js";
@@ -32,7 +32,9 @@ export interface EditorViewHandle {
32
32
  };
33
33
  scrollToHeading: (slug: string, offset: number) => void;
34
34
  /** Put the caret at the text the person double-clicked in the rendered view; false when not found. */
35
- placeCaret: (context: CaretContext) => boolean;
35
+ placeCaret: (context: CaretContext, to?: CaretContext | null) => boolean;
36
+ /** The caret or selection as text around it, to carry across a view switch; null when there is none. */
37
+ getCaret: () => CaretCarry | null;
36
38
  find: (query: string, options: FindOptions, step?: 1 | -1) => ViewFindState;
37
39
  replaceCurrent: (query: string, replacement: string, options: FindOptions) => string[];
38
40
  replaceAll: (query: string, replacement: string, options: FindOptions) => string[];
@@ -17,7 +17,7 @@ import {
17
17
  serializeVisualDocument
18
18
  } from "../core/visual-document.js";
19
19
  import { findInDoc, replaceInDoc } from "../core/visual-find.js";
20
- import { locateCaret } from "../core/caret-context.js";
20
+ import { contextAtIndex, locateCaret } from "../core/caret-context.js";
21
21
  import { createVisualExtensions } from "./visual-extensions.js";
22
22
  import { findHighlightKey } from "./decorations.js";
23
23
  import { BlockHandle } from "./BlockHandle.js";
@@ -26,6 +26,21 @@ import { RICH_EDITOR_HOST_KEY, islandSelectionOf, tableAnchorOf } from "./format
26
26
  import { useRichEditorContext } from "../editor/RichEditorContext.js";
27
27
  const HEADLESS_SCHEMA = getSchema(createRichEditorExtensions());
28
28
  const REPORT_DELAY_MS = 120;
29
+ function flattenDoc(doc) {
30
+ let flat = "";
31
+ const positions = [];
32
+ doc.descendants((node, pos) => {
33
+ if (node.isText && node.text) {
34
+ for (let i = 0; i < node.text.length; i++) positions.push(pos + i);
35
+ flat += node.text;
36
+ } else if (node.isBlock && flat.length > 0 && !flat.endsWith("\n")) {
37
+ positions.push(pos);
38
+ flat += "\n";
39
+ }
40
+ return true;
41
+ });
42
+ return { flat, positions };
43
+ }
29
44
  function VisualEditor({
30
45
  initialText,
31
46
  onChange,
@@ -180,31 +195,30 @@ function VisualEditor({
180
195
  editor.chain().setTextSelection(pos).focus(void 0, { scrollIntoView: false }).run();
181
196
  }
182
197
  },
183
- placeCaret: (context2) => {
198
+ placeCaret: (context2, to) => {
184
199
  if (!editor) return false;
185
- let flat = "";
186
- const positions = [];
187
- editor.state.doc.descendants((node, pos2) => {
188
- if (node.isText && node.text) {
189
- for (let i = 0; i < node.text.length; i++) positions.push(pos2 + i);
190
- flat += node.text;
191
- } else if (node.isBlock && flat.length > 0 && !flat.endsWith("\n")) {
192
- positions.push(pos2);
193
- flat += "\n";
194
- }
195
- return true;
196
- });
200
+ const { flat, positions } = flattenDoc(editor.state.doc);
197
201
  const index = locateCaret(flat, context2);
198
202
  if (index === null) return false;
199
- const pos = index > 0 && positions[index - 1] !== void 0 ? positions[index - 1] + 1 : positions[index] ?? editor.state.doc.content.size - 1;
203
+ const posOf = (i) => i > 0 && positions[i - 1] !== void 0 ? positions[i - 1] + 1 : positions[i] ?? editor.state.doc.content.size - 1;
204
+ const toIndex = to ? locateCaret(flat, to) : null;
200
205
  try {
201
- editor.view.dispatch(editor.state.tr.setSelection(TextSelection.near(editor.state.doc.resolve(pos))).scrollIntoView());
206
+ const doc = editor.state.doc;
207
+ const selection = toIndex !== null && toIndex !== index ? TextSelection.between(doc.resolve(posOf(index)), doc.resolve(posOf(toIndex))) : TextSelection.near(doc.resolve(posOf(index)));
208
+ editor.view.dispatch(editor.state.tr.setSelection(selection).scrollIntoView());
202
209
  editor.view.focus();
203
210
  return true;
204
211
  } catch {
205
212
  return false;
206
213
  }
207
214
  },
215
+ getCaret: () => {
216
+ if (!editor) return null;
217
+ const { flat, positions } = flattenDoc(editor.state.doc);
218
+ const indexOf = (pos) => positions.filter((p) => p < pos).length;
219
+ const { from, to, empty } = editor.state.selection;
220
+ return { from: contextAtIndex(flat, indexOf(from)), to: empty ? null : contextAtIndex(flat, indexOf(to)) };
221
+ },
208
222
  find: (query, options, step) => {
209
223
  if (!editor) return { count: 0, current: -1, skippedProtected: 0, error: null };
210
224
  const result = findInDoc(editor.state.doc, query, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-editor",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "The AI Matrx editor: Visual (Tiptap), Source (CodeMirror), Preview, the markdown formatting command layer, and edit-in-place — over @ai-matrx/rich-content. Consumers that only render never install it.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -90,12 +90,12 @@
90
90
  "@tiptap/suggestion": "^3.31.3",
91
91
  "github-slugger": "^2.0.0",
92
92
  "lucide-react": "^1.48.0",
93
- "@ai-matrx/agents": "latest",
94
- "@ai-matrx/alchemy": "latest",
95
93
  "@ai-matrx/content-ir": "latest",
96
94
  "@ai-matrx/design-system": "latest",
97
- "@ai-matrx/print": "latest",
95
+ "@ai-matrx/alchemy": "latest",
98
96
  "@ai-matrx/kit": "latest",
97
+ "@ai-matrx/agents": "latest",
98
+ "@ai-matrx/print": "latest",
99
99
  "@ai-matrx/rich-content": "latest"
100
100
  },
101
101
  "peerDependencies": {