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.
- package/README.md +5 -0
- package/package.json +221 -0
- package/src/Button.module.less +36 -0
- package/src/Button.tsx +50 -0
- package/src/ColorPicker.module.less +89 -0
- package/src/ColorPicker.tsx +369 -0
- package/src/ContentEditable.module.less +78 -0
- package/src/ContentEditable.tsx +41 -0
- package/src/Dialog.module.less +23 -0
- package/src/Dialog.tsx +34 -0
- package/src/DropDown.module.less +95 -0
- package/src/DropDown.tsx +267 -0
- package/src/DropdownColorPicker.tsx +41 -0
- package/src/EditorShellStyles/index.module.less +43 -0
- package/src/EditorShellStyles/index.tsx +18 -0
- package/src/EquationEditor.module.less +41 -0
- package/src/EquationEditor.tsx +49 -0
- package/src/ExcalidrawModal.module.less +62 -0
- package/src/ExcalidrawModal.tsx +252 -0
- package/src/FileInput.tsx +40 -0
- package/src/FlashMessage.module.less +28 -0
- package/src/FlashMessage.tsx +31 -0
- package/src/Icon/index.module.less +4 -0
- package/src/Icon/index.tsx +32 -0
- package/src/ImageResizer.tsx +317 -0
- package/src/Input.module.less +38 -0
- package/src/KatexEquationAlterer.module.less +41 -0
- package/src/KatexEquationAlterer.tsx +84 -0
- package/src/KatexRenderer.tsx +73 -0
- package/src/Modal.module.less +65 -0
- package/src/Modal.tsx +176 -0
- package/src/Select.module.less +41 -0
- package/src/Select.tsx +37 -0
- package/src/Skeleton.module.less +67 -0
- package/src/Skeleton.tsx +28 -0
- package/src/Switch.tsx +38 -0
- package/src/TextInput.tsx +48 -0
- package/src/utils/joinClasses.ts +13 -0
|
@@ -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,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
|
+
}
|