onchain-lexical-ui 0.0.1

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,252 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+ /* eslint-disable lexical/no-optional-chaining */
9
+
10
+ import type {
11
+ AppState,
12
+ BinaryFiles,
13
+ ExcalidrawImperativeAPI,
14
+ ExcalidrawInitialDataState,
15
+ } from '@excalidraw/excalidraw/types';
16
+ import type {JSX} from 'react';
17
+
18
+ import {Excalidraw} from '@excalidraw/excalidraw';
19
+ import {isDOMNode} from 'lexical';
20
+ import * as React from 'react';
21
+ import {ReactPortal, useEffect, useLayoutEffect, useRef, useState} from 'react';
22
+ import {createPortal} from 'react-dom';
23
+
24
+ import Button from './Button';
25
+ import Styles from './ExcalidrawModal.module.less';
26
+ import Modal from './Modal';
27
+
28
+ export type ExcalidrawInitialElements = ExcalidrawInitialDataState['elements'];
29
+
30
+ type Props = {
31
+ closeOnClickOutside?: boolean;
32
+ /**
33
+ * The initial set of elements to draw into the scene
34
+ */
35
+ initialElements: ExcalidrawInitialElements;
36
+ /**
37
+ * The initial set of elements to draw into the scene
38
+ */
39
+ initialAppState: AppState;
40
+ /**
41
+ * The initial set of elements to draw into the scene
42
+ */
43
+ initialFiles: BinaryFiles;
44
+ /**
45
+ * Controls the visibility of the modal
46
+ */
47
+ isShown?: boolean;
48
+ /**
49
+ * Callback when closing and discarding the new changes
50
+ */
51
+ onClose: () => void;
52
+ /**
53
+ * Completely remove Excalidraw component
54
+ */
55
+ onDelete: () => void;
56
+ /**
57
+ * Callback when the save button is clicked
58
+ */
59
+ onSave: (
60
+ elements: ExcalidrawInitialElements,
61
+ appState: Partial<AppState>,
62
+ files: BinaryFiles,
63
+ ) => void;
64
+ };
65
+
66
+ export const useCallbackRefState = () => {
67
+ const [refValue, setRefValue] =
68
+ React.useState<ExcalidrawImperativeAPI | null>(null);
69
+ const refCallback = React.useCallback(
70
+ (value: ExcalidrawImperativeAPI | null) => setRefValue(value),
71
+ [],
72
+ );
73
+ return [refValue, refCallback] as const;
74
+ };
75
+
76
+ /**
77
+ * @explorer-desc
78
+ * A component which renders a modal with Excalidraw (a painting app)
79
+ * which can be used to export an editable image
80
+ */
81
+ export default function ExcalidrawModal({
82
+ closeOnClickOutside = false,
83
+ onSave,
84
+ initialElements,
85
+ initialAppState,
86
+ initialFiles,
87
+ isShown = false,
88
+ onDelete,
89
+ onClose,
90
+ }: Props): ReactPortal | null {
91
+ const excaliDrawModelRef = useRef<HTMLDivElement | null>(null);
92
+ const [excalidrawAPI, excalidrawAPIRefCallback] = useCallbackRefState();
93
+ const [discardModalOpen, setDiscardModalOpen] = useState(false);
94
+ const [elements, setElements] =
95
+ useState<ExcalidrawInitialElements>(initialElements);
96
+ const [files, setFiles] = useState<BinaryFiles>(initialFiles);
97
+
98
+ useEffect(() => {
99
+ if (excaliDrawModelRef.current !== null) {
100
+ excaliDrawModelRef.current.focus();
101
+ }
102
+ }, []);
103
+
104
+ useEffect(() => {
105
+ let modalOverlayElement: HTMLElement | null = null;
106
+
107
+ const clickOutsideHandler = (event: MouseEvent) => {
108
+ const target = event.target;
109
+ if (
110
+ excaliDrawModelRef.current !== null &&
111
+ isDOMNode(target) &&
112
+ !excaliDrawModelRef.current.contains(target) &&
113
+ closeOnClickOutside
114
+ ) {
115
+ onDelete();
116
+ }
117
+ };
118
+
119
+ if (excaliDrawModelRef.current !== null) {
120
+ modalOverlayElement = excaliDrawModelRef.current?.parentElement;
121
+ if (modalOverlayElement !== null) {
122
+ modalOverlayElement?.addEventListener('click', clickOutsideHandler);
123
+ }
124
+ }
125
+
126
+ return () => {
127
+ if (modalOverlayElement !== null) {
128
+ modalOverlayElement?.removeEventListener('click', clickOutsideHandler);
129
+ }
130
+ };
131
+ }, [closeOnClickOutside, onDelete]);
132
+
133
+ useLayoutEffect(() => {
134
+ const currentModalRef = excaliDrawModelRef.current;
135
+
136
+ const onKeyDown = (event: KeyboardEvent) => {
137
+ if (event.key === 'Escape') {
138
+ onDelete();
139
+ }
140
+ };
141
+
142
+ if (currentModalRef !== null) {
143
+ currentModalRef.addEventListener('keydown', onKeyDown);
144
+ }
145
+
146
+ return () => {
147
+ if (currentModalRef !== null) {
148
+ currentModalRef.removeEventListener('keydown', onKeyDown);
149
+ }
150
+ };
151
+ }, [elements, files, onDelete]);
152
+
153
+ const save = () => {
154
+ if (elements && elements.filter((el) => !el.isDeleted).length > 0) {
155
+ const appState = excalidrawAPI?.getAppState();
156
+ // We only need a subset of the state
157
+ const partialState: Partial<AppState> = {
158
+ exportBackground: appState?.exportBackground,
159
+ exportScale: appState?.exportScale,
160
+ exportWithDarkMode: appState?.theme === 'dark',
161
+ isBindingEnabled: appState?.isBindingEnabled,
162
+ isLoading: appState?.isLoading,
163
+ name: appState?.name,
164
+ theme: appState?.theme,
165
+ viewBackgroundColor: appState?.viewBackgroundColor,
166
+ viewModeEnabled: appState?.viewModeEnabled,
167
+ zenModeEnabled: appState?.zenModeEnabled,
168
+ zoom: appState?.zoom,
169
+ };
170
+ onSave(elements, partialState, files);
171
+ } else {
172
+ // delete node if the scene is clear
173
+ onDelete();
174
+ }
175
+ };
176
+
177
+ const discard = () => {
178
+ setDiscardModalOpen(true);
179
+ };
180
+
181
+ function ShowDiscardDialog(): JSX.Element {
182
+ return (
183
+ <Modal
184
+ title="Discard"
185
+ onClose={() => {
186
+ setDiscardModalOpen(false);
187
+ }}
188
+ closeOnClickOutside={false}>
189
+ Are you sure you want to discard the changes?
190
+ <div className={Styles.ExcalidrawModal__discardModal}>
191
+ <Button
192
+ onClick={() => {
193
+ setDiscardModalOpen(false);
194
+ onClose();
195
+ }}>
196
+ Discard
197
+ </Button>{' '}
198
+ <Button
199
+ onClick={() => {
200
+ setDiscardModalOpen(false);
201
+ }}>
202
+ Cancel
203
+ </Button>
204
+ </div>
205
+ </Modal>
206
+ );
207
+ }
208
+
209
+ if (isShown === false) {
210
+ return null;
211
+ }
212
+
213
+ const onChange = (
214
+ els: ExcalidrawInitialElements,
215
+ _: AppState,
216
+ fls: BinaryFiles,
217
+ ) => {
218
+ setElements(els);
219
+ setFiles(fls);
220
+ };
221
+
222
+ return createPortal(
223
+ <div className={Styles.ExcalidrawModal__overlay} role="dialog">
224
+ <div
225
+ className={Styles.ExcalidrawModal__modal}
226
+ ref={excaliDrawModelRef}
227
+ tabIndex={-1}>
228
+ <div className={Styles.ExcalidrawModal__row}>
229
+ {discardModalOpen && <ShowDiscardDialog />}
230
+ <Excalidraw
231
+ onChange={onChange}
232
+ excalidrawAPI={excalidrawAPIRefCallback}
233
+ initialData={{
234
+ appState: initialAppState || {isLoading: false},
235
+ elements: initialElements,
236
+ files: initialFiles,
237
+ }}
238
+ />
239
+ <div className={Styles.ExcalidrawModal__actions}>
240
+ <button className="action-button" onClick={discard}>
241
+ Discard
242
+ </button>
243
+ <button className="action-button" onClick={save}>
244
+ Save
245
+ </button>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>,
250
+ document.body,
251
+ );
252
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {JSX} from 'react';
10
+
11
+ import * as React from 'react';
12
+
13
+ import Styles from './Input.module.less';
14
+
15
+ type Props = Readonly<{
16
+ 'data-test-id'?: string;
17
+ accept?: string;
18
+ label: string;
19
+ onChange: (files: FileList | null) => void;
20
+ }>;
21
+
22
+ export default function FileInput({
23
+ accept,
24
+ label,
25
+ onChange,
26
+ 'data-test-id': dataTestId,
27
+ }: Props): JSX.Element {
28
+ return (
29
+ <div className={Styles.Input__wrapper}>
30
+ <label className={Styles.Input__label}>{label}</label>
31
+ <input
32
+ type="file"
33
+ accept={accept}
34
+ className={Styles.Input__input}
35
+ onChange={(e) => onChange(e.target.files)}
36
+ data-test-id={dataTestId}
37
+ />
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ *
8
+ */
9
+
10
+ .FlashMessage__overlay {
11
+ display: flex;
12
+ justify-content: center;
13
+ align-items: center;
14
+ position: fixed;
15
+ pointer-events: none;
16
+ top: 0px;
17
+ bottom: 0px;
18
+ left: 0px;
19
+ right: 0px;
20
+ }
21
+ .FlashMessage__alert {
22
+ background-color: rgba(0, 0, 0, 0.8);
23
+ color: white;
24
+ padding: 20px;
25
+ font-size: 1.5rem;
26
+ border-radius: 1em;
27
+ padding: 0.5em 1.5em;
28
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {JSX} from 'react';
10
+
11
+ import {ReactNode} from 'react';
12
+ import {createPortal} from 'react-dom';
13
+
14
+ import Styles from './FlashMessage.module.less';
15
+
16
+ export interface FlashMessageProps {
17
+ children: ReactNode;
18
+ }
19
+
20
+ export default function FlashMessage({
21
+ children,
22
+ }: FlashMessageProps): JSX.Element {
23
+ return createPortal(
24
+ <div className={Styles.FlashMessage__overlay} role="dialog">
25
+ <p className={Styles.FlashMessage__alert} role="alert">
26
+ {children}
27
+ </p>
28
+ </div>,
29
+ document.body,
30
+ );
31
+ }
@@ -0,0 +1,4 @@
1
+ .icon {
2
+ font-size: 16px;
3
+ color: #666666;
4
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+ import type {IconFontProps} from '@ant-design/icons/lib/components/IconFont';
9
+
10
+ import {createFromIconfontCN} from '@ant-design/icons';
11
+ import {useSettings} from 'onchain-lexical-context/settings';
12
+ import React from 'react';
13
+
14
+ import Styles from './index.module.less';
15
+
16
+ export const AliIconFontFn = (scriptUrl = '/font/iconfont.js') => {
17
+ return createFromIconfontCN({
18
+ scriptUrl,
19
+ });
20
+ };
21
+
22
+ export const Icon: React.FC<IconFontProps<string>> = ({type, className, ...props}) => {
23
+ const {extra} = useSettings();
24
+ const AliIconFont = AliIconFontFn(extra.iconScriptUrl);
25
+ return (
26
+ <AliIconFont
27
+ {...props}
28
+ className={`${Styles.icon} ${className}`}
29
+ type={type}
30
+ />
31
+ );
32
+ };
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {LexicalEditor} from 'lexical';
10
+ import type {JSX} from 'react';
11
+
12
+ import {calculateZoomLevel} from '@lexical/utils';
13
+ import * as React from 'react';
14
+ import {useRef} from 'react';
15
+
16
+ function clamp(value: number, min: number, max: number) {
17
+ return Math.min(Math.max(value, min), max);
18
+ }
19
+
20
+ const Direction = {
21
+ east: 1 << 0,
22
+ north: 1 << 3,
23
+ south: 1 << 1,
24
+ west: 1 << 2,
25
+ };
26
+
27
+ export default function ImageResizer({
28
+ onResizeStart,
29
+ onResizeEnd,
30
+ buttonRef,
31
+ imageRef,
32
+ maxWidth,
33
+ editor,
34
+ showCaption,
35
+ setShowCaption,
36
+ captionsEnabled,
37
+ }: {
38
+ editor: LexicalEditor;
39
+ buttonRef: {current: null | HTMLButtonElement};
40
+ imageRef: {current: null | HTMLElement};
41
+ maxWidth?: number;
42
+ onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void;
43
+ onResizeStart: () => void;
44
+ setShowCaption: (show: boolean) => void;
45
+ showCaption: boolean;
46
+ captionsEnabled: boolean;
47
+ }): JSX.Element {
48
+ const controlWrapperRef = useRef<HTMLDivElement>(null);
49
+ const userSelect = useRef({
50
+ priority: '',
51
+ value: 'default',
52
+ });
53
+ const positioningRef = useRef<{
54
+ currentHeight: 'inherit' | number;
55
+ currentWidth: 'inherit' | number;
56
+ direction: number;
57
+ isResizing: boolean;
58
+ ratio: number;
59
+ startHeight: number;
60
+ startWidth: number;
61
+ startX: number;
62
+ startY: number;
63
+ }>({
64
+ currentHeight: 0,
65
+ currentWidth: 0,
66
+ direction: 0,
67
+ isResizing: false,
68
+ ratio: 0,
69
+ startHeight: 0,
70
+ startWidth: 0,
71
+ startX: 0,
72
+ startY: 0,
73
+ });
74
+ const editorRootElement = editor.getRootElement();
75
+ // Find max width, accounting for editor padding.
76
+ const maxWidthContainer = maxWidth
77
+ ? maxWidth
78
+ : editorRootElement !== null
79
+ ? editorRootElement.getBoundingClientRect().width - 20
80
+ : 100;
81
+ const maxHeightContainer =
82
+ editorRootElement !== null
83
+ ? editorRootElement.getBoundingClientRect().height - 20
84
+ : 100;
85
+
86
+ const minWidth = 100;
87
+ const minHeight = 100;
88
+
89
+ const setStartCursor = (direction: number) => {
90
+ const ew = direction === Direction.east || direction === Direction.west;
91
+ const ns = direction === Direction.north || direction === Direction.south;
92
+ const nwse =
93
+ (direction & Direction.north && direction & Direction.west) ||
94
+ (direction & Direction.south && direction & Direction.east);
95
+
96
+ const cursorDir = ew ? 'ew' : ns ? 'ns' : nwse ? 'nwse' : 'nesw';
97
+
98
+ if (editorRootElement !== null) {
99
+ editorRootElement.style.setProperty(
100
+ 'cursor',
101
+ `${cursorDir}-resize`,
102
+ 'important',
103
+ );
104
+ }
105
+ if (document.body !== null) {
106
+ document.body.style.setProperty(
107
+ 'cursor',
108
+ `${cursorDir}-resize`,
109
+ 'important',
110
+ );
111
+ userSelect.current.value = document.body.style.getPropertyValue(
112
+ '-webkit-user-select',
113
+ );
114
+ userSelect.current.priority = document.body.style.getPropertyPriority(
115
+ '-webkit-user-select',
116
+ );
117
+ document.body.style.setProperty(
118
+ '-webkit-user-select',
119
+ `none`,
120
+ 'important',
121
+ );
122
+ }
123
+ };
124
+
125
+ const setEndCursor = () => {
126
+ if (editorRootElement !== null) {
127
+ editorRootElement.style.setProperty('cursor', 'text');
128
+ }
129
+ if (document.body !== null) {
130
+ document.body.style.setProperty('cursor', 'default');
131
+ document.body.style.setProperty(
132
+ '-webkit-user-select',
133
+ userSelect.current.value,
134
+ userSelect.current.priority,
135
+ );
136
+ }
137
+ };
138
+
139
+ const handlePointerDown = (
140
+ event: React.PointerEvent<HTMLDivElement>,
141
+ direction: number,
142
+ ) => {
143
+ if (!editor.isEditable()) {
144
+ return;
145
+ }
146
+
147
+ const image = imageRef.current;
148
+ const controlWrapper = controlWrapperRef.current;
149
+
150
+ if (image !== null && controlWrapper !== null) {
151
+ event.preventDefault();
152
+ const {width, height} = image.getBoundingClientRect();
153
+ const zoom = calculateZoomLevel(image);
154
+ const positioning = positioningRef.current;
155
+ positioning.startWidth = width;
156
+ positioning.startHeight = height;
157
+ positioning.ratio = width / height;
158
+ positioning.currentWidth = width;
159
+ positioning.currentHeight = height;
160
+ positioning.startX = event.clientX / zoom;
161
+ positioning.startY = event.clientY / zoom;
162
+ positioning.isResizing = true;
163
+ positioning.direction = direction;
164
+
165
+ setStartCursor(direction);
166
+ onResizeStart();
167
+
168
+ controlWrapper.classList.add('image-control-wrapper--resizing');
169
+ image.style.height = `${height}px`;
170
+ image.style.width = `${width}px`;
171
+
172
+ document.addEventListener('pointermove', handlePointerMove);
173
+ document.addEventListener('pointerup', handlePointerUp);
174
+ }
175
+ };
176
+ const handlePointerMove = (event: PointerEvent) => {
177
+ const image = imageRef.current;
178
+ const positioning = positioningRef.current;
179
+
180
+ const isHorizontal =
181
+ positioning.direction & (Direction.east | Direction.west);
182
+ const isVertical =
183
+ positioning.direction & (Direction.south | Direction.north);
184
+
185
+ if (image !== null && positioning.isResizing) {
186
+ const zoom = calculateZoomLevel(image);
187
+ // Corner cursor
188
+ if (isHorizontal && isVertical) {
189
+ let diff = Math.floor(positioning.startX - event.clientX / zoom);
190
+ diff = positioning.direction & Direction.east ? -diff : diff;
191
+
192
+ const width = clamp(
193
+ positioning.startWidth + diff,
194
+ minWidth,
195
+ maxWidthContainer,
196
+ );
197
+
198
+ const height = width / positioning.ratio;
199
+ image.style.width = `${width}px`;
200
+ image.style.height = `${height}px`;
201
+ positioning.currentHeight = height;
202
+ positioning.currentWidth = width;
203
+ } else if (isVertical) {
204
+ let diff = Math.floor(positioning.startY - event.clientY / zoom);
205
+ diff = positioning.direction & Direction.south ? -diff : diff;
206
+
207
+ const height = clamp(
208
+ positioning.startHeight + diff,
209
+ minHeight,
210
+ maxHeightContainer,
211
+ );
212
+
213
+ image.style.height = `${height}px`;
214
+ positioning.currentHeight = height;
215
+ } else {
216
+ let diff = Math.floor(positioning.startX - event.clientX / zoom);
217
+ diff = positioning.direction & Direction.east ? -diff : diff;
218
+
219
+ const width = clamp(
220
+ positioning.startWidth + diff,
221
+ minWidth,
222
+ maxWidthContainer,
223
+ );
224
+
225
+ image.style.width = `${width}px`;
226
+ positioning.currentWidth = width;
227
+ }
228
+ }
229
+ };
230
+ const handlePointerUp = () => {
231
+ const image = imageRef.current;
232
+ const positioning = positioningRef.current;
233
+ const controlWrapper = controlWrapperRef.current;
234
+ if (image !== null && controlWrapper !== null && positioning.isResizing) {
235
+ const width = positioning.currentWidth;
236
+ const height = positioning.currentHeight;
237
+ positioning.startWidth = 0;
238
+ positioning.startHeight = 0;
239
+ positioning.ratio = 0;
240
+ positioning.startX = 0;
241
+ positioning.startY = 0;
242
+ positioning.currentWidth = 0;
243
+ positioning.currentHeight = 0;
244
+ positioning.isResizing = false;
245
+
246
+ controlWrapper.classList.remove('image-control-wrapper--resizing');
247
+
248
+ setEndCursor();
249
+ onResizeEnd(width, height);
250
+
251
+ document.removeEventListener('pointermove', handlePointerMove);
252
+ document.removeEventListener('pointerup', handlePointerUp);
253
+ }
254
+ };
255
+ return (
256
+ <div ref={controlWrapperRef}>
257
+ {!showCaption && captionsEnabled && (
258
+ <button
259
+ className="image-caption-button"
260
+ ref={buttonRef}
261
+ onClick={() => {
262
+ setShowCaption(!showCaption);
263
+ }}>
264
+ Add Caption
265
+ </button>
266
+ )}
267
+ <div
268
+ className="image-resizer image-resizer-n"
269
+ onPointerDown={(event) => {
270
+ handlePointerDown(event, Direction.north);
271
+ }}
272
+ />
273
+ <div
274
+ className="image-resizer image-resizer-ne"
275
+ onPointerDown={(event) => {
276
+ handlePointerDown(event, Direction.north | Direction.east);
277
+ }}
278
+ />
279
+ <div
280
+ className="image-resizer image-resizer-e"
281
+ onPointerDown={(event) => {
282
+ handlePointerDown(event, Direction.east);
283
+ }}
284
+ />
285
+ <div
286
+ className="image-resizer image-resizer-se"
287
+ onPointerDown={(event) => {
288
+ handlePointerDown(event, Direction.south | Direction.east);
289
+ }}
290
+ />
291
+ <div
292
+ className="image-resizer image-resizer-s"
293
+ onPointerDown={(event) => {
294
+ handlePointerDown(event, Direction.south);
295
+ }}
296
+ />
297
+ <div
298
+ className="image-resizer image-resizer-sw"
299
+ onPointerDown={(event) => {
300
+ handlePointerDown(event, Direction.south | Direction.west);
301
+ }}
302
+ />
303
+ <div
304
+ className="image-resizer image-resizer-w"
305
+ onPointerDown={(event) => {
306
+ handlePointerDown(event, Direction.west);
307
+ }}
308
+ />
309
+ <div
310
+ className="image-resizer image-resizer-nw"
311
+ onPointerDown={(event) => {
312
+ handlePointerDown(event, Direction.north | Direction.west);
313
+ }}
314
+ />
315
+ </div>
316
+ );
317
+ }