sonorance 0.1.0-beta.4.1
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/LICENSE +174 -0
- package/README.md +103 -0
- package/build/icon.png +0 -0
- package/package.json +86 -0
- package/skill/SKILL.md +41 -0
- package/skill/scripts/sonorance.mjs +31 -0
- package/src/azure-monitor.mjs +221 -0
- package/src/cli.mjs +315 -0
- package/src/comment-client.mjs +75 -0
- package/src/engine-default.mjs +136 -0
- package/src/feedback.mjs +151 -0
- package/src/gitignore.mjs +27 -0
- package/src/grammar.mjs +55 -0
- package/src/identity.mjs +126 -0
- package/src/otlp.mjs +166 -0
- package/src/plugins/deliberate/contribute.mjs +28 -0
- package/src/plugins/deliberate/domain.mjs +40 -0
- package/src/plugins/deliberate/frontmatter.mjs +85 -0
- package/src/plugins/deliberate/gitignore.mjs +21 -0
- package/src/plugins/deliberate/kinds.mjs +44 -0
- package/src/plugins/deliberate/markdown.mjs +42 -0
- package/src/plugins/deliberate/paths.mjs +245 -0
- package/src/plugins/deliberate/stages.mjs +27 -0
- package/src/plugins/deliberate/vault.mjs +1043 -0
- package/src/plugins.mjs +91 -0
- package/src/release-config.mjs +2 -0
- package/src/scrubber.mjs +64 -0
- package/src/server/index.mjs +993 -0
- package/src/sources.mjs +80 -0
- package/src/telemetry-schema.mjs +187 -0
- package/src/telemetry.mjs +390 -0
- package/src/ui/active-line.mjs +42 -0
- package/src/ui/app.js +3553 -0
- package/src/ui/at-mention.mjs +67 -0
- package/src/ui/comments-plugin.mjs +107 -0
- package/src/ui/diff-plugin.mjs +73 -0
- package/src/ui/editor.mjs +210 -0
- package/src/ui/index.html +1723 -0
- package/src/ui/md.mjs +233 -0
- package/src/ui/paste-md.mjs +54 -0
- package/src/ui/shell.html +1374 -0
- package/src/ui/slash.mjs +122 -0
- package/src/vault-registry.mjs +150 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// "@" mention for the Tiptap surface — type "@" to summon the agent and leave a
|
|
2
|
+
// comment for it, from the keyboard, without reaching for the mouse.
|
|
3
|
+
//
|
|
4
|
+
// In Sonorance a comment IS a message to the agent (the composer says "leave a
|
|
5
|
+
// comment for the agent to address"), so "@agent" is the natural, self-marketing
|
|
6
|
+
// trigger: it reads as addressing the AI. Built on Tiptap's Suggestion utility,
|
|
7
|
+
// mirroring slash.mjs (same self-rendered popup, no tippy).
|
|
8
|
+
//
|
|
9
|
+
// The mention is a pure TRIGGER: picking "@agent" deletes the typed "@…" (the
|
|
10
|
+
// document is never mutated with mention text) and hands off to the app, which
|
|
11
|
+
// opens the comment composer anchored to the block the caret is in. The mention
|
|
12
|
+
// list is built to grow — add "@person" here when collaboration lands.
|
|
13
|
+
import { Extension } from '@tiptap/core';
|
|
14
|
+
import { Suggestion } from '@tiptap/suggestion';
|
|
15
|
+
import { PluginKey } from '@tiptap/pm/state';
|
|
16
|
+
import { makeRenderer } from './slash.mjs';
|
|
17
|
+
|
|
18
|
+
// The mention roster. `run()` clears the typed trigger, then the app callback
|
|
19
|
+
// opens the composer on the current block (see onAtComment in app.js).
|
|
20
|
+
function targets(onComment) {
|
|
21
|
+
return [
|
|
22
|
+
{ title: '@agent — ask the agent', keys: 'agent ai ask comment', run: ({ editor, range }) => {
|
|
23
|
+
editor.chain().focus().deleteRange(range).run();
|
|
24
|
+
// Defer so ProseMirror's DOM selection settles on the (now shortened) block
|
|
25
|
+
// before the app reads it to anchor the comment.
|
|
26
|
+
setTimeout(() => onComment && onComment(), 0);
|
|
27
|
+
} },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function filter(list, query) {
|
|
32
|
+
const q = (query || '').toLowerCase().trim();
|
|
33
|
+
if (!q) return list;
|
|
34
|
+
return list.filter(c => (c.title + ' ' + c.keys).toLowerCase().includes(q));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Factory: the "@" extension needs the app's composer callback, so it's built
|
|
38
|
+
// per editor (like the comments plugin) rather than shared statically.
|
|
39
|
+
export function atMention({ onComment } = {}) {
|
|
40
|
+
const list = targets(onComment);
|
|
41
|
+
return Extension.create({
|
|
42
|
+
name: 'atMention',
|
|
43
|
+
addProseMirrorPlugins() {
|
|
44
|
+
return [
|
|
45
|
+
Suggestion({
|
|
46
|
+
editor: this.editor,
|
|
47
|
+
pluginKey: new PluginKey('atSuggestion'),
|
|
48
|
+
char: '@',
|
|
49
|
+
startOfLine: false,
|
|
50
|
+
allowSpaces: false,
|
|
51
|
+
// Fire only when "@" opens a word (start of block or after whitespace),
|
|
52
|
+
// never mid-token — so an email address (a@b) never pops the menu, and
|
|
53
|
+
// never inside a code block.
|
|
54
|
+
allow: ({ state, range }) => {
|
|
55
|
+
const $from = state.doc.resolve(range.from);
|
|
56
|
+
if ($from.parent.type.spec.code) return false;
|
|
57
|
+
const before = $from.parent.textBetween(0, $from.parentOffset, undefined, ' ');
|
|
58
|
+
return /(^|\s)@?$/.test(before);
|
|
59
|
+
},
|
|
60
|
+
items: ({ query }) => filter(list, query),
|
|
61
|
+
command: ({ editor, range, props }) => props.run({ editor, range }),
|
|
62
|
+
render: makeRenderer,
|
|
63
|
+
}),
|
|
64
|
+
];
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Comment highlighting for the Tiptap surface, as a ProseMirror plugin.
|
|
2
|
+
//
|
|
3
|
+
// The hand-rolled live-preview highlighted comment anchors by injecting <span
|
|
4
|
+
// class="cmark"> into the DOM (surroundContents). That fights ProseMirror,
|
|
5
|
+
// which owns and re-renders its DOM. The correct mechanism is a decoration
|
|
6
|
+
// set: we locate each comment's anchor (its `quote`, scoped to the section
|
|
7
|
+
// under its `heading`) in the current doc and render an inline decoration.
|
|
8
|
+
// Decorations survive edits (they re-derive from doc state) and never mutate
|
|
9
|
+
// the document.
|
|
10
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
11
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
12
|
+
|
|
13
|
+
export const commentsKey = new PluginKey('sonorance-comments');
|
|
14
|
+
|
|
15
|
+
// Flatten the doc into a plain string with a parallel map from each character
|
|
16
|
+
// index to its ProseMirror position, plus the char index at which each heading
|
|
17
|
+
// (by trimmed text) starts — so a quote can be scoped to its section.
|
|
18
|
+
function flatten(doc) {
|
|
19
|
+
let text = '';
|
|
20
|
+
const posAt = []; // posAt[i] = PM position of character i
|
|
21
|
+
const headingStart = []; // { text, at } in order
|
|
22
|
+
let curHeadingText = null, curHeadingAt = -1;
|
|
23
|
+
doc.descendants((node, pos) => {
|
|
24
|
+
if (node.type.name === 'heading') { curHeadingText = ''; curHeadingAt = text.length; return; }
|
|
25
|
+
if (node.isText) {
|
|
26
|
+
const t = node.text || '';
|
|
27
|
+
for (let i = 0; i < t.length; i++) { posAt.push(pos + i); }
|
|
28
|
+
text += t;
|
|
29
|
+
if (curHeadingText !== null) curHeadingText += t;
|
|
30
|
+
} else if (node.isBlock && text.length && text[text.length - 1] !== '\n') {
|
|
31
|
+
// block boundary — keep quotes from spanning across blocks
|
|
32
|
+
text += '\n'; posAt.push(pos);
|
|
33
|
+
if (curHeadingText !== null && curHeadingText.trim()) {
|
|
34
|
+
headingStart.push({ text: curHeadingText.trim(), at: curHeadingAt });
|
|
35
|
+
curHeadingText = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
if (curHeadingText !== null && curHeadingText.trim()) headingStart.push({ text: curHeadingText.trim(), at: curHeadingAt });
|
|
40
|
+
return { text, posAt, headingStart };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Locate a comment anchor -> { from, to } PM positions, or null. Prefers a
|
|
44
|
+
// quote match scoped to its heading's section; falls back to a whole-doc quote
|
|
45
|
+
// search, then to the heading itself.
|
|
46
|
+
export function findRange(doc, anchor) {
|
|
47
|
+
if (!anchor) return null;
|
|
48
|
+
const { text, posAt, headingStart } = flatten(doc);
|
|
49
|
+
const quote = (anchor.quote || '').trim();
|
|
50
|
+
const heading = (anchor.heading || '').trim();
|
|
51
|
+
|
|
52
|
+
let scopeStart = 0;
|
|
53
|
+
if (heading) {
|
|
54
|
+
const h = headingStart.find(x => x.text === heading);
|
|
55
|
+
if (h) scopeStart = h.at;
|
|
56
|
+
}
|
|
57
|
+
if (quote && quote.length >= 2) {
|
|
58
|
+
let idx = text.indexOf(quote, scopeStart);
|
|
59
|
+
if (idx < 0) idx = text.indexOf(quote); // fall back to a global search
|
|
60
|
+
if (idx >= 0 && posAt[idx] != null) {
|
|
61
|
+
const from = posAt[idx];
|
|
62
|
+
const to = (posAt[idx + quote.length - 1] ?? from) + 1;
|
|
63
|
+
return { from, to };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// No usable quote: highlight the heading line if we found it.
|
|
67
|
+
if (heading) {
|
|
68
|
+
const h = headingStart.find(x => x.text === heading);
|
|
69
|
+
if (h && posAt[h.at] != null) return { from: posAt[h.at], to: (posAt[h.at + heading.length - 1] ?? posAt[h.at]) + 1 };
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildDecorations(doc, comments) {
|
|
75
|
+
const decos = [];
|
|
76
|
+
for (const c of comments || []) {
|
|
77
|
+
const r = findRange(doc, c.anchor);
|
|
78
|
+
if (!r || r.to <= r.from) continue;
|
|
79
|
+
decos.push(Decoration.inline(r.from, r.to, { class: 'cmark', 'data-comment': c.id }));
|
|
80
|
+
}
|
|
81
|
+
return DecorationSet.create(doc, decos);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A Tiptap-compatible ProseMirror plugin. `onClick(id)` fires when a highlight
|
|
85
|
+
// is clicked (to focus the matching comment card).
|
|
86
|
+
export function commentsPlugin({ onClick } = {}) {
|
|
87
|
+
return new Plugin({
|
|
88
|
+
key: commentsKey,
|
|
89
|
+
state: {
|
|
90
|
+
init: () => ({ comments: [], decos: DecorationSet.empty }),
|
|
91
|
+
apply(tr, value) {
|
|
92
|
+
const meta = tr.getMeta(commentsKey);
|
|
93
|
+
if (meta && meta.comments) return { comments: meta.comments, decos: buildDecorations(tr.doc, meta.comments) };
|
|
94
|
+
if (tr.docChanged) return { comments: value.comments, decos: buildDecorations(tr.doc, value.comments) };
|
|
95
|
+
return value;
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
props: {
|
|
99
|
+
decorations(state) { return commentsKey.getState(state)?.decos; },
|
|
100
|
+
handleClick(view, pos, event) {
|
|
101
|
+
const el = event.target.closest?.('.cmark');
|
|
102
|
+
if (el && el.dataset.comment) { onClick?.(el.dataset.comment); return true; }
|
|
103
|
+
return false;
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Change decorations for the Tiptap surface, as a ProseMirror plugin.
|
|
2
|
+
//
|
|
3
|
+
// The hand-rolled live-preview decorated diffs by mutating the rendered DOM
|
|
4
|
+
// (inserting `.diffdel` islands, toggling `.diff-add`/`.diff-mod` on `.blk`
|
|
5
|
+
// elements). That fights ProseMirror, which owns its DOM. Here the diff is a
|
|
6
|
+
// decoration set instead: added/modified top-level blocks get a NODE
|
|
7
|
+
// decoration (a class), and removed blocks are shown as WIDGET decorations
|
|
8
|
+
// (read-only red islands rendered from pre-computed HTML) before the block
|
|
9
|
+
// that follows them.
|
|
10
|
+
//
|
|
11
|
+
// The diff itself is computed in the app (over serialized Markdown, reusing the
|
|
12
|
+
// same LCS the live-preview path uses) and handed in as instructions keyed by
|
|
13
|
+
// top-level block index — this module only turns those into decorations, and
|
|
14
|
+
// bails safely (renders nothing) if the block count no longer lines up with the
|
|
15
|
+
// document (e.g. mid-edit), exactly like the live-preview `diffWorkUnits` guard.
|
|
16
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
17
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
18
|
+
|
|
19
|
+
export const diffKey = new PluginKey('sonorance-diff');
|
|
20
|
+
|
|
21
|
+
// Absolute positions of each top-level block node, in document order.
|
|
22
|
+
function topLevelPositions(doc) {
|
|
23
|
+
const out = [];
|
|
24
|
+
doc.forEach((node, offset) => { out.push({ pos: offset, size: node.nodeSize }); });
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildDecorations(doc, diff) {
|
|
29
|
+
if (!diff) return DecorationSet.empty;
|
|
30
|
+
const { nodeClasses = [], deletions = [], count } = diff;
|
|
31
|
+
const blocks = topLevelPositions(doc);
|
|
32
|
+
// Bail (show nothing) if the document no longer matches the diff we were
|
|
33
|
+
// handed — a transient mid-edit state; the app re-derives on the next tick.
|
|
34
|
+
if (typeof count === 'number' && blocks.length !== count) return DecorationSet.empty;
|
|
35
|
+
|
|
36
|
+
const decos = [];
|
|
37
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
38
|
+
const cls = nodeClasses[i];
|
|
39
|
+
if (cls) decos.push(Decoration.node(blocks[i].pos, blocks[i].pos + blocks[i].size, { class: cls }));
|
|
40
|
+
}
|
|
41
|
+
for (const d of deletions || []) {
|
|
42
|
+
const at = d.at;
|
|
43
|
+
const pos = at >= 0 && at < blocks.length ? blocks[at].pos
|
|
44
|
+
: (blocks.length ? blocks[blocks.length - 1].pos + blocks[blocks.length - 1].size : 0);
|
|
45
|
+
const side = at >= 0 && at < blocks.length ? -1 : 1;
|
|
46
|
+
decos.push(Decoration.widget(pos, () => {
|
|
47
|
+
const wrap = document.createElement('div');
|
|
48
|
+
wrap.className = 'diffdel-wrap';
|
|
49
|
+
wrap.setAttribute('contenteditable', 'false');
|
|
50
|
+
wrap.innerHTML = d.html || '';
|
|
51
|
+
return wrap;
|
|
52
|
+
}, { side, ignoreSelection: true, key: 'del-' + at + '-' + (d.key || '') }));
|
|
53
|
+
}
|
|
54
|
+
return DecorationSet.create(doc, decos);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function diffPlugin() {
|
|
58
|
+
return new Plugin({
|
|
59
|
+
key: diffKey,
|
|
60
|
+
state: {
|
|
61
|
+
init: () => ({ diff: null, decos: DecorationSet.empty }),
|
|
62
|
+
apply(tr, value) {
|
|
63
|
+
const meta = tr.getMeta(diffKey);
|
|
64
|
+
if (meta !== undefined) return { diff: meta, decos: buildDecorations(tr.doc, meta) };
|
|
65
|
+
if (tr.docChanged) return { diff: value.diff, decos: buildDecorations(tr.doc, value.diff) };
|
|
66
|
+
return value;
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
props: {
|
|
70
|
+
decorations(state) { return diffKey.getState(state)?.decos; },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The Sonorance editor core — a thin controller over a Tiptap/ProseMirror
|
|
2
|
+
// editor. The app talks to this module, never to Tiptap directly, so the rest
|
|
3
|
+
// of the UI (open/save, ToC, comments, diff) has one stable seam.
|
|
4
|
+
//
|
|
5
|
+
// Markdown is the on-disk format; the editor's live truth is a ProseMirror
|
|
6
|
+
// doc. Leading YAML frontmatter is split out on load (kept verbatim, hidden)
|
|
7
|
+
// and re-attached on serialize. See md.mjs for the bridge.
|
|
8
|
+
import { Editor } from '@tiptap/core';
|
|
9
|
+
import { StarterKit } from '@tiptap/starter-kit';
|
|
10
|
+
import { Link } from '@tiptap/extension-link';
|
|
11
|
+
import { TaskList } from '@tiptap/extension-task-list';
|
|
12
|
+
import { TaskItem } from '@tiptap/extension-task-item';
|
|
13
|
+
import { TableKit } from '@tiptap/extension-table';
|
|
14
|
+
import { Placeholder } from '@tiptap/extension-placeholder';
|
|
15
|
+
import { Extension } from '@tiptap/core';
|
|
16
|
+
import { mdToDoc, docToMd } from './md.mjs';
|
|
17
|
+
import { commentsPlugin, commentsKey, findRange } from './comments-plugin.mjs';
|
|
18
|
+
import { diffPlugin, diffKey } from './diff-plugin.mjs';
|
|
19
|
+
import { activeLinePlugin } from './active-line.mjs';
|
|
20
|
+
import { SlashCommands } from './slash.mjs';
|
|
21
|
+
import { atMention } from './at-mention.mjs';
|
|
22
|
+
import { markdownPastePlugin } from './paste-md.mjs';
|
|
23
|
+
|
|
24
|
+
// Slug a heading's text into a stable id (matches the old reindexHeadings so
|
|
25
|
+
// ToC links + comment heading-anchors keep working).
|
|
26
|
+
function slug(text) {
|
|
27
|
+
return String(text).toLowerCase().trim()
|
|
28
|
+
.replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'section';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function textOf(node) {
|
|
32
|
+
let out = '';
|
|
33
|
+
node.descendants?.((n) => { if (n.isText) out += n.text; });
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Split a Markdown string into its top-level block sources, normalized through
|
|
38
|
+
// the SAME PM-JSON -> Markdown path the live editor uses (so a block's text is
|
|
39
|
+
// byte-identical whether it comes from the committed base here or from the live
|
|
40
|
+
// document via blockSources()). Used by the app's WYSIWYG diff. Frontmatter is
|
|
41
|
+
// dropped (it isn't a document block).
|
|
42
|
+
export function mdBlocks(md) {
|
|
43
|
+
const { doc } = mdToDoc(md || '');
|
|
44
|
+
const kids = (doc && doc.content) || [];
|
|
45
|
+
return kids.map((node) => docToMd({ type: 'doc', content: [node] }, '').trimEnd());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function baseExtensions({ placeholder } = {}) {
|
|
49
|
+
const ext = [
|
|
50
|
+
StarterKit.configure({ link: false }),
|
|
51
|
+
Link.configure({ openOnClick: false, autolink: true }),
|
|
52
|
+
TaskList,
|
|
53
|
+
TaskItem.configure({ nested: true }),
|
|
54
|
+
TableKit.configure({ table: { resizable: true } }),
|
|
55
|
+
SlashCommands,
|
|
56
|
+
];
|
|
57
|
+
if (placeholder) ext.push(Placeholder.configure({
|
|
58
|
+
// Per-block hint (only the block with the caret, see showOnlyCurrent): the
|
|
59
|
+
// empty document gets the passed prompt; any other empty block advertises the
|
|
60
|
+
// two keyboard affordances so "/" and "@" are discoverable in place.
|
|
61
|
+
includeChildren: false,
|
|
62
|
+
showOnlyCurrent: true,
|
|
63
|
+
placeholder: ({ editor, node }) => {
|
|
64
|
+
if (editor.isEmpty) return placeholder;
|
|
65
|
+
if (node.type.name === 'paragraph') return 'Type “/” for blocks, “@” to ask the agent…';
|
|
66
|
+
return '';
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
return ext;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createEditor({ element, editable = true, placeholder, onChange, onSelectionChange, onCommentClick, onAtComment, onLinkClick, extensions } = {}) {
|
|
73
|
+
let frontmatter = '';
|
|
74
|
+
|
|
75
|
+
const commentExt = Extension.create({
|
|
76
|
+
name: 'sonoranceComments',
|
|
77
|
+
addProseMirrorPlugins() { return [commentsPlugin({ onClick: onCommentClick }), diffPlugin(), activeLinePlugin(), markdownPastePlugin()]; },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const editor = new Editor({
|
|
81
|
+
element,
|
|
82
|
+
editable,
|
|
83
|
+
extensions: (extensions || baseExtensions({ placeholder })).concat(commentExt, atMention({ onComment: onAtComment })),
|
|
84
|
+
content: { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
85
|
+
// Links stay `openOnClick: false` (so a plain click still places the caret for editing the
|
|
86
|
+
// surrounding text), but a click that lands ON the anchor jumps out. The app handler opens
|
|
87
|
+
// external targets in a new tab and scrolls in-page `#anchor` links.
|
|
88
|
+
editorProps: {
|
|
89
|
+
handleClick(view, pos, event) {
|
|
90
|
+
const a = event.target && event.target.closest && event.target.closest('a[href]');
|
|
91
|
+
if (a && onLinkClick) { onLinkClick(a, event); return true; }
|
|
92
|
+
return false;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
onUpdate: () => { onChange?.(); },
|
|
96
|
+
onSelectionUpdate: () => { onSelectionChange?.(); },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
editor,
|
|
101
|
+
|
|
102
|
+
// Load a Markdown string (frontmatter split off + remembered).
|
|
103
|
+
setMarkdown(md, { emit = false } = {}) {
|
|
104
|
+
const { frontmatter: fm, doc } = mdToDoc(md || '');
|
|
105
|
+
frontmatter = fm;
|
|
106
|
+
editor.commands.setContent(doc, { emitUpdate: emit });
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Serialize the current doc back to Markdown (frontmatter re-attached).
|
|
110
|
+
getMarkdown() {
|
|
111
|
+
return docToMd(editor.getJSON(), frontmatter);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
frontmatter() { return frontmatter; },
|
|
115
|
+
setFrontmatter(fm) { frontmatter = fm || ''; },
|
|
116
|
+
|
|
117
|
+
setEditable(on) { editor.setEditable(!!on); },
|
|
118
|
+
isEditable() { return editor.isEditable; },
|
|
119
|
+
focus() { editor.commands.focus(); },
|
|
120
|
+
hasFocus() { try { return editor.view.hasFocus(); } catch { return false; } },
|
|
121
|
+
isEmpty() { return editor.isEmpty; },
|
|
122
|
+
|
|
123
|
+
// Whether a mark/node is active at the current selection — for lighting the
|
|
124
|
+
// bubble-menu buttons (bold/italic/link/H2) and detecting a table context.
|
|
125
|
+
isActive(name, attrs) { try { return editor.isActive(name, attrs); } catch { return false; } },
|
|
126
|
+
|
|
127
|
+
// Inline formatting toggles from the selection bubble menu (thin wrappers so
|
|
128
|
+
// the app never reaches into Tiptap directly). Each keeps the selection.
|
|
129
|
+
toggleBold() { editor.chain().focus().toggleBold().run(); },
|
|
130
|
+
toggleItalic() { editor.chain().focus().toggleItalic().run(); },
|
|
131
|
+
toggleHeading(level = 2) { editor.chain().focus().toggleHeading({ level }).run(); },
|
|
132
|
+
// Set (or clear when href is empty) a link on the current selection.
|
|
133
|
+
setLink(href) {
|
|
134
|
+
const h = (href || '').trim();
|
|
135
|
+
if (!h) editor.chain().focus().unsetLink().run();
|
|
136
|
+
else editor.chain().focus().setLink({ href: h }).run();
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// Table structure edits from the in-table toolbar.
|
|
140
|
+
addRow() { editor.chain().focus().addRowAfter().run(); },
|
|
141
|
+
addColumn() { editor.chain().focus().addColumnAfter().run(); },
|
|
142
|
+
deleteRow() { editor.chain().focus().deleteRow().run(); },
|
|
143
|
+
deleteColumn() { editor.chain().focus().deleteColumn().run(); },
|
|
144
|
+
deleteTable() { editor.chain().focus().deleteTable().run(); },
|
|
145
|
+
|
|
146
|
+
// Show a set of comments as inline highlight decorations (no DOM mutation).
|
|
147
|
+
setComments(comments) {
|
|
148
|
+
const tr = editor.state.tr.setMeta(commentsKey, { comments: comments || [] });
|
|
149
|
+
editor.view.dispatch(tr);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// Show change decorations (added/modified block classes + removed-block
|
|
153
|
+
// widget islands). Pass null to clear. See diff-plugin.mjs for the shape.
|
|
154
|
+
setDiff(diff) {
|
|
155
|
+
const tr = editor.state.tr.setMeta(diffKey, diff || null);
|
|
156
|
+
editor.view.dispatch(tr);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// The top-level block sources of the current doc, in order — the unit the
|
|
160
|
+
// app's diff compares against the committed base. Serialized per block so
|
|
161
|
+
// block index lines up 1:1 with the ProseMirror top-level nodes.
|
|
162
|
+
blockSources() {
|
|
163
|
+
const json = editor.getJSON();
|
|
164
|
+
const kids = (json && json.content) || [];
|
|
165
|
+
return kids.map((node) => docToMd({ type: 'doc', content: [node] }, '').trimEnd());
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// The PM range { from, to } a comment anchors to in the current doc, or null.
|
|
169
|
+
commentRange(anchor) { return findRange(editor.state.doc, anchor); },
|
|
170
|
+
|
|
171
|
+
// The DOM rect of a comment's anchor (for placing/scrolling its card).
|
|
172
|
+
commentRect(anchor) {
|
|
173
|
+
const r = findRange(editor.state.doc, anchor);
|
|
174
|
+
if (!r) return null;
|
|
175
|
+
try { const s = editor.view.coordsAtPos(r.from); return { top: s.top, bottom: s.bottom, left: s.left }; } catch { return null; }
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
// The anchor { quote, heading } for the current (non-empty) selection, or
|
|
179
|
+
// null when the selection is collapsed. Used to create a new comment.
|
|
180
|
+
selectionAnchor() {
|
|
181
|
+
const { from, to, empty } = editor.state.selection;
|
|
182
|
+
if (empty || to <= from) return null;
|
|
183
|
+
const quote = editor.state.doc.textBetween(from, to, ' ').trim();
|
|
184
|
+
if (!quote) return null;
|
|
185
|
+
let heading = '';
|
|
186
|
+
editor.state.doc.nodesBetween(0, from, (node, pos) => {
|
|
187
|
+
if (node.type.name === 'heading' && pos <= from) heading = node.textContent;
|
|
188
|
+
});
|
|
189
|
+
return { quote: quote.slice(0, 2000), heading: heading.trim() };
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// Heading list for the ToC / scroll-spy, with stable, de-duplicated ids.
|
|
193
|
+
headings() {
|
|
194
|
+
const out = [];
|
|
195
|
+
const seen = new Map();
|
|
196
|
+
editor.state.doc.descendants((node) => {
|
|
197
|
+
if (node.type.name !== 'heading') return;
|
|
198
|
+
const text = textOf(node);
|
|
199
|
+
let id = slug(text);
|
|
200
|
+
const n = (seen.get(id) || 0) + 1;
|
|
201
|
+
seen.set(id, n);
|
|
202
|
+
if (n > 1) id = `${id}-${n}`;
|
|
203
|
+
out.push({ level: node.attrs.level, text, id });
|
|
204
|
+
});
|
|
205
|
+
return out;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
destroy() { editor.destroy(); },
|
|
209
|
+
};
|
|
210
|
+
}
|