@dxos/react-ui-editor 0.4.4 → 0.4.5-main.181e1ed

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,92 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ // Based upon @acheronfail’s implementation, fetched 16 Feb 2024
6
+ // https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448/5
7
+
8
+ import { type EditorState, type Extension, Facet, Transaction } from '@codemirror/state';
9
+ import { EditorView } from '@codemirror/view';
10
+
11
+ /**
12
+ * Number of lines above/below the cursor to keep visible in the viewport.
13
+ * Defaults to 3.
14
+ */
15
+ export const cursorLineMarginFacet = Facet.define<number, number>({
16
+ combine: (input) => input[0] ?? 3,
17
+ });
18
+
19
+ const annotation = 'cursorLineMargin';
20
+ const lineAtPos = (s: EditorState, pos: number) => s.doc.lineAt(pos).number;
21
+ // seems to the best approximation of CM5's `cursorScrollMargin`
22
+ // https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448
23
+ export const cursorLineMargin: Extension = EditorView.updateListener.of(
24
+ ({ transactions, state, selectionSet, startState, view }) => {
25
+ // make sure we don't trigger an infinite loop and ignore our own changes
26
+ if (transactions.length < 1 || transactions.some((tr) => tr.isUserEvent(annotation))) {
27
+ return;
28
+ }
29
+
30
+ const s = state;
31
+ const { main } = s.selection;
32
+ const cursorLineMargin = s.facet(cursorLineMarginFacet);
33
+
34
+ // editor rect
35
+ const rect = view.dom.getBoundingClientRect();
36
+ // top and bottom pixel positions of the visible region of the editor
37
+ const viewportTop = rect.top - view.documentTop;
38
+ const viewportBottom = rect.bottom - view.documentTop;
39
+
40
+ // line with main selection
41
+ const mainLine = lineAtPos(s, main.head);
42
+ // top most visible line
43
+ const _visTopLine = lineAtPos(s, view.lineBlockAtHeight(viewportTop).from);
44
+
45
+ // bottom most visible line
46
+ // NOTE: calculating this is slightly more complex - if the editor is
47
+ // larger than all the lines in it, and the `scrollPastEnd()` extension is
48
+ // enabled, then we need to calculate where the bottom most visible line
49
+ // should be if it extended all the way to the bottom of the editor
50
+ const botLineBlk = view.lineBlockAtHeight(viewportBottom);
51
+ const visBotLineDoc = lineAtPos(s, Math.min(botLineBlk.to, s.doc.length));
52
+ const visBotLine =
53
+ botLineBlk.bottom >= viewportBottom
54
+ ? visBotLineDoc
55
+ : visBotLineDoc + Math.floor((viewportBottom - botLineBlk.bottom) / view.defaultLineHeight);
56
+
57
+ // const needsScrollTop = mainLine <= visTopLine + cursorLineMargin;
58
+ const needsScrollTop = false;
59
+ const needsScrollBot = mainLine >= visBotLine - cursorLineMargin;
60
+
61
+ // the scroll margins are overlapping
62
+ if (needsScrollTop && needsScrollBot) {
63
+ console.warn('is cursorScrollMargin too large for the editor size?');
64
+ return;
65
+ }
66
+
67
+ // no need to scroll, we're in between scroll regions
68
+ if (!needsScrollTop && !needsScrollBot) {
69
+ return;
70
+ }
71
+
72
+ // if we're within the margin, but moving the other way, then don't scroll
73
+ if (selectionSet) {
74
+ const { head } = startState.selection.main;
75
+ const oldMainLine = lineAtPos(startState, head);
76
+ if (needsScrollTop && oldMainLine < mainLine) {
77
+ return;
78
+ }
79
+ if (needsScrollBot && oldMainLine > mainLine) {
80
+ return;
81
+ }
82
+ }
83
+
84
+ view.dispatch({
85
+ annotations: Transaction.userEvent.of(annotation),
86
+ effects: EditorView.scrollIntoView(s.selection.main.head, {
87
+ y: needsScrollTop ? 'start' : 'end',
88
+ yMargin: view.defaultLineHeight * cursorLineMargin,
89
+ }),
90
+ });
91
+ },
92
+ );
@@ -10,6 +10,7 @@ export * from './basic';
10
10
  export * from './blast';
11
11
  export * from './comments';
12
12
  export * from './cursor';
13
+ export * from './cursor-line-margin';
13
14
  export * from './listener';
14
15
  export * from './markdown';
15
16
  export * from './mention';
@@ -101,6 +101,8 @@ describe('setHeading', () => {
101
101
  testCommand('can add a heading inside block markup', '> - {one\n> - two}\n', setHeading(1), '> - # one\n> - # two\n');
102
102
 
103
103
  testCommand('can remove a heading inside block markup', '1. # one{}', setHeading(0), '1. one');
104
+
105
+ testCommand('can add a heading to a blank line', 'one\n\n{}', setHeading(1), 'one\n\n# {}');
104
106
  });
105
107
 
