tiptap-markdown-react 0.9.6 → 0.10.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,24 @@
1
+ import { Editor } from '@tiptap/react';
2
+ import { CommentAnchorStatus, CommentSegment } from './commentAnchor/commentMapper';
3
+ /**
4
+ * 内容锚点 → 当前文档里的区间。
5
+ *
6
+ * 用途:宿主在提交一次「圈选改写」时记下选区的内容锚点(`prefix` / `exact` / `suffix`,
7
+ * `blockHash` 可选),等 AI 改完、推送回来时再用它把那段区间找回来——**不能用位置**,
8
+ * 因为编辑器在这期间可能整体重建过(保存、切卡、SWR 刷新都会重挂),位置活不过重建。
9
+ *
10
+ * 锚点类型直接复用评论锚定那套(`commentMapper`),匹配走同一条 fallback 链:
11
+ * `blockHash` → 前后文 → `exact` → 前缀降级(前 20 字)。**这条链是会降级的**,所以:
12
+ *
13
+ * > 调用方必须自己比对返回的 `text` 是否仍等于提交时那一段。本函数只回答「在哪」,
14
+ * > 不回答「还算不算同一段」——后者决定了是原地回填还是弹冲突。
15
+ */
16
+ export interface AnchorRange {
17
+ /** doc 坐标 */
18
+ from: number;
19
+ to: number;
20
+ /** 区间当前对应的纯文本(块间用 `\n\n` 连接,与选区文本同一口径)。 */
21
+ text: string;
22
+ status: CommentAnchorStatus;
23
+ }
24
+ export declare function findRangeByAnchor(editor: Editor, anchor: CommentSegment | null | undefined): AnchorRange | null;
@@ -0,0 +1 @@
1
+ export {};
@@ -3,6 +3,8 @@ import { SourceRef } from '../citationUtils';
3
3
  import { RenderCitation } from '../citationTypes';
4
4
  import { CommentClickPayload, CommentRef } from '../commentAnchor/commentTypes';
5
5
  import { CodeBlockLabels } from '../labels';
6
+ import { PendingAnchor } from '../pendingAnchor';
7
+ import { SelectionKind } from '../selectionKind';
6
8
  import { TocItem } from '../toc/extractToc';
