@dxos/react-ui-editor 0.4.4-next.e0df51e → 0.4.5-main.2d76d4e
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 +134 -67
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/hooks.stories.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/hooks/useTextEditor.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 +27 -27
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +6 -0
- package/src/components/TextEditor/TextEditor.tsx +18 -5
- package/src/components/TextEditor/automerge.stories.tsx +9 -6
- package/src/components/TextEditor/hooks.stories.tsx +47 -17
- package/src/components/Toolbar/Toolbar.stories.tsx +2 -1
- package/src/extensions/cursor-line-margin.ts +92 -0
- package/src/extensions/index.ts +1 -0
- package/src/hooks/useTextEditor.ts +6 -2
- package/src/styles/layout.ts +3 -2
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import '@dxosTheme';
|
|
6
6
|
|
|
7
|
-
import React from 'react';
|
|
7
|
+
import React, { useMemo, useState } from 'react';
|
|
8
8
|
|
|
9
|
-
import { useThemeContext } from '@dxos/react-ui';
|
|
9
|
+
import { Toolbar as NaturalToolbar, Select, useThemeContext } from '@dxos/react-ui';
|
|
10
10
|
import { attentionSurface, mx, textBlockWidth } from '@dxos/react-ui-theme';
|
|
11
11
|
import { withTheme } from '@dxos/storybook-utils';
|
|
12
12
|
|
|
13
13
|
import { TextEditor } from './TextEditor';
|
|
14
14
|
import {
|
|
15
|
+
type EditorMode,
|
|
16
|
+
EditorModes,
|
|
15
17
|
code,
|
|
16
18
|
createMarkdownExtensions,
|
|
17
19
|
formatting,
|
|
@@ -24,7 +26,7 @@ import {
|
|
|
24
26
|
useFormattingState,
|
|
25
27
|
} from '../../extensions';
|
|
26
28
|
import { createDataExtensions, createThemeExtensions, useActionHandler, useTextEditor } from '../../hooks';
|
|
27
|
-
import { editorFillLayoutEditor,
|
|
29
|
+
import { editorFillLayoutEditor, editorWithToolbarLayout } from '../../styles';
|
|
28
30
|
import { markdownTheme } from '../../themes';
|
|
29
31
|
import translations from '../../translations';
|
|
30
32
|
import { Toolbar } from '../Toolbar';
|
|
@@ -45,17 +47,16 @@ type StoryProps = {
|
|
|
45
47
|
const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
|
|
46
48
|
const { themeMode } = useThemeContext();
|
|
47
49
|
const [formattingState, trackFormatting] = useFormattingState();
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
50
|
+
const [editorMode, setEditorMode] = useState<EditorMode>('default');
|
|
51
|
+
const extensions = useMemo(
|
|
52
|
+
() => [
|
|
53
|
+
editorMode ? EditorModes[editorMode] : [],
|
|
53
54
|
createDataExtensions({ readonly }),
|
|
54
55
|
createThemeExtensions({
|
|
55
56
|
themeMode,
|
|
56
57
|
theme: markdownTheme,
|
|
57
58
|
slots: {
|
|
58
|
-
|
|
59
|
+
content: { className: '!p-4' },
|
|
59
60
|
},
|
|
60
61
|
}),
|
|
61
62
|
// TODO(burdon): Move lineWrapping.
|
|
@@ -71,28 +72,57 @@ const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
|
|
|
71
72
|
tasklist(),
|
|
72
73
|
trackFormatting,
|
|
73
74
|
],
|
|
74
|
-
|
|
75
|
+
[editorMode, themeMode, placeholder, trackFormatting, readonly],
|
|
76
|
+
);
|
|
77
|
+
const { parentRef, view } = useTextEditor({ autoFocus, doc, extensions });
|
|
75
78
|
|
|
76
79
|
const handleAction = useActionHandler(view);
|
|
77
80
|
|
|
78
|
-
//
|
|
79
|
-
// sure if view is even guaranteed to exist at this point.
|
|
81
|
+
// TODO(marijn) This doesn't update the state on view changes.
|
|
82
|
+
// Also not sure if view is even guaranteed to exist at this point.
|
|
80
83
|
return (
|
|
81
84
|
<div role='none' className={mx('fixed inset-0', editorWithToolbarLayout)}>
|
|
82
85
|
<Toolbar.Root onAction={handleAction} state={formattingState} classNames={textBlockWidth}>
|
|
83
86
|
<Toolbar.Markdown />
|
|
87
|
+
<EditorModeToolbar editorMode={editorMode} setEditorMode={setEditorMode} />
|
|
84
88
|
</Toolbar.Root>
|
|
85
89
|
<div role='none' className='overflow-y-auto'>
|
|
86
|
-
<div
|
|
87
|
-
role='textbox'
|
|
88
|
-
className={mx(textBlockWidth, attentionSurface, editorFillLayoutRoot, 'p-4')}
|
|
89
|
-
ref={parentRef}
|
|
90
|
-
/>
|
|
90
|
+
<div role='textbox' className={mx(textBlockWidth, attentionSurface, editorFillLayoutEditor)} ref={parentRef} />
|
|
91
91
|
</div>
|
|
92
92
|
</div>
|
|
93
93
|
);
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
const EditorModeToolbar = ({
|
|
97
|
+
editorMode,
|
|
98
|
+
setEditorMode,
|
|
99
|
+
}: {
|
|
100
|
+
editorMode: EditorMode;
|
|
101
|
+
setEditorMode: (mode: EditorMode) => void;
|
|
102
|
+
}) => {
|
|
103
|
+
return (
|
|
104
|
+
<Select.Root value={editorMode} onValueChange={(value) => setEditorMode(value as EditorMode)}>
|
|
105
|
+
<NaturalToolbar.Button asChild>
|
|
106
|
+
<Select.TriggerButton variant='ghost'>{editorMode}</Select.TriggerButton>
|
|
107
|
+
</NaturalToolbar.Button>
|
|
108
|
+
<Select.Portal>
|
|
109
|
+
<Select.Content>
|
|
110
|
+
<Select.ScrollUpButton />
|
|
111
|
+
<Select.Viewport>
|
|
112
|
+
{['default', 'vim'].map((mode) => (
|
|
113
|
+
<Select.Option key={mode} value={mode}>
|
|
114
|
+
{mode}
|
|
115
|
+
</Select.Option>
|
|
116
|
+
))}
|
|
117
|
+
</Select.Viewport>
|
|
118
|
+
<Select.ScrollDownButton />
|
|
119
|
+
<Select.Arrow />
|
|
120
|
+
</Select.Content>
|
|
121
|
+
</Select.Portal>
|
|
122
|
+
</Select.Root>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
96
126
|
export default {
|
|
97
127
|
title: 'react-ui-editor/useTextEditor',
|
|
98
128
|
component: TextEditor,
|
|
@@ -76,8 +76,9 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
|
|
|
76
76
|
model={model}
|
|
77
77
|
extensions={extensions}
|
|
78
78
|
slots={{
|
|
79
|
-
root: { className: mx(textBlockWidth, editorFillLayoutRoot
|
|
79
|
+
root: { className: mx(textBlockWidth, editorFillLayoutRoot) },
|
|
80
80
|
editor: { className: editorFillLayoutEditor },
|
|
81
|
+
content: { className: '!pli-2' },
|
|
81
82
|
}}
|
|
82
83
|
/>
|
|
83
84
|
</div>
|
|
@@ -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
|
@@ -34,6 +34,7 @@ export type UseTextEditor = {
|
|
|
34
34
|
/**
|
|
35
35
|
* Hook for creating editor.
|
|
36
36
|
*/
|
|
37
|
+
// TODO(wittjosiah): Does not work in strict mode.
|
|
37
38
|
export const useTextEditor = ({
|
|
38
39
|
autoFocus,
|
|
39
40
|
scrollTo,
|
|
@@ -45,7 +46,9 @@ export const useTextEditor = ({
|
|
|
45
46
|
const onUpdate = useRef<() => void>();
|
|
46
47
|
const [view, setView] = useState<EditorView>();
|
|
47
48
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
|
|
48
50
|
useEffect(() => {
|
|
51
|
+
let view: EditorView;
|
|
49
52
|
if (parentRef.current) {
|
|
50
53
|
// https://codemirror.net/docs/ref/#state.EditorStateConfig
|
|
51
54
|
const state = EditorState.create({
|
|
@@ -64,7 +67,7 @@ export const useTextEditor = ({
|
|
|
64
67
|
});
|
|
65
68
|
|
|
66
69
|
// https://codemirror.net/docs/ref/#view.EditorViewConfig
|
|
67
|
-
|
|
70
|
+
view = new EditorView({
|
|
68
71
|
parent: parentRef.current,
|
|
69
72
|
scrollTo,
|
|
70
73
|
state,
|
|
@@ -84,7 +87,8 @@ export const useTextEditor = ({
|
|
|
84
87
|
return () => {
|
|
85
88
|
view?.destroy();
|
|
86
89
|
};
|
|
87
|
-
|
|
90
|
+
// TODO(wittjosiah): Does `parentRef` ever change? Only `.current` changes?
|
|
91
|
+
}, [parentRef, extensions]);
|
|
88
92
|
|
|
89
93
|
useEffect(() => {
|
|
90
94
|
if (view) {
|
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';
|