@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.
- package/dist/lib/browser/index.mjs +163 -68
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
- package/dist/types/src/extensions/cursor-line-margin.d.ts +8 -0
- package/dist/types/src/extensions/cursor-line-margin.d.ts.map +1 -0
- package/dist/types/src/extensions/index.d.ts +1 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
- package/dist/types/src/styles/layout.d.ts +2 -2
- package/dist/types/src/styles/layout.d.ts.map +1 -1
- package/package.json +31 -28
- package/src/components/TextEditor/hooks.stories.tsx +3 -7
- package/src/components/Toolbar/Toolbar.stories.tsx +2 -1
- package/src/components/Toolbar/Toolbar.tsx +0 -1
- package/src/extensions/cursor-line-margin.ts +92 -0
- package/src/extensions/index.ts +1 -0
- package/src/extensions/markdown/formatting.test.ts +17 -0
- package/src/extensions/markdown/formatting.ts +66 -12
- package/src/styles/layout.ts +3 -2
|
@@ -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
|
+
);
|
package/src/extensions/index.ts
CHANGED
|
@@ -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) * 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
|
-
|
|
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 |
|
|
178
|
-
let endCovered: boolean |
|
|
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 =
|
|
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 =
|
|
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 === '
|
|
267
|
-
changes.push(
|
|
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 === '
|
|
272
|
-
changes.push(
|
|
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
|
-
|
|
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
|
}),
|
package/src/styles/layout.ts
CHANGED
|
@@ -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 = '
|
|
9
|
-
export const editorFillLayoutEditor =
|
|
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';
|