7
9
  export interface MarkdownWysiwygEditorHandle {
8
10
  /** 取当前正文的 markdown 字符串。 */
@@ -58,6 +60,23 @@ export interface MarkdownWysiwygEditorProps {
58
60
  extraExtensions?: AnyExtension[];
59
61
  /** 代码块 NodeView 的本地化文案。 */
60
62
  codeBlockLabels?: Partial<CodeBlockLabels>;
63
+ /**
64
+ * 「这段正在被 AI 改写」的高亮:宿主在提交请求时给上、回填或冲突时传空数组清空。
65
+ * 走 decoration,**不进 markdown、不进 undo 历史**,能盖住表格与图表这类块级节点。
66
+ */
67
+ pendingAnchors?: PendingAnchor[];
68
+ /** 点击待改写高亮区域时回调,给出锚点 id(宿主可用来跳回/取消)。 */
69
+ onAnchorClick?: (id: string) => void;
70
+ /**
71
+ * 选区变化。宿主用它做「选中即出现引用」——塌缩成光标也会报(`empty: true`),
72
+ * 好让引用跟着消失;`kind` 区分普通文字 / 表格单元格 / 整节点(图表)。
73
+ */
74
+ onSelectionChange?: (selection: {
75
+ from: number;
76
+ to: number;
77
+ empty: boolean;
78
+ kind: SelectionKind;
79
+ }) => void;
61
80
  /** 附加到滚动容器的 class。 */
62
81
  className?: string;
63
82
  /**
package/dist/index.d.ts CHANGED
@@ -36,5 +36,11 @@ export type { CommentRef, CommentClickPayload, RenderComment, RenderCommentConte
36
36
  export { useIsMobile } from './hooks/useIsMobile';
37
37
  export * from './icons';
38
38
  export { insertMarkdown } from './insertMarkdown';
39
+ export { findRangeByAnchor, type AnchorRange } from './anchorRange';
40
+ export { getMarkdownForRange, type GetMarkdownForRangeOptions, type MarkdownRange, } from './markdownRange';
41
+ export { normalizeMarkdown } from './normalizeMarkdown';
42
+ export { PENDING_ANCHOR_CLASS, type PendingAnchor, } from './pendingAnchor';
43
+ export { replaceRangeWithMarkdown, type ReplaceRangeOptions } from './replaceRange';
44
+ export { selectionKind, type SelectionKind } from './selectionKind';
39
45
  export type { Editor } from '@tiptap/react';
40
46
  export type { JSONContent } from '@tiptap/core';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { Markdown } from "@tiptap/markdown";
2
+ import { Markdown, MarkdownManager } from "@tiptap/markdown";
3
3
  import { c as renderOrUpdateChart, d as destroyChart } from "./scrollToTocHeading-BnfiGfjk.js";
4
4
  import { C, a, R, b, f, m, r, s } from "./scrollToTocHeading-BnfiGfjk.js";
5
5
  import { jsx, jsxs, Fragment as Fragment$1 } from "react/jsx-runtime";
@@ -10,17 +10,18 @@ import { NodeViewWrapper, ReactNodeViewRenderer, NodeViewContent, useEditor, Edi
10
10
  import { useRef, useState, useMemo, useEffect, forwardRef, useImperativeHandle, useId, useCallback, useContext, createContext } from "react";
11
11
  import { e as enrichMarkdownCitations, b as styles$5 } from "./ReportContent-XJE1P0zp.js";
12
12
  import { R as R2, a as a2, s as s2 } from "./ReportContent-XJE1P0zp.js";
13
- import { C as CitationRef, i as mergeCommentIntervals, h as mapCommentAnchors, p as prepareChartMarkdown, j as reportChart, b as baseExtensions, f as CommentMark, l as lowlight, m as makeTocGetId, k as parseCodeTokenAsChartOrCodeBlock, a as pureCodeBlock, c as pureImage, n as renderMathInto, o as subscribeMathClick } from "./extractToc-BrN-Lwwy.js";
14
- import { g, e, d, s as s3 } from "./extractToc-BrN-Lwwy.js";
13
+ import { C as CitationRef, i as mergeCommentIntervals, h as mapCommentAnchors, p as prepareChartMarkdown, j as reportChart, b as baseExtensions, f as CommentMark, l as lowlight, m as makeTocGetId, k as parseCodeTokenAsChartOrCodeBlock, a as pureCodeBlock, c as pureImage, n as renderMathInto, o as subscribeMathClick, d as pureChart } from "./extractToc-BrN-Lwwy.js";
14
+ import { g, e, s as s3 } from "./extractToc-BrN-Lwwy.js";
15
15
  import { Slice, Fragment } from "@tiptap/pm/model";
16
16
  import { Extension, Node as Node$1 } from "@tiptap/core";
17
- import { PluginKey, Plugin } from "@tiptap/pm/state";
17
+ import { PluginKey, Plugin, NodeSelection, TextSelection } from "@tiptap/pm/state";
18
18
  import { DecorationSet, Decoration } from "@tiptap/pm/view";
19
19
  import { FileHandlePlugin } from "@tiptap/extension-file-handler";
20
+ import { skipTrailingNodeMeta } from "@tiptap/extensions";
21
+ import { CellSelection, isInTable, selectedRect, rowIsHeader } from "@tiptap/pm/tables";
20
22
  import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
21
23
  import * as Popover from "@radix-ui/react-popover";
22
24
  import { createPortal } from "react-dom";
23
- import { isInTable, selectedRect, rowIsHeader, CellSelection } from "@tiptap/pm/tables";
24
25
  function CitationRefView({ node, extension }) {
25
26
  const index = String(node.attrs.index ?? "");
26
27
  const url = node.attrs.url || null;
@@ -452,6 +453,78 @@ const MarkdownFileDrop = Extension.create({
452
453
  ];
453
454
  }
454
455
  });
456
+ const PENDING_ANCHOR_META = "tmr:pendingAnchor";
457
+ const pendingAnchorPluginKey = new PluginKey(
458
+ "tmrPendingAnchor"
459
+ );
460
+ const PENDING_ANCHOR_CLASS = "tmr-pending-anchor";
461
+ const pendingAnchorExtension = Extension.create({
462
+ name: "pendingAnchor",
463
+ addProseMirrorPlugins() {
464
+ return [
465
+ new Plugin({
466
+ key: pendingAnchorPluginKey,
467
+ state: {
468
+ init: () => ({ decorations: DecorationSet.empty }),
469
+ apply(tr, value) {
470
+ const next = tr.getMeta(PENDING_ANCHOR_META);
471
+ if (next) return { decorations: buildDecorations(tr.doc, next) };
472
+ if (tr.docChanged) {
473
+ return { decorations: value.decorations.map(tr.mapping, tr.doc) };
474
+ }
475
+ return value;
476
+ }
477
+ },
478
+ props: {
479
+ decorations(state) {
480
+ var _a;
481
+ return ((_a = pendingAnchorPluginKey.getState(state)) == null ? void 0 : _a.decorations) ?? null;
482
+ }
483
+ }
484
+ })
485
+ ];
486
+ }
487
+ });
488
+ function setPendingAnchors(editor, anchors) {
489
+ editor.view.dispatch(
490
+ editor.state.tr.setMeta(PENDING_ANCHOR_META, anchors).setMeta(skipTrailingNodeMeta, true).setMeta("addToHistory", false)
491
+ );
492
+ }
493
+ function buildDecorations(doc, anchors) {
494
+ const bounds = [];
495
+ doc.forEach((child, offset) => {
496
+ bounds.push({ from: offset, to: offset + child.nodeSize });
497
+ });
498
+ const decorations = [];
499
+ for (const anchor of anchors) {
500
+ for (const range of anchor.ranges) {
501
+ const from = Math.max(0, Math.min(range.from, range.to));
502
+ const to = Math.min(doc.content.size, Math.max(range.from, range.to));
503
+ if (from >= to) continue;
504
+ for (const bound of bounds) {
505
+ if (bound.to <= from || bound.from >= to) continue;
506
+ const attrs = {
507
+ class: PENDING_ANCHOR_CLASS,
508
+ "data-pending-id": anchor.id
509
+ };
510
+ if (from <= bound.from && to >= bound.to) {
511
+ decorations.push(Decoration.node(bound.from, bound.to, attrs));
512
+ } else {
513
+ decorations.push(
514
+ Decoration.inline(Math.max(from, bound.from), Math.min(to, bound.to), attrs)
515
+ );
516
+ }
517
+ }
518
+ }
519
+ }
520
+ return decorations.length ? DecorationSet.create(doc, decorations) : DecorationSet.empty;
521
+ }
522
+ function selectionKind(selection) {
523
+ if (selection instanceof CellSelection) return "cell";
524
+ if (selection instanceof NodeSelection) return "node";
525
+ if (selection instanceof TextSelection) return "text";
526
+ return "text";
527
+ }
455
528
  function looksLikeMarkdown(text) {
456
529
  return /^#{1,6}\s/m.test(text) || // 标题
457
530
  /\*\*[^*\n]+\*\*/.test(text) || // 加粗
@@ -1055,7 +1128,10 @@ const MarkdownWysiwygEditor = forwardRef(function MarkdownWysiwygEditor2({
1055
1128
  onCommentClick,
1056
1129
  onActiveCommentChange,
1057
1130
  showCommentGutter = true,
1058
- commentInteractive = true
1131
+ commentInteractive = true,
1132
+ pendingAnchors,
1133
+ onAnchorClick,
1134
+ onSelectionChange
1059
1135
  }, ref) {
1060
1136
  const preparedInitial = prepareChartMarkdown(
1061
1137
  enrichMarkdownCitations(initialMarkdown, sources ?? [])
@@ -1074,6 +1150,7 @@ const MarkdownWysiwygEditor = forwardRef(function MarkdownWysiwygEditor2({
1074
1150
  interactive: commentInteractive
1075
1151
  }),
1076
1152
  Markdown,
1153
+ pendingAnchorExtension,
1077
1154
  TableOfContents.configure({
1078
1155
  getId: makeTocGetId(),
1079
1156
  onUpdate: (anchors) => {
@@ -1122,6 +1199,47 @@ const MarkdownWysiwygEditor = forwardRef(function MarkdownWysiwygEditor2({
1122
1199
  )
1123
1200
  );
1124
1201
  }, [editor, activeCommentId]);
1202
+ useEffect(() => {
1203
+ if (!editor) return;
1204
+ setPendingAnchors(editor, pendingAnchors ?? []);
1205
+ }, [editor, pendingAnchors]);
1206
+ const onAnchorClickRef = useRef(onAnchorClick);
1207
+ useEffect(() => {
1208
+ onAnchorClickRef.current = onAnchorClick;
1209
+ }, [onAnchorClick]);
1210
+ useEffect(() => {
1211
+ if (!editor) return;
1212
+ const dom = editor.view.dom;
1213
+ const onClick = (event) => {
1214
+ var _a, _b, _c;
1215
+ const target = event.target;
1216
+ const id = (_b = (_a = target == null ? void 0 : target.closest) == null ? void 0 : _a.call(target, "[data-pending-id]")) == null ? void 0 : _b.getAttribute("data-pending-id");
1217
+ if (id) (_c = onAnchorClickRef.current) == null ? void 0 : _c.call(onAnchorClickRef, id);
1218
+ };
1219
+ dom.addEventListener("click", onClick);
1220
+ return () => dom.removeEventListener("click", onClick);
1221
+ }, [editor]);
1222
+ const onSelectionChangeRef = useRef(onSelectionChange);
1223
+ useEffect(() => {
1224
+ onSelectionChangeRef.current = onSelectionChange;
1225
+ }, [onSelectionChange]);
1226
+ useEffect(() => {
1227
+ if (!editor) return;
1228
+ const onSelectionUpdate = () => {
1229
+ var _a;
1230
+ const { from, to, empty } = editor.state.selection;
1231
+ (_a = onSelectionChangeRef.current) == null ? void 0 : _a.call(onSelectionChangeRef, {
1232
+ from,
1233
+ to,
1234
+ empty,
1235
+ kind: selectionKind(editor.state.selection)
1236
+ });
1237
+ };
1238
+ editor.on("selectionUpdate", onSelectionUpdate);
1239
+ return () => {
1240
+ editor.off("selectionUpdate", onSelectionUpdate);
1241
+ };
1242
+ }, [editor]);
1125
1243
  const onCommentClickRef = useRef(onCommentClick);
1126
1244
  const onActiveCommentChangeRef = useRef(onActiveCommentChange);
1127
1245
  onCommentClickRef.current = onCommentClick;
@@ -2067,8 +2185,8 @@ function EditorToolbar({
2067
2185
  if (!coords) return;
2068
2186
  const $pos = editor.state.doc.resolve(coords.pos);
2069
2187
  let inTable = false;
2070
- for (let d2 = $pos.depth; d2 > 0; d2--) {
2071
- if ($pos.node(d2).type.name === "table") {
2188
+ for (let d = $pos.depth; d > 0; d--) {
2189
+ if ($pos.node(d).type.name === "table") {
2072
2190
  inTable = true;
2073
2191
  break;
2074
2192
  }
@@ -2984,6 +3102,121 @@ function CommentPopover({
2984
3102
  ) })
2985
3103
  ] });
2986
3104
  }
3105
+ const PROBE_ID = "__anchor-range-probe__";
3106
+ function findRangeByAnchor(editor, anchor) {
3107
+ if (!anchor || !anchor.exact) return null;
3108
+ const [result] = mapCommentAnchors(editor.state.doc, [
3109
+ { commentId: PROBE_ID, segments: [anchor] }
3110
+ ]);
3111
+ const range = result == null ? void 0 : result.ranges[0];
3112
+ if (!range) return null;
3113
+ return {
3114
+ from: range.from,
3115
+ to: range.to,
3116
+ text: editor.state.doc.textBetween(range.from, range.to, "\n\n"),
3117
+ status: result.status
3118
+ };
3119
+ }
3120
+ const BLOCK_SEPARATOR = "\n\n";
3121
+ function topLevelBlocks(editor) {
3122
+ const blocks = [];
3123
+ editor.state.doc.forEach((node, offset) => {
3124
+ blocks.push({
3125
+ json: node.toJSON(),
3126
+ from: offset,
3127
+ to: offset + node.nodeSize
3128
+ });
3129
+ });
3130
+ return blocks;
3131
+ }
3132
+ function getMarkdownForRange(editor, from, to, options = {}) {
3133
+ const manager = editor.markdown;
3134
+ if (!manager) return null;
3135
+ const size = editor.state.doc.content.size;
3136
+ let start = Math.max(0, Math.min(from, to));
3137
+ let end = Math.min(size, Math.max(from, to));
3138
+ if (start >= end) return null;
3139
+ const blocks = topLevelBlocks(editor);
3140
+ if (blocks.length === 0) return null;
3141
+ const expand = options.expandToBlocks !== false;
3142
+ let first = 0;
3143
+ let last = blocks.length - 1;
3144
+ if (expand) {
3145
+ first = blocks.findIndex((b2) => b2.to > start);
3146
+ if (first < 0) return null;
3147
+ for (let i = blocks.length - 1; i >= first; i -= 1) {
3148
+ if (blocks[i].from < end) {
3149
+ last = i;
3150
+ break;
3151
+ }
3152
+ }
3153
+ start = blocks[first].from;
3154
+ end = blocks[last].to;
3155
+ }
3156
+ const parts = blocks.map((b2) => manager.serialize({ type: "doc", content: [b2.json] }));
3157
+ const full = editor.getMarkdown();
3158
+ if (expand && parts.join(BLOCK_SEPARATOR) === full) {
3159
+ const markdown2 = parts.slice(first, last + 1).join(BLOCK_SEPARATOR);
3160
+ if (!markdown2) return null;
3161
+ const at2 = first === 0 ? 0 : parts.slice(0, first).join(BLOCK_SEPARATOR).length + BLOCK_SEPARATOR.length;
3162
+ return { markdown: markdown2, from: at2, to: at2 + markdown2.length };
3163
+ }
3164
+ const markdown = manager.serialize(
3165
+ editor.state.doc.cut(start, end).toJSON()
3166
+ );
3167
+ if (!markdown) return null;
3168
+ const at = full.indexOf(markdown);
3169
+ if (at < 0 || full.indexOf(markdown, at + 1) >= 0) return null;
3170
+ return { markdown, from: at, to: at + markdown.length };
3171
+ }
3172
+ function normalizeMarkdown(markdown) {
3173
+ if (!markdown) return markdown;
3174
+ const manager = getManager();
3175
+ return manager.serialize(manager.parse(prepareChartMarkdown(markdown)));
3176
+ }
3177
+ let sharedManager = null;
3178
+ function getManager() {
3179
+ sharedManager ?? (sharedManager = new MarkdownManager({
3180
+ extensions: [...baseExtensions, pureCodeBlock, pureImage, pureChart, CitationRef]
3181
+ }));
3182
+ return sharedManager;
3183
+ }
3184
+ function replaceRangeWithMarkdown(editor, range, markdown, options = {}) {
3185
+ const prepared = prepareChartMarkdown(
3186
+ enrichMarkdownCitations(markdown ?? "", options.sources ?? [])
3187
+ );
3188
+ if (!prepared.trim()) return false;
3189
+ let from = Math.max(0, Math.min(range.from, range.to));
3190
+ let to = Math.max(range.from, range.to);
3191
+ if (from === to) return false;
3192
+ if (tryInsertMarkdownAt(editor, from, to, prepared)) return true;
3193
+ for (const block of splitTopLevelBlocks(prepared)) {
3194
+ const before = editor.state.doc.content.size;
3195
+ const changed = tryInsertMarkdownAt(editor, from, to, block) || insertPlainParagraphAt(editor, from, to, stripToPlainText(block));
3196
+ if (!changed) continue;
3197
+ const inserted = editor.state.doc.content.size - before + (to - from);
3198
+ from += inserted;
3199
+ to = from;
3200
+ }
3201
+ return true;
3202
+ }
3203
+ function tryInsertMarkdownAt(editor, from, to, markdown) {
3204
+ try {
3205
+ return editor.chain().insertContentAt({ from, to }, markdown, { contentType: "markdown" }).run();
3206
+ } catch {
3207
+ return false;
3208
+ }
3209
+ }
3210
+ function insertPlainParagraphAt(editor, from, to, text) {
3211
+ try {
3212
+ return editor.chain().insertContentAt(
3213
+ { from, to },
3214
+ { type: "paragraph", content: text ? [{ type: "text", text }] : [] }
3215
+ ).run();
3216
+ } catch {
3217
+ return false;
3218
+ }
3219
+ }
2987
3220
  export {
2988
3221
  BoldIcon,
2989
3222
  COMMENT_ACTIVE_META,
@@ -3017,6 +3250,7 @@ export {
3017
3250
  MarkdownPreview,
3018
3251
  MarkdownWysiwygEditor,
3019
3252
  MenuIcon,
3253
+ PENDING_ANCHOR_CLASS,
3020
3254
  RedoIcon,
3021
3255
  R2 as ReportContent,
3022
3256
  R as ReportContentInteractive,
@@ -3050,7 +3284,9 @@ export {
3050
3284
  enrichMarkdownCitations,
3051
3285
  e as extractToc,
3052
3286
  f as findCitationRefElement,
3287
+ findRangeByAnchor,
3053
3288
  focusComment,
3289
+ getMarkdownForRange,
3054
3290
  insertMarkdown,
3055
3291
  looksLikeMarkdown,
3056
3292
  lowlight,
@@ -3059,13 +3295,16 @@ export {
3059
3295
  mergeCommentIntervals,
3060
3296
  m as mountChartsInContainer,
3061
3297
  nextComment,
3298
+ normalizeMarkdown,
3062
3299
  prepareChartMarkdown,
3063
- d as pureChart,
3300
+ pureChart,
3064
3301
  pureCodeBlock,
3065
3302
  pureImage,
3066
3303
  r as readCitationAttrs,
3304
+ replaceRangeWithMarkdown,
3067
3305
  reportChart,
3068
3306
  s as scrollToTocHeading,
3307
+ selectionKind,
3069
3308
  s2 as serializeChartPayload,
3070
3309
  s3 as stabilizeMarkdown,
3071
3310
  stripCommentAnchorMarks,