You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
409 lines
11 KiB
TypeScript
409 lines
11 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
*/
|
|
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
import {$wrapNodeInElement, mergeRegister} from '@lexical/utils';
|
|
import {
|
|
$createParagraphNode,
|
|
$createRangeSelection,
|
|
$getSelection,
|
|
$insertNodes,
|
|
$isNodeSelection,
|
|
$isRootOrShadowRoot,
|
|
$setSelection,
|
|
COMMAND_PRIORITY_EDITOR,
|
|
COMMAND_PRIORITY_HIGH,
|
|
COMMAND_PRIORITY_LOW,
|
|
createCommand,
|
|
DRAGOVER_COMMAND,
|
|
DRAGSTART_COMMAND,
|
|
DROP_COMMAND,
|
|
LexicalCommand,
|
|
LexicalEditor,
|
|
} from 'lexical';
|
|
import {useEffect, useRef, useState} from 'react';
|
|
import * as React from 'react';
|
|
// import {CAN_USE_DOM} from '../../shared/canUseDOM';
|
|
|
|
// import landscapeImage from '../../images/landscape.jpg';
|
|
// import yellowFlowerImage from '../../images/yellow-flower.jpg';
|
|
import {
|
|
$createImageNode,
|
|
$isImageNode,
|
|
ImageNode,
|
|
ImagePayload,
|
|
} from '../../nodes/ImageNode';
|
|
import Button from '../../ui/Button';
|
|
import {DialogActions, DialogButtonsList} from '../../ui/Dialog';
|
|
import FileInput from '../../ui/FileInput';
|
|
import TextInput from '../../ui/TextInput';
|
|
|
|
import { postUploadFileItem } from '../../../../actions/CommonActions.js';
|
|
|
|
export type InsertImagePayload = Readonly<ImagePayload>;
|
|
|
|
// const getDOMSelection = (targetWindow: Window | null): Selection | null =>
|
|
// CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
|
|
const getDOMSelection = (targetWindow: Window | null): Selection | null =>
|
|
(targetWindow || window).getSelection();
|
|
|
|
export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
|
|
createCommand('INSERT_IMAGE_COMMAND');
|
|
|
|
export function InsertImageUriDialogBody({
|
|
onClick,
|
|
}: {
|
|
onClick: (payload: InsertImagePayload) => void;
|
|
}) {
|
|
const [src, setSrc] = useState('');
|
|
const [altText, setAltText] = useState('');
|
|
|
|
const isDisabled = src === '';
|
|
|
|
return (
|
|
<>
|
|
<TextInput
|
|
label="Image URL"
|
|
placeholder="i.e. https://source.unsplash.com/random"
|
|
onChange={setSrc}
|
|
value={src}
|
|
data-test-id="image-modal-url-input"
|
|
/>
|
|
{/* <TextInput
|
|
label="Alt Text"
|
|
placeholder="Random unsplash image"
|
|
onChange={setAltText}
|
|
value={altText}
|
|
data-test-id="image-modal-alt-text-input"
|
|
/> */}
|
|
<DialogActions>
|
|
<Button
|
|
data-test-id="image-modal-confirm-btn"
|
|
disabled={isDisabled}
|
|
onClick={() => onClick({altText, src})}>
|
|
Confirm
|
|
</Button>
|
|
</DialogActions>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function InsertImageUploadedDialogBody({
|
|
onClick,
|
|
}: {
|
|
onClick: (payload: InsertImagePayload) => void;
|
|
}) {
|
|
const [src, setSrc] = useState('');
|
|
const [altText, setAltText] = useState('');
|
|
|
|
const isDisabled = src === '';
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
const loadImage = async (files: FileList | null) => {
|
|
setUploading(true);
|
|
const _tmpFile = files[0];
|
|
const suffix = _tmpFile.name.slice(_tmpFile.name.lastIndexOf('.') + 1).toLocaleLowerCase();
|
|
const newName = `${Date.now().toString(32)}.${suffix}`;
|
|
const { file_url } = await postUploadFileItem(_tmpFile, newName);
|
|
setUploading(false);
|
|
if (file_url) {
|
|
setSrc(file_url);
|
|
return file_url;
|
|
}
|
|
// const reader = new FileReader();
|
|
// reader.onload = function () {
|
|
// if (typeof reader.result === 'string') {
|
|
// setSrc(reader.result);
|
|
// }
|
|
// return '';
|
|
// };
|
|
// if (files !== null) {
|
|
// reader.readAsDataURL(files[0]);
|
|
// }
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<FileInput
|
|
label="Image Upload"
|
|
onChange={loadImage}
|
|
accept="image/*"
|
|
data-test-id="image-modal-file-upload"
|
|
/>
|
|
{/* <TextInput
|
|
label="Alt Text"
|
|
placeholder="Descriptive alternative text"
|
|
onChange={setAltText}
|
|
value={altText}
|
|
data-test-id="image-modal-alt-text-input"
|
|
/> */}
|
|
<DialogActions>
|
|
<Button
|
|
data-test-id="image-modal-file-upload-btn"
|
|
disabled={isDisabled}
|
|
onClick={() => onClick({altText, src})}>
|
|
{uploading ? 'Pls wait...' : 'Confirm'}
|
|
</Button>
|
|
</DialogActions>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function InsertImageDialog({
|
|
activeEditor,
|
|
onClose,
|
|
}: {
|
|
activeEditor: LexicalEditor;
|
|
onClose: () => void;
|
|
}): JSX.Element {
|
|
const [mode, setMode] = useState<null | 'url' | 'file'>(null);
|
|
const hasModifier = useRef(false);
|
|
|
|
useEffect(() => {
|
|
hasModifier.current = false;
|
|
const handler = (e: KeyboardEvent) => {
|
|
hasModifier.current = e.altKey;
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => {
|
|
document.removeEventListener('keydown', handler);
|
|
};
|
|
}, [activeEditor]);
|
|
|
|
const onClick = (payload: InsertImagePayload) => {
|
|
// console.log('payload', payload);
|
|
|
|
activeEditor.dispatchCommand(INSERT_IMAGE_COMMAND, payload);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{!mode && (
|
|
<DialogButtonsList>
|
|
{/* <Button
|
|
data-test-id="image-modal-option-sample"
|
|
onClick={() =>
|
|
onClick(
|
|
hasModifier.current
|
|
? {
|
|
altText:
|
|
'Daylight fir trees forest glacier green high ice landscape',
|
|
src: landscapeImage,
|
|
}
|
|
: {
|
|
altText: 'Yellow flower in tilt shift lens',
|
|
src: yellowFlowerImage,
|
|
},
|
|
)
|
|
}>
|
|
Sample
|
|
</Button> */}
|
|
<Button
|
|
data-test-id="image-modal-option-url"
|
|
onClick={() => setMode('url')}>
|
|
URL
|
|
</Button>
|
|
<Button
|
|
data-test-id="image-modal-option-file"
|
|
onClick={() => setMode('file')}>
|
|
File
|
|
</Button>
|
|
</DialogButtonsList>
|
|
)}
|
|
{mode === 'url' && <InsertImageUriDialogBody onClick={onClick} />}
|
|
{mode === 'file' && <InsertImageUploadedDialogBody onClick={onClick} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function ImagesPlugin({
|
|
captionsEnabled,
|
|
}: {
|
|
captionsEnabled?: boolean;
|
|
}): JSX.Element | null {
|
|
const [editor] = useLexicalComposerContext();
|
|
|
|
useEffect(() => {
|
|
if (!editor.hasNodes([ImageNode])) {
|
|
throw new Error('ImagesPlugin: ImageNode not registered on editor');
|
|
}
|
|
|
|
return mergeRegister(
|
|
editor.registerCommand<InsertImagePayload>(
|
|
INSERT_IMAGE_COMMAND,
|
|
(payload) => {
|
|
const imageNode = $createImageNode(payload);
|
|
$insertNodes([imageNode]);
|
|
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
|
|
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
|
|
}
|
|
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand<DragEvent>(
|
|
DRAGSTART_COMMAND,
|
|
(event) => {
|
|
return $onDragStart(event);
|
|
},
|
|
COMMAND_PRIORITY_HIGH,
|
|
),
|
|
editor.registerCommand<DragEvent>(
|
|
DRAGOVER_COMMAND,
|
|
(event) => {
|
|
return $onDragover(event);
|
|
},
|
|
COMMAND_PRIORITY_LOW,
|
|
),
|
|
editor.registerCommand<DragEvent>(
|
|
DROP_COMMAND,
|
|
(event) => {
|
|
return $onDrop(event, editor);
|
|
},
|
|
COMMAND_PRIORITY_HIGH,
|
|
),
|
|
);
|
|
}, [captionsEnabled, editor]);
|
|
|
|
return null;
|
|
}
|
|
|
|
const TRANSPARENT_IMAGE =
|
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
const img = document.createElement('img');
|
|
img.src = TRANSPARENT_IMAGE;
|
|
|
|
function $onDragStart(event: DragEvent): boolean {
|
|
const node = $getImageNodeInSelection();
|
|
if (!node) {
|
|
return false;
|
|
}
|
|
const dataTransfer = event.dataTransfer;
|
|
if (!dataTransfer) {
|
|
return false;
|
|
}
|
|
dataTransfer.setData('text/plain', '_');
|
|
dataTransfer.setDragImage(img, 0, 0);
|
|
dataTransfer.setData(
|
|
'application/x-lexical-drag',
|
|
JSON.stringify({
|
|
data: {
|
|
altText: node.__altText,
|
|
caption: node.__caption,
|
|
height: node.__height,
|
|
key: node.getKey(),
|
|
maxWidth: node.__maxWidth,
|
|
showCaption: node.__showCaption,
|
|
src: node.__src,
|
|
width: node.__width,
|
|
},
|
|
type: 'image',
|
|
}),
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
function $onDragover(event: DragEvent): boolean {
|
|
const node = $getImageNodeInSelection();
|
|
if (!node) {
|
|
return false;
|
|
}
|
|
if (!canDropImage(event)) {
|
|
event.preventDefault();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function $onDrop(event: DragEvent, editor: LexicalEditor): boolean {
|
|
const node = $getImageNodeInSelection();
|
|
if (!node) {
|
|
return false;
|
|
}
|
|
const data = getDragImageData(event);
|
|
if (!data) {
|
|
return false;
|
|
}
|
|
event.preventDefault();
|
|
if (canDropImage(event)) {
|
|
const range = getDragSelection(event);
|
|
node.remove();
|
|
const rangeSelection = $createRangeSelection();
|
|
if (range !== null && range !== undefined) {
|
|
rangeSelection.applyDOMRange(range);
|
|
}
|
|
$setSelection(rangeSelection);
|
|
editor.dispatchCommand(INSERT_IMAGE_COMMAND, data);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function $getImageNodeInSelection(): ImageNode | null {
|
|
const selection = $getSelection();
|
|
if (!$isNodeSelection(selection)) {
|
|
return null;
|
|
}
|
|
const nodes = selection.getNodes();
|
|
const node = nodes[0];
|
|
return $isImageNode(node) ? node : null;
|
|
}
|
|
|
|
function getDragImageData(event: DragEvent): null | InsertImagePayload {
|
|
const dragData = event.dataTransfer?.getData('application/x-lexical-drag');
|
|
if (!dragData) {
|
|
return null;
|
|
}
|
|
const {type, data} = JSON.parse(dragData);
|
|
if (type !== 'image') {
|
|
return null;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
declare global {
|
|
interface DragEvent {
|
|
rangeOffset?: number;
|
|
rangeParent?: Node;
|
|
}
|
|
}
|
|
|
|
function canDropImage(event: DragEvent): boolean {
|
|
const target = event.target;
|
|
return !!(
|
|
target &&
|
|
target instanceof HTMLElement &&
|
|
!target.closest('code, span.editor-image') &&
|
|
target.parentElement &&
|
|
target.parentElement.closest('div.ContentEditable__root')
|
|
);
|
|
}
|
|
|
|
function getDragSelection(event: DragEvent): Range | null | undefined {
|
|
let range;
|
|
const target = event.target as null | Element | Document;
|
|
const targetWindow =
|
|
target == null
|
|
? null
|
|
: target.nodeType === 9
|
|
? (target as Document).defaultView
|
|
: (target as Element).ownerDocument.defaultView;
|
|
const domSelection = getDOMSelection(targetWindow);
|
|
if (document.caretRangeFromPoint) {
|
|
range = document.caretRangeFromPoint(event.clientX, event.clientY);
|
|
} else if (event.rangeParent && domSelection !== null) {
|
|
domSelection.collapse(event.rangeParent, event.rangeOffset || 0);
|
|
range = domSelection.getRangeAt(0);
|
|
} else {
|
|
throw Error(`Cannot get the selection when dragging`);
|
|
}
|
|
|
|
return range;
|
|
}
|