@ai-matrx/rich-editor 0.2.10 → 0.2.11

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.11
4
+ ### Bold (and every inline mark) toggles like a real editor
5
+
6
+ - A selection that is partly bold becomes ONE bold span: the bold inside it loses its markers and one pair wraps the whole (`Alpha **bravo** delta` -> `**Alpha bravo delta**`, never `**Alpha **bravo** delta**`). A selection reaching into a bold span joins it.
7
+ - A selection inside one bold span unbolds just that part (`**a [b] c**` -> `**a** b **c**`), and the toolbar's pressed state is on there.
8
+ - Same rule for italic, strike and inline code — one command layer, every host.
9
+
10
+
3
11
  ## 0.2.10
4
12
  ### The caret survives a view switch
5
13
 
@@ -94,6 +94,83 @@ function mapPos(pos, changes, assoc) {
94
94
  function sorted(changes) {
95
95
  return [...changes].sort((a, b) => a.from - b.from || a.to - b.to);
96
96
  }
97
+ function markSpansOnLine(text, pos, spec) {
98
+ const lineStart = text.lastIndexOf("\n", pos - 1) + 1;
99
+ const nl = text.indexOf("\n", pos);
100
+ const lineEnd = nl === -1 ? text.length : nl;
101
+ const spans = [];
102
+ const open = {};
103
+ let i = lineStart;
104
+ while (i < lineEnd) {
105
+ const ch = text[i];
106
+ if (ch === "`" && !spec.chars.includes("`")) {
107
+ const close = text.indexOf("`", i + 1);
108
+ i = close === -1 || close >= lineEnd ? i + 1 : close + 1;
109
+ continue;
110
+ }
111
+ if (!spec.chars.includes(ch)) {
112
+ i += 1;
113
+ continue;
114
+ }
115
+ const len = runLength(text, i, 1, ch);
116
+ if (spec.runs.includes(len)) {
117
+ const start = open[ch];
118
+ if (start === void 0) {
119
+ open[ch] = i + len;
120
+ } else {
121
+ if (i > start) spans.push({ innerFrom: start, innerTo: i });
122
+ open[ch] = void 0;
123
+ }
124
+ }
125
+ i += len;
126
+ }
127
+ return spans;
128
+ }
129
+ function contentOf(seg, spans, spec) {
130
+ let { from, to } = seg;
131
+ for (const sp of spans) {
132
+ if (from >= sp.innerFrom - spec.size && from < sp.innerFrom) from = sp.innerFrom;
133
+ if (to > sp.innerTo && to <= sp.innerTo + spec.size) to = sp.innerTo;
134
+ }
135
+ return { from, to: Math.max(from, to) };
136
+ }
137
+ function mergeOn(seg, spans, spec, changes) {
138
+ const touched = spans.filter((sp) => sp.innerFrom <= seg.to && sp.innerTo >= seg.from);
139
+ const from = Math.min(seg.from, ...touched.map((sp) => sp.innerFrom));
140
+ const to = Math.max(seg.to, ...touched.map((sp) => sp.innerTo));
141
+ let keepOpen = false;
142
+ let keepClose = false;
143
+ for (const sp of touched) {
144
+ if (sp.innerFrom === from && !keepOpen) keepOpen = true;
145
+ else changes.push({ from: sp.innerFrom - spec.size, to: sp.innerFrom, insert: "" });
146
+ if (sp.innerTo === to && !keepClose) keepClose = true;
147
+ else changes.push({ from: sp.innerTo, to: sp.innerTo + spec.size, insert: "" });
148
+ }
149
+ if (!keepOpen) changes.push({ from, to: from, insert: spec.marker });
150
+ if (!keepClose) changes.push({ from: to, to, insert: spec.marker });
151
+ return { from, to };
152
+ }
153
+ function splitOff(text, seg, sp, spec, changes) {
154
+ if (seg.from <= sp.innerFrom) {
155
+ changes.push({ from: sp.innerFrom - spec.size, to: sp.innerFrom, insert: "" });
156
+ } else {
157
+ let at = seg.from;
158
+ while (at > sp.innerFrom && /\s/.test(text[at - 1] ?? "")) at -= 1;
159
+ if (at === sp.innerFrom) changes.push({ from: sp.innerFrom - spec.size, to: sp.innerFrom, insert: "" });
160
+ else changes.push({ from: at, to: at, insert: spec.marker });
161
+ }
162
+ if (seg.to >= sp.innerTo) {
163
+ changes.push({ from: sp.innerTo, to: sp.innerTo + spec.size, insert: "" });
164
+ } else {
165
+ let at = seg.to;
166
+ while (at < sp.innerTo && /\s/.test(text[at] ?? "")) at += 1;
167
+ if (at === sp.innerTo) changes.push({ from: sp.innerTo, to: sp.innerTo + spec.size, insert: "" });
168
+ else changes.push({ from: at, to: at, insert: spec.marker });
169
+ }
170
+ }
171
+ function spanHolding(seg, spans) {
172
+ return spans.find((sp) => sp.innerFrom <= seg.from && seg.to <= sp.innerTo) ?? null;
173
+ }
97
174
  function inlineCommand(text, from, to, spec) {
98
175
  if (from === to) {
99
176
  const word = wordAt(text, from);
@@ -109,21 +186,43 @@ function inlineCommand(text, from, to, spec) {
109
186
  const segments = inlineSegments(text, from, to);
110
187
  if (segments.length === 0) return { changes: [], anchor: from, head: to };
111
188
  const wraps = segments.map((seg) => wrapOf(text, seg, spec));
112
- const allOn = wraps.every((w) => w !== null);
189
+ if (wraps.every((w) => w !== null)) {
190
+ const changes2 = [];
191
+ wraps.forEach((w) => {
192
+ changes2.push({ from: w.innerFrom - spec.size, to: w.innerFrom, insert: "" });
193
+ changes2.push({ from: w.innerTo, to: w.innerTo + spec.size, insert: "" });
194
+ });
195
+ const ordered2 = sorted(changes2);
196
+ const anchor2 = mapPos(wraps[0].innerFrom, ordered2, 1);
197
+ const head2 = mapPos(wraps[wraps.length - 1].innerTo, ordered2, -1);
198
+ return { changes: ordered2, anchor: anchor2, head: Math.max(anchor2, head2) };
199
+ }
200
+ const lineSpans = segments.map((seg) => markSpansOnLine(text, seg.from, spec));
201
+ const contents = segments.map((seg, i) => contentOf(seg, lineSpans[i], spec));
202
+ const holders = contents.map((c, i) => wraps[i] ? null : spanHolding(c, lineSpans[i]));
203
+ const allOn = wraps.every((w, i) => w !== null || holders[i] !== null);
113
204
  const changes = [];
205
+ let startAt = contents[0].from;
206
+ let endAt = contents[contents.length - 1].to;
114
207
  segments.forEach((seg, i) => {
115
208
  const w = wraps[i];
116
- if (allOn && w) {
117
- changes.push({ from: w.innerFrom - spec.size, to: w.innerFrom, insert: "" });
118
- changes.push({ from: w.innerTo, to: w.innerTo + spec.size, insert: "" });
209
+ if (allOn) {
210
+ if (w) {
211
+ changes.push({ from: w.innerFrom - spec.size, to: w.innerFrom, insert: "" });
212
+ changes.push({ from: w.innerTo, to: w.innerTo + spec.size, insert: "" });
213
+ } else {
214
+ splitOff(text, contents[i], holders[i], spec, changes);
215
+ }
119
216
  } else if (!w) {
120
- changes.push({ from: seg.from, to: seg.from, insert: spec.marker });
121
- changes.push({ from: seg.to, to: seg.to, insert: spec.marker });
217
+ const merged = mergeOn(contents[i], lineSpans[i], spec, changes);
218
+ if (i === 0) startAt = merged.from;
219
+ if (i === segments.length - 1) endAt = merged.to;
220
+ } else {
221
+ if (i === 0) startAt = w.innerFrom;
222
+ if (i === segments.length - 1) endAt = w.innerTo;
122
223
  }
123
224
  });
124
225
  const ordered = sorted(changes);
125
- const startAt = allOn ? wraps[0].innerFrom : segments[0].from;
126
- const endAt = allOn ? wraps[wraps.length - 1].innerTo : segments[segments.length - 1].to;
127
226
  const anchor = mapPos(startAt, ordered, 1);
128
227
  const head = mapPos(endAt, ordered, -1);
129
228
  return { changes: ordered, anchor, head: Math.max(anchor, head) };
@@ -293,7 +392,12 @@ function isFormatActive(text, from, to, command) {
293
392
  const range = a === b ? wordAt(text, a) : { from: a, to: b };
294
393
  if (!range) return false;
295
394
  const segs = inlineSegments(text, range.from, range.to);
296
- return segs.length > 0 && segs.every((s) => wrapOf(text, s, INLINE[command]) !== null);
395
+ const spec = INLINE[command];
396
+ return segs.length > 0 && segs.every((s) => {
397
+ if (wrapOf(text, s, spec) !== null) return true;
398
+ const spans = markSpansOnLine(text, s.from, spec);
399
+ return spanHolding(contentOf(s, spans, spec), spans) !== null;
400
+ });
297
401
  }
298
402
  case "link":
299
403
  return WHOLE_LINK.test(text.slice(a, b)) || text[a - 1] === "[" && text.slice(b, b + 2) === "](";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-editor",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
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,11 +90,11 @@
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",
93
95
  "@ai-matrx/content-ir": "latest",
94
96
  "@ai-matrx/design-system": "latest",
95
- "@ai-matrx/alchemy": "latest",
96
97
  "@ai-matrx/kit": "latest",
97
- "@ai-matrx/agents": "latest",
98
98
  "@ai-matrx/print": "latest",
99
99
  "@ai-matrx/rich-content": "latest"
100
100
  },