Skip to content

Commit 27eaac5

Browse files
committed
Merge branch 'feature/CUI-4-add-editor-component' into q/1.0
2 parents 89ba271 + df0e4b5 commit 27eaac5

File tree

8 files changed

+1666
-134
lines changed

8 files changed

+1666
-134
lines changed

package-lock.json

Lines changed: 940 additions & 134 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@
8585
}
8686
},
8787
"dependencies": {
88+
"@codemirror/lang-json": "^6.0.2",
89+
"@codemirror/language": "^6.12.2",
90+
"@codemirror/view": "^6.39.15",
8891
"@floating-ui/dom": "^1.6.3",
8992
"@floating-ui/react": "^0.27.15",
9093
"@fortawesome/fontawesome-free": "^7.1.0",
@@ -93,7 +96,10 @@
9396
"@fortawesome/free-solid-svg-icons": "^7.1.0",
9497
"@fortawesome/react-fontawesome": "^3.1.1",
9598
"@js-temporal/polyfill": "^0.4.4",
99+
"@lezer/highlight": "^1.2.3",
96100
"@storybook/preview-api": "^8.3.6",
101+
"@uiw/react-codemirror": "^4.25.5",
102+
"codemirror-json-schema": "^0.8.1",
97103
"downshift": "^7.0.5",
98104
"polished": "3.4.1",
99105
"pretty-bytes": "^5.6.0",
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { json } from '@codemirror/lang-json';
2+
import type { Extension } from '@codemirror/state';
3+
import { EditorView, ViewPlugin } from '@codemirror/view';
4+
import CodeMirror from '@uiw/react-codemirror';
5+
import { jsonSchema as jsonSchemaExtension } from 'codemirror-json-schema';
6+
import type { JSONSchema7 } from 'json-schema';
7+
import { useMemo, useRef } from 'react';
8+
import { useTheme } from 'styled-components';
9+
import type { CoreUITheme } from '../../style/theme';
10+
import { createEditorTheme } from './editorTheme';
11+
12+
const EDIT_KEYS = new Set(['Backspace', 'Delete', 'Enter', 'Tab']);
13+
14+
export function isEditAttempt(e: KeyboardEvent): boolean {
15+
const isTyping = !e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1;
16+
const isCutPaste = (e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'v');
17+
return isTyping || EDIT_KEYS.has(e.key) || isCutPaste;
18+
}
19+
20+
export function createReadOnlyTooltipExtension(): Extension {
21+
return ViewPlugin.define((view) => {
22+
let tooltip: HTMLDivElement | null = null;
23+
let hideTimer: ReturnType<typeof setTimeout> | null = null;
24+
let wrapper: HTMLDivElement | null = null;
25+
26+
const dismiss = () => {
27+
tooltip?.remove();
28+
tooltip = null;
29+
if (hideTimer) clearTimeout(hideTimer);
30+
hideTimer = null;
31+
};
32+
33+
const show = () => {
34+
if (hideTimer) clearTimeout(hideTimer);
35+
36+
const head = view.state.selection.main.head;
37+
const coords = view.coordsAtPos(head);
38+
if (!coords) return;
39+
40+
if (!wrapper) {
41+
wrapper = document.createElement('div');
42+
Object.assign(wrapper.style, {
43+
position: 'absolute',
44+
top: '0',
45+
left: '0',
46+
width: '100%',
47+
height: '100%',
48+
pointerEvents: 'none',
49+
overflow: 'visible',
50+
zIndex: '100',
51+
});
52+
view.dom.parentElement?.appendChild(wrapper);
53+
}
54+
55+
const parentRect =
56+
wrapper.offsetParent?.getBoundingClientRect() ??
57+
view.dom.getBoundingClientRect();
58+
59+
if (!tooltip) {
60+
tooltip = document.createElement('div');
61+
tooltip.className = 'cm-readonly-tooltip';
62+
tooltip.textContent = 'Cannot edit in read-only editor';
63+
tooltip.setAttribute('role', 'status');
64+
tooltip.setAttribute('aria-live', 'polite');
65+
Object.assign(tooltip.style, {
66+
position: 'absolute',
67+
padding: '4px 12px',
68+
borderRadius: '4px',
69+
fontSize: '12px',
70+
pointerEvents: 'none',
71+
whiteSpace: 'nowrap',
72+
});
73+
wrapper.appendChild(tooltip);
74+
}
75+
76+
tooltip.style.left = `${coords.left - parentRect.left}px`;
77+
tooltip.style.top = `${coords.bottom - parentRect.top + 4}px`;
78+
79+
hideTimer = setTimeout(dismiss, 2000);
80+
};
81+
82+
const handler = (e: KeyboardEvent) => {
83+
if (isEditAttempt(e)) show();
84+
};
85+
86+
view.dom.addEventListener('keydown', handler, true);
87+
88+
return {
89+
destroy() {
90+
view.dom.removeEventListener('keydown', handler, true);
91+
dismiss();
92+
wrapper?.remove();
93+
},
94+
};
95+
});
96+
}
97+
98+
export interface EditorProps {
99+
value: string;
100+
onChange?: (value: string) => void;
101+
readOnly?: boolean;
102+
language?: 'json' | { name: 'json'; schema?: JSONSchema7 };
103+
height?: string;
104+
width?: string;
105+
}
106+
107+
export const Editor = ({
108+
value,
109+
onChange,
110+
readOnly = false,
111+
language = 'json',
112+
height = '400px',
113+
width = '100%',
114+
}: EditorProps) => {
115+
const theme = useTheme() as CoreUITheme;
116+
117+
const editorTheme = useMemo(() => createEditorTheme(theme), [theme]);
118+
119+
const langName = typeof language === 'string' ? language : language.name;
120+
const schema = typeof language === 'object' ? language.schema : undefined;
121+
122+
const readOnlyTooltipExt = useRef<Extension | null>(null);
123+
if (!readOnlyTooltipExt.current) {
124+
readOnlyTooltipExt.current = createReadOnlyTooltipExtension();
125+
}
126+
127+
const extensions = useMemo(() => {
128+
const exts: Extension[] = [];
129+
if (langName === 'json') {
130+
if (schema) {
131+
exts.push(...jsonSchemaExtension(schema));
132+
} else {
133+
exts.push(json());
134+
}
135+
}
136+
if (readOnly) {
137+
exts.push(readOnlyTooltipExt.current!);
138+
}
139+
return exts;
140+
}, [langName, schema, readOnly]);
141+
142+
return (
143+
<CodeMirror
144+
value={value}
145+
height={height}
146+
width={width}
147+
extensions={extensions}
148+
onChange={onChange}
149+
readOnly={readOnly}
150+
theme={editorTheme}
151+
basicSetup={{
152+
lineNumbers: true,
153+
foldGutter: true,
154+
autocompletion: true,
155+
highlightActiveLine: true,
156+
highlightActiveLineGutter: true,
157+
indentOnInput: true,
158+
bracketMatching: true,
159+
closeBrackets: true,
160+
}}
161+
/>
162+
);
163+
};

0 commit comments

Comments
 (0)