106
108
  describe('addStyle', () => {
@@ -158,6 +160,8 @@ describe('addStyle', () => {
158
160
  em,
159
161
  '*[one {two](x) ![three} four](y)* five',
160
162
  );
163
+
164
+ testCommand('can add a style to an empty line', '{}', em, '*{}*');
161
165
  });
162
166
 
163
167
  describe('removeStyle', () => {
@@ -188,6 +192,15 @@ describe('removeStyle', () => {
188
192
  );
189
193
 
190
194
  testCommand('can shrink existing styles', '*one {two three} four*', em, '*one* {two three} *four*');
195
+
196
+ testCommand('can remove strong from a partially emphasized text', '**{one*two*}**', str, '{one*two*}');
197
+
198
+ testCommand(
199
+ 'can remove strong from a partially emphasized text with markers outside',
200
+ '***{o*ne*two}***',
201
+ str,
202
+ '*{o*ne*two}*',
203
+ );
191
204
  });
192
205
 
193
206
  describe('addLink', () => {
@@ -284,6 +297,8 @@ describe('addList', () => {
284
297
  );
285
298
 
286
299
  testCommand("doesn't renumber lists with a different parent", '> one{}\n\n1. two', ordered, '> 1. one\n\n1. two');
300
+
301
+ testCommand('can add a list to an empty line', '{}', ordered, '1. {}');
287
302
  });
288
303
 
289
304
  describe('removeList', () => {
@@ -345,6 +360,8 @@ describe('addBlockquote', () => {
345
360
  addBlockquote,
346
361
  '> one\n> two\n>\n> # three\n\nfour',
347
362
  );
363
+
364
+ testCommand('can add a blockquote to an empty line', '{}', addBlockquote, '>{}');
348
365
  });
349
366
 
350
367
  describe('removeBlockquote', () => {
@@ -94,6 +94,7 @@ export const setHeading =
94
94
  const changes: ChangeSpec[] = [];
95
95
  let prevBlock = -1;
96
96
  for (const range of ranges) {
97
+ let sawBlock = false;
97
98
  syntaxTree(state).iterate({
98
99
  from: range.from,
99
100
  to: range.to,
@@ -101,6 +102,7 @@ export const setHeading =
101
102
  if (!Object.hasOwn(Textblocks, node.name) || prevBlock === node.from) {
102
103
  return;
103
104
  }
105
+ sawBlock = true;
104
106
  prevBlock = node.from;
105
107
  const blockType = Textblocks[node.name];
106
108
  const isHeading = /heading(\d)/.exec(blockType);
@@ -129,13 +131,25 @@ export const setHeading =
129
131
  }
130
132
  },
131
133
  });
134
+ let line;
135
+ if (!sawBlock && range.empty && level > 0 && !/\S/.test((line = state.doc.lineAt(range.from)).text)) {
136
+ changes.push({ from: line.from, to: line.to, insert: '#'.repeat(level) + ' ' });
137
+ }
132
138
  }
133
139
 
134
140
  if (!changes.length) {
135
141
  return false;
136
142
  }
137
143
 
138
- dispatch(state.update({ changes, userEvent: 'format.setHeading', scrollIntoView: true }));
144
+ const changeSet = state.changes(changes);
145
+ dispatch(
146
+ state.update({
147
+ changes: changeSet,
148
+ selection: state.selection.map(changeSet, 1),
149
+ userEvent: 'format.setHeading',
150
+ scrollIntoView: true,
151
+ }),
152
+ );
139
153
  return true;
140
154
  };
141
155
 
@@ -174,8 +188,8 @@ export const setStyle =
174
188
  const changesAtEnd: ChangeSpec[] = [];
175
189
  let blockStart = -1;
176
190
  let blockEnd = -1;
177
- let startCovered: boolean | 'adjacent' = false;
178
- let endCovered: boolean | 'adjacent' = false;
191
+ let startCovered: boolean | { from: number; to: number } = false;
192
+ let endCovered: boolean | { from: number; to: number } = false;
179
193
  let { from, to } = range;
180
194
  // Iterate the selected range. For each textblock, determine a
181
195
  // start and end position, the overlap of the selected range and
@@ -218,10 +232,16 @@ export const setStyle =
218
232
  // by this.
219
233
  if (markType === type) {
220
234
  if (openEnd <= from && closeStart >= from) {
221
- startCovered = openEnd === from ? 'adjacent' : true;
235
+ startCovered =
236
+ !enable && openEnd === skipMarkers(from, node.node, -1, openEnd)
237
+ ? { from: node.from, to: openEnd }
238
+ : true;
222
239
  }
223
240
  if (openEnd <= to && closeStart >= to) {
224
- endCovered = closeStart === to ? 'adjacent' : true;
241
+ endCovered =
242
+ !enable && closeStart === skipMarkers(to, node.node, 1, closeStart)
243
+ ? { from: closeStart, to: node.to }
244
+ : true;
225
245
  }
226
246
  }
227
247
  // Marks of the same type in range, or any mark if we're
@@ -263,13 +283,13 @@ export const setStyle =
263
283
  changes.push({ from: rangeEnd, insert: marker });
264
284
  }
265
285
  } else {
266
- if (startCovered === 'adjacent') {
267
- changes.push({ from: from - marker.length, to: from });
286
+ if (typeof startCovered === 'object') {
287
+ changes.push(startCovered);
268
288
  } else if (startCovered) {
269
289
  changes.push({ from: skipSpaces(rangeStart, state.doc, -1, blockStart), insert: marker });
270
290
  }
271
- if (endCovered === 'adjacent') {
272
- changes.push({ from: to, to: to + marker.length });
291
+ if (typeof endCovered === 'object') {
292
+ changes.push(endCovered);
273
293
  } else if (endCovered) {
274
294
  changes.push({ from: skipSpaces(rangeEnd, state.doc, 1, blockEnd), insert: marker });
275
295
  }
@@ -277,6 +297,12 @@ export const setStyle =
277
297
  }
278
298
  },
279
299
  });
