@scality/core-ui 0.199.0 → 0.201.0
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/components/buttonv2/Buttonv2.component.d.ts.map +1 -1
- package/dist/components/buttonv2/Buttonv2.component.js +9 -5
- package/dist/components/buttonv2/CopyButton.component.d.ts +1 -1
- package/dist/components/buttonv2/CopyButton.component.d.ts.map +1 -1
- package/dist/components/buttonv2/CopyButton.component.js +25 -15
- package/dist/components/editor/Editor.component.d.ts +17 -0
- package/dist/components/editor/Editor.component.d.ts.map +1 -0
- package/dist/components/editor/Editor.component.js +118 -0
- package/dist/components/editor/editorTheme.d.ts +5 -0
- package/dist/components/editor/editorTheme.d.ts.map +1 -0
- package/dist/components/editor/editorTheme.js +115 -0
- package/dist/components/editor/index.d.ts +3 -0
- package/dist/components/editor/index.d.ts.map +1 -0
- package/dist/components/editor/index.js +1 -0
- package/dist/components/iconhelper/IconHelper.d.ts.map +1 -1
- package/dist/components/iconhelper/IconHelper.js +0 -1
- package/dist/components/navbar/Navbar.component.d.ts.map +1 -1
- package/dist/components/navbar/Navbar.component.js +15 -10
- package/dist/next.d.ts +2 -0
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +1 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +24 -0
- package/package.json +7 -1
- package/src/lib/components/buttonv2/Buttonv2.component.tsx +8 -5
- package/src/lib/components/buttonv2/CopyButton.component.test.tsx +252 -0
- package/src/lib/components/buttonv2/CopyButton.component.tsx +28 -21
- package/src/lib/components/editor/Editor.component.tsx +163 -0
- package/src/lib/components/editor/Editor.test.tsx +295 -0
- package/src/lib/components/editor/editorTheme.ts +126 -0
- package/src/lib/components/editor/index.ts +2 -0
- package/src/lib/components/iconhelper/IconHelper.tsx +0 -1
- package/src/lib/components/navbar/Navbar.component.tsx +15 -10
- package/src/lib/next.ts +2 -0
- package/src/lib/utils.test.ts +48 -0
- package/src/lib/utils.ts +32 -0
- package/stories/editor.stories.tsx +132 -0
|
@@ -2,6 +2,7 @@ import type { ButtonHTMLAttributes } from 'react';
|
|
|
2
2
|
import styled, { css } from 'styled-components';
|
|
3
3
|
import { spacing } from '../../spacing';
|
|
4
4
|
import { fontSize, fontWeight } from '../../style/theme';
|
|
5
|
+
import { getContrastText } from '../../utils';
|
|
5
6
|
import { Loader } from '../loader/Loader.component';
|
|
6
7
|
import { Tooltip, Props as TooltipProps } from '../tooltip/Tooltip.component';
|
|
7
8
|
|
|
@@ -71,29 +72,31 @@ export const ButtonStyled = styled.button<ButtonStyledProps>`
|
|
|
71
72
|
const brand = props.theme;
|
|
72
73
|
|
|
73
74
|
switch (props.variant) {
|
|
74
|
-
case 'primary':
|
|
75
|
+
case 'primary': {
|
|
76
|
+
const primaryTextColor = getContrastText(brand.buttonPrimary, brand.textPrimary, brand.textReverse) ?? brand.textPrimary;
|
|
75
77
|
return css`
|
|
76
78
|
background: ${brand.buttonPrimary};
|
|
77
79
|
background-clip: padding-box, border-box;
|
|
78
80
|
border: ${spacing.r1} solid transparent;
|
|
79
81
|
border-color: ${brand.buttonPrimary};
|
|
80
|
-
color: ${
|
|
82
|
+
color: ${primaryTextColor};
|
|
81
83
|
&:hover:enabled {
|
|
82
84
|
cursor: pointer;
|
|
83
85
|
border: ${spacing.r1} solid ${brand.infoPrimary};
|
|
84
|
-
color: ${
|
|
86
|
+
color: ${primaryTextColor};
|
|
85
87
|
}
|
|
86
88
|
// :focus-visible is the keyboard-only version of :focus
|
|
87
89
|
&:focus-visible:enabled {
|
|
88
90
|
${FocusVisibleStyle}
|
|
89
|
-
color: ${
|
|
91
|
+
color: ${primaryTextColor};
|
|
90
92
|
}
|
|
91
93
|
&:active:enabled {
|
|
92
94
|
cursor: pointer;
|
|
93
|
-
color: ${
|
|
95
|
+
color: ${primaryTextColor};
|
|
94
96
|
border: ${spacing.r1} solid ${brand.infoSecondary};
|
|
95
97
|
}
|
|
96
98
|
`;
|
|
99
|
+
}
|
|
97
100
|
|
|
98
101
|
case 'secondary':
|
|
99
102
|
return css`
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { getWrapper } from "../../testUtils";
|
|
6
|
+
import { CopyButton } from "./CopyButton.component";
|
|
7
|
+
|
|
8
|
+
describe("CopyButton", () => {
|
|
9
|
+
const { Wrapper } = getWrapper();
|
|
10
|
+
const originalClipboard = navigator.clipboard;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
Object.assign(navigator, {
|
|
14
|
+
clipboard: {
|
|
15
|
+
writeText: jest.fn(),
|
|
16
|
+
write: jest.fn(),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
// JSDOM doesn't define ClipboardItem; needed for copyAsHtml path. Stub only — full interface not needed for tests.
|
|
20
|
+
if (typeof globalThis.ClipboardItem === "undefined") {
|
|
21
|
+
// @ts-expect-error — intentional stub for JSDOM; global ClipboardItem type expects full interface (getType, types, etc.)
|
|
22
|
+
globalThis.ClipboardItem = class {};
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
Object.assign(navigator, { clipboard: originalClipboard });
|
|
28
|
+
jest.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("when writeText returns a rejected Promise, should enter the unsupported state", async () => {
|
|
32
|
+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
|
|
33
|
+
writeTextMock.mockRejectedValueOnce(new Error("Clipboard denied"));
|
|
34
|
+
|
|
35
|
+
render(<CopyButton textToCopy="test" />, { wrapper: Wrapper });
|
|
36
|
+
|
|
37
|
+
const button = screen.getByRole("button", { name: "Copy" });
|
|
38
|
+
await act(() => userEvent.click(button));
|
|
39
|
+
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(writeTextMock).toHaveBeenCalledWith("test");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Unsupported state: button should not show success, and not be aria-disabled
|
|
45
|
+
expect(
|
|
46
|
+
screen.queryByRole("button", { name: "Copied !" }),
|
|
47
|
+
).not.toBeInTheDocument();
|
|
48
|
+
const buttonAfterReject = screen.getByRole("button", { name: "Copy" });
|
|
49
|
+
expect(buttonAfterReject).not.toHaveAttribute("aria-disabled", "true");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("during success state click does nothing; after 2s resets to idle and click works again", async () => {
|
|
53
|
+
jest.useFakeTimers();
|
|
54
|
+
try {
|
|
55
|
+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
|
|
56
|
+
writeTextMock.mockResolvedValue(undefined);
|
|
57
|
+
|
|
58
|
+
render(<CopyButton textToCopy="test" />, { wrapper: Wrapper });
|
|
59
|
+
|
|
60
|
+
await act(() =>
|
|
61
|
+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(
|
|
66
|
+
screen.getByRole("button", { name: "Copied !" }),
|
|
67
|
+
).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(writeTextMock).toHaveBeenCalledTimes(1);
|
|
71
|
+
const inSuccess = screen.getByRole("button", { name: "Copied !" });
|
|
72
|
+
expect(inSuccess).toHaveAttribute("aria-disabled", "true");
|
|
73
|
+
|
|
74
|
+
await act(() => userEvent.click(inSuccess));
|
|
75
|
+
expect(writeTextMock).toHaveBeenCalledTimes(1);
|
|
76
|
+
|
|
77
|
+
act(() => {
|
|
78
|
+
jest.advanceTimersByTime(2000);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(
|
|
83
|
+
screen.getByRole("button", { name: "Copy" }),
|
|
84
|
+
).toBeInTheDocument();
|
|
85
|
+
expect(
|
|
86
|
+
screen.getByRole("button", { name: "Copy" }),
|
|
87
|
+
).not.toHaveAttribute("aria-disabled", "true");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await act(() =>
|
|
91
|
+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(writeTextMock).toHaveBeenCalledTimes(2);
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
jest.useRealTimers();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("standard copy (writeText): on success shows Copied ! tooltip and aria-disabled", async () => {
|
|
103
|
+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
|
|
104
|
+
writeTextMock.mockResolvedValueOnce(undefined);
|
|
105
|
+
|
|
106
|
+
render(<CopyButton textToCopy="hello" />, { wrapper: Wrapper });
|
|
107
|
+
|
|
108
|
+
await act(() =>
|
|
109
|
+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(writeTextMock).toHaveBeenCalledWith("hello");
|
|
114
|
+
const button = screen.getByRole("button", { name: "Copied !" });
|
|
115
|
+
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("copyAsHtml: calls clipboard.write and on success shows Copied ! and aria-disabled", async () => {
|
|
120
|
+
const writeMock = navigator.clipboard.write as jest.Mock;
|
|
121
|
+
writeMock.mockResolvedValueOnce(undefined);
|
|
122
|
+
|
|
123
|
+
render(<CopyButton textToCopy="<b>hi</b>" copyAsHtml />, {
|
|
124
|
+
wrapper: Wrapper,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await act(() =>
|
|
128
|
+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(writeMock).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(writeMock.mock.calls[0][0]).toHaveLength(1);
|
|
134
|
+
expect(
|
|
135
|
+
screen.getByRole("button", { name: "Copied !" }),
|
|
136
|
+
).toBeInTheDocument();
|
|
137
|
+
expect(screen.getByRole("button", { name: "Copied !" })).toHaveAttribute(
|
|
138
|
+
"aria-disabled",
|
|
139
|
+
"true",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("copyAsHtml: when write rejects, enters unsupported state", async () => {
|
|
145
|
+
const writeMock = navigator.clipboard.write as jest.Mock;
|
|
146
|
+
writeMock.mockRejectedValueOnce(new Error("Denied"));
|
|
147
|
+
|
|
148
|
+
render(<CopyButton textToCopy="<b>hi</b>" copyAsHtml />, {
|
|
149
|
+
wrapper: Wrapper,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await act(() =>
|
|
153
|
+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await waitFor(() => {
|
|
157
|
+
expect(writeMock).toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(
|
|
161
|
+
screen.queryByRole("button", { name: "Copied !" }),
|
|
162
|
+
).not.toBeInTheDocument();
|
|
163
|
+
expect(screen.getByRole("button", { name: "Copy" })).not.toHaveAttribute(
|
|
164
|
+
"aria-disabled",
|
|
165
|
+
"true",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("ghost variant without label: tooltip is Copy then Copied ! on success", async () => {
|
|
170
|
+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
|
|
171
|
+
writeTextMock.mockResolvedValueOnce(undefined);
|
|
172
|
+
|
|
173
|
+
render(<CopyButton textToCopy="x" />, { wrapper: Wrapper });
|
|
174
|
+
|
|
175
|
+
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
|
176
|
+
|
|
177
|
+
await act(() =>
|
|
178
|
+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
expect(
|
|
183
|
+
screen.getByRole("button", { name: "Copied !" }),
|
|
184
|
+
).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("ghost variant with label: tooltip is Copy {label} then Copied ! on success", async () => {
|
|
189
|
+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
|
|
190
|
+
writeTextMock.mockResolvedValueOnce(undefined);
|
|
191
|
+
|
|
192
|
+
render(<CopyButton textToCopy="x" label="key" />, { wrapper: Wrapper });
|
|
193
|
+
|
|
194
|
+
expect(
|
|
195
|
+
screen.getByRole("button", { name: "Copy key" }),
|
|
196
|
+
).toBeInTheDocument();
|
|
197
|
+
|
|
198
|
+
await act(() =>
|
|
199
|
+
userEvent.click(screen.getByRole("button", { name: "Copy key" })),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await waitFor(() => {
|
|
203
|
+
expect(
|
|
204
|
+
screen.getByRole("button", { name: "Copied !" }),
|
|
205
|
+
).toBeInTheDocument();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("outline variant without label: tooltip is Copy then Copied! on success", async () => {
|
|
210
|
+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
|
|
211
|
+
writeTextMock.mockResolvedValueOnce(undefined);
|
|
212
|
+
|
|
213
|
+
render(<CopyButton textToCopy="x" variant="outline" />, {
|
|
214
|
+
wrapper: Wrapper,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
|
218
|
+
|
|
219
|
+
await act(() =>
|
|
220
|
+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(
|
|
225
|
+
screen.getByRole("button", { name: "Copied!" }),
|
|
226
|
+
).toBeInTheDocument();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("outline variant with label: tooltip is Copy {label} then Copied {label}! on success", async () => {
|
|
231
|
+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
|
|
232
|
+
writeTextMock.mockResolvedValueOnce(undefined);
|
|
233
|
+
|
|
234
|
+
render(<CopyButton variant="outline" textToCopy="x" label="key" />, {
|
|
235
|
+
wrapper: Wrapper,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(
|
|
239
|
+
screen.getByRole("button", { name: "Copy key" }),
|
|
240
|
+
).toBeInTheDocument();
|
|
241
|
+
|
|
242
|
+
await act(() =>
|
|
243
|
+
userEvent.click(screen.getByRole("button", { name: "Copy key" })),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(
|
|
248
|
+
screen.getByRole("button", { name: "Copied key!" }),
|
|
249
|
+
).toBeInTheDocument();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { Icon } from '../icon/Icon.component';
|
|
3
|
-
import { Button, Props } from './Buttonv2.component';
|
|
3
|
+
import { Button, type Props } from './Buttonv2.component';
|
|
4
4
|
|
|
5
5
|
export const COPY_STATE_IDLE = 'idle';
|
|
6
6
|
export const COPY_STATE_SUCCESS = 'success';
|
|
@@ -8,6 +8,7 @@ export const COPY_STATE_UNSUPPORTED = 'unsupported';
|
|
|
8
8
|
export const useClipboard = () => {
|
|
9
9
|
const [copyStatus, setCopyStatus] = useState(COPY_STATE_IDLE);
|
|
10
10
|
useEffect(() => {
|
|
11
|
+
if (copyStatus === COPY_STATE_IDLE) return;
|
|
11
12
|
const timer = setTimeout(() => {
|
|
12
13
|
setCopyStatus(COPY_STATE_IDLE);
|
|
13
14
|
}, 2000);
|
|
@@ -41,8 +42,14 @@ export const useClipboard = () => {
|
|
|
41
42
|
});
|
|
42
43
|
} else {
|
|
43
44
|
// Copy as plain text only
|
|
44
|
-
navigator.clipboard
|
|
45
|
-
|
|
45
|
+
navigator.clipboard
|
|
46
|
+
.writeText(text)
|
|
47
|
+
.then(() => {
|
|
48
|
+
setCopyStatus(COPY_STATE_SUCCESS);
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
setCopyStatus(COPY_STATE_UNSUPPORTED);
|
|
52
|
+
});
|
|
46
53
|
}
|
|
47
54
|
};
|
|
48
55
|
|
|
@@ -65,45 +72,45 @@ export const CopyButton = ({
|
|
|
65
72
|
variant?: 'outline' | 'ghost';
|
|
66
73
|
} & Omit<Props, 'tooltip' | 'label'>) => {
|
|
67
74
|
const { copy, copyStatus } = useClipboard();
|
|
75
|
+
const isSuccess = copyStatus === COPY_STATE_SUCCESS;
|
|
68
76
|
return (
|
|
69
77
|
<Button
|
|
70
78
|
{...props}
|
|
71
79
|
variant={variant === 'outline' ? 'outline' : undefined}
|
|
72
80
|
style={{
|
|
81
|
+
...props.style,
|
|
82
|
+
...(isSuccess && { cursor: 'not-allowed', opacity: 0.5 }),
|
|
73
83
|
minWidth:
|
|
74
|
-
//Just to make sure the width of the button stays the same when copied!
|
|
75
84
|
variant === 'outline'
|
|
76
|
-
? (label ? label.length / 2 : 0) + 7
|
|
85
|
+
? `${(label ? label.length / 2 : 0) + 7}rem`
|
|
77
86
|
: undefined,
|
|
78
87
|
}}
|
|
79
88
|
label={
|
|
80
89
|
variant === 'outline'
|
|
81
|
-
?
|
|
82
|
-
? `Copied${label ?
|
|
83
|
-
: `Copy${label ?
|
|
90
|
+
? isSuccess
|
|
91
|
+
? `Copied${label ? ` ${label}` : ''}!`
|
|
92
|
+
: `Copy${label ? ` ${label}` : ''}`
|
|
84
93
|
: undefined
|
|
85
94
|
}
|
|
86
95
|
icon={
|
|
87
96
|
<Icon
|
|
88
|
-
name={
|
|
89
|
-
color={
|
|
90
|
-
copyStatus === COPY_STATE_SUCCESS ? 'statusHealthy' : undefined
|
|
91
|
-
}
|
|
97
|
+
name={isSuccess ? 'Check' : 'Copy'}
|
|
98
|
+
color={isSuccess ? 'statusHealthy' : undefined}
|
|
92
99
|
/>
|
|
93
100
|
}
|
|
94
|
-
disabled={
|
|
95
|
-
|
|
101
|
+
disabled={props.disabled}
|
|
102
|
+
aria-disabled={isSuccess || props.disabled}
|
|
103
|
+
onClick={() => {
|
|
104
|
+
if (!isSuccess) copy(textToCopy, copyAsHtml);
|
|
105
|
+
}}
|
|
96
106
|
type="button"
|
|
97
107
|
tooltip={
|
|
98
108
|
variant !== 'outline'
|
|
99
109
|
? {
|
|
100
|
-
overlay:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
overlayStyle: {
|
|
105
|
-
maxWidth: '20rem',
|
|
106
|
-
},
|
|
110
|
+
overlay: isSuccess
|
|
111
|
+
? 'Copied !'
|
|
112
|
+
: `Copy${label ? ` ${label}` : ''}`,
|
|
113
|
+
overlayStyle: { maxWidth: '20rem' },
|
|
107
114
|
placement: 'top',
|
|
108
115
|
}
|
|
109
116
|
: undefined
|
|
@@ -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
|
+
};
|