300
+ if (blockStart < 0 && range.empty && enable && !/\S/.test(state.doc.lineAt(range.from).text)) {
301
+ return {
302
+ changes: { from: range.head, insert: marker + marker },
303
+ range: EditorSelection.cursor(range.head + marker.length),
304
+ };
305
+ }
280
306
  const changeSet = state.changes(changes.concat(changesAtEnd));
281
307
  return {
282
308
  changes: changeSet,
@@ -350,6 +376,20 @@ const skipSpaces = (pos: number, doc: Text, dir: -1 | 1, limit?: number) => {
350
376
  return pos;
351
377
  };
352
378
 
379
+ const skipMarkers = (pos: number, tree: SyntaxNode, dir: -1 | 1, limit?: number) => {
380
+ for (;;) {
381
+ const next = tree.resolve(pos, dir);
382
+ if (!IgnoreInline.has(next.name)) {
383
+ return pos;
384
+ }
385
+ const moveTo = dir < 0 ? next.from : next.to;
386
+ if (limit != null && (dir < 0 ? moveTo < limit : moveTo > limit)) {
387
+ return pos;
388
+ }
389
+ pos = moveTo;
390
+ }
391
+ };
392
+
353
393
  // TODO(burdon): Define and trigger snippets for codeblock, table, etc.
354
394
  const snippets = {
355
395
  codeblock: snippet(
@@ -515,7 +555,6 @@ export const addLink: StateCommand = ({ state, dispatch }) => {
515
555
  // Lists
516
556
  //
517
557
 
518
- // TODO(burdon): Cursor is positioned incorrectly.
519
558
  export const addList =
520
559
  (type: List): StateCommand =>
521
560
  ({ state, dispatch }) => {
@@ -654,7 +693,15 @@ export const addList =
654
693
  }
655
694
  }
656
695
 
657
- dispatch(state.update({ changes, userEvent: 'format.list.add', scrollIntoView: true }));
696
+ const changeSet = state.changes(changes);
697
+ dispatch(
698
+ state.update({
699
+ changes: changeSet,
700
+ selection: state.selection.map(changeSet, 1),
701
+ userEvent: 'format.list.add',
702
+ scrollIntoView: true,
703
+ }),
704
+ );
658
705
  return true;
659
706
  };
660
707
 
@@ -751,6 +798,7 @@ export const setBlockquote =
751
798
  const lines: Line[] = [];
752
799
  let lastBlock = -1;
753
800
  for (const { from, to } of state.selection.ranges) {
801
+ const sawBlock = false;
754
802
  syntaxTree(state).iterate({
755
803
  from,
756
804
  to,
@@ -786,6 +834,10 @@ export const setBlockquote =
786
834
  }
787
835
  },
788
836
  });
837
+ let line;
838
+ if (!sawBlock && enable && from === to && !/\S/.test((line = state.doc.lineAt(from)).text)) {
839
+ lines.push(line);
840
+ }
789
841
  }
790
842
 
791
843
  const changes: ChangeSpec[] = [];
@@ -803,9 +855,11 @@ export const setBlockquote =
803
855
  return false;
804
856
  }
805
857
 
858
+ const changeSet = state.changes(changes);
806
859
  dispatch(
807
860
  state.update({
808
- changes,
861
+ changes: changeSet,
862
+ selection: state.selection.map(changeSet, 1),
809
863
  userEvent: enable ? 'format.blockquote.add' : 'format.blockquote.remove',
810
864
  scrollIntoView: true,
811
865
  }),
@@ -5,7 +5,8 @@
5
5
  export const editorWithToolbarLayout =
6
6
  'grid grid-cols-1 grid-rows-[min-content_1fr] data-[toolbar=disabled]:grid-rows-[1fr] justify-center content-start overflow-hidden';
7
7
 
8
- export const editorFillLayoutRoot = 'min-bs-full grid';
9
- export const editorFillLayoutEditor = 'min-bs-full';
8
+ export const editorFillLayoutRoot = 'bs-full overflow-hidden grid';
9
+ export const editorFillLayoutEditor =
10
+ 'bs-full overflow-hidden [&>.cm-scroller]:bs-full [&>.cm-scroller]:overflow-y-auto';
10
11
 
11
12
  export const editorHalfViewportOverscrollContent = 'after:block after:min-bs-[50dvh] after:pointer-events-none';