文件
mynode/packages/lib/components/Dialog.tsx
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

234 行
7.9 KiB
TypeScript

import shim from '../shim';
import { blur, focus } from '../utils/focusHandler';
import useDocument from '../hooks/dom/useDocument';
import type { FC, CSSProperties, ReactNode } from 'react';
const React = shim.react();
const { useEffect, useRef, useState } = shim.react();
type OnCancelListener = ()=> void;
// Closes made by the app, not the user, so the close event must not report them as a cancel.
const removedReturnValue = 'removed-from-dom';
const hiddenReturnValue = 'hidden';
type OnShowListener = ()=> void;
type OnBeforeShowListener = (dialog: HTMLDialogElement)=> void;
interface Props {
className?: string;
onCancel?: OnCancelListener;
onShow?: OnShowListener;
onBeforeShow?: OnBeforeShowListener;
contentStyle?: CSSProperties;
open?: boolean;
preventAutoCloseOnCancel?: boolean;
contentFillsScreen?: boolean;
children: ReactNode;
}
const Dialog: FC<Props> = props => {
const [containerElement, setContainerElement] = useState<HTMLDivElement|null>(null);
const containerDocument = useDocument(containerElement);
// For correct focus handling, the dialog element needs to be managed separately from React. In particular,
// just after creating the dialog, we need to call .showModal() and just **before** closing the dialog, we
// need to call .close(). This second requirement is particularly difficult, as this needs to happen even
// if the dialog is closed by removing its parent from the React DOM.
//
// Because useEffect cleanup can happen after an element is removed from the HTML DOM, the dialog is managed
// using native HTML APIs. This allows us to call .close() while the dialog is still attached to the DOM, which
// allows the browser to restore the focus from before the dialog was opened.
const dialogElement = useDialogElement(containerDocument, props.onCancel, props.preventAutoCloseOnCancel ?? false);
useDialogClassNames(dialogElement, props.className);
const [contentRendered, setContentRendered] = useState(false);
const propsRef = useRef(props);
propsRef.current = props;
useEffect(() => {
if (!dialogElement || !contentRendered) return;
const open = props.open ?? true;
if (!dialogElement.open && open) {
propsRef.current.onBeforeShow?.(dialogElement);
dialogElement.showModal();
propsRef.current.onShow?.();
} else if (dialogElement.open && !open) {
// The close event fires later, when a dialog shown next may already own onCancel.
dialogElement.close(hiddenReturnValue);
}
}, [dialogElement, contentRendered, props.open]);
useEffect(() => {
if (!dialogElement) return;
if (props.contentFillsScreen) {
dialogElement.classList.add('-fullscreen');
} else {
dialogElement.classList.remove('-fullscreen');
}
}, [props.contentFillsScreen, dialogElement]);
if (dialogElement && !contentRendered) {
setContentRendered(true);
}
const content = (
<div className='content' style={props.contentStyle}>
{props.children}
</div>
);
return <div ref={setContainerElement} className='dialog-anchor-node'>
{dialogElement && shim.reactDom().createPortal(content, dialogElement) as ReactNode}
</div>;
};
// We keep track of the mouse events to allow the action to be cancellable on the mouseup
// If dialogElement is the source of the mouse event it means
// that the user clicked in the dimmed background and not in the content of the dialog
const useClickedOutsideContent = (dialogElement: HTMLDialogElement|null) => {
const mouseDownOutsideContent = useRef(false);
mouseDownOutsideContent.current = false;
const [clickedOutsideContent, setClickedOutsideContent] = useState(false);
useEffect(() => {
if (!dialogElement) return () => {};
const mouseDownListener = (event: MouseEvent) => {
if (event.target === dialogElement) {
mouseDownOutsideContent.current = true;
} else {
mouseDownOutsideContent.current = false;
}
};
const mouseUpListener = (event: MouseEvent) => {
if (!mouseDownOutsideContent.current) return;
if (mouseDownOutsideContent.current && event.target === dialogElement) {
setClickedOutsideContent(true);
mouseDownOutsideContent.current = false;
} else {
setClickedOutsideContent(false);
mouseDownOutsideContent.current = false;
}
};
dialogElement.addEventListener('mousedown', mouseDownListener);
dialogElement.addEventListener('mouseup', mouseUpListener);
return () => {
dialogElement.removeEventListener('mousedown', mouseDownListener);
dialogElement.removeEventListener('mouseup', mouseUpListener);
};
}, [dialogElement]);
return [clickedOutsideContent, setClickedOutsideContent] as const;
};
const useDialogElement = (containerDocument: Document, onCancel: undefined|OnCancelListener, preventAutoCloseOnCancel: boolean) => {
const [dialogElement, setDialogElement] = useState<HTMLDialogElement|null>(null);
const onCancelRef = useRef(onCancel);
onCancelRef.current = onCancel;
const skipCloseCancelRef = useRef(false);
const [clickedOutsideContent, setClickedOutsideContent] = useClickedOutsideContent(dialogElement);
useEffect(() => {
if (clickedOutsideContent) {
const onCancel = onCancelRef.current;
if (onCancel) {
if (preventAutoCloseOnCancel) {
skipCloseCancelRef.current = true;
}
onCancel();
} else {
setClickedOutsideContent(false);
}
}
}, [clickedOutsideContent, setClickedOutsideContent, preventAutoCloseOnCancel]);
useEffect(() => {
if (!containerDocument) return () => {};
const dialog = containerDocument.createElement('dialog');
dialog.classList.add('dialog-modal-layer');
dialog.addEventListener('cancel', event => {
if (preventAutoCloseOnCancel) {
// Prevent the native dialog element from auto-closing so the app can
// decide whether to close (for example, to confirm unsaved changes).
event.preventDefault();
skipCloseCancelRef.current = true;
onCancelRef.current?.();
return;
}
const canCancel = !!onCancelRef.current;
if (!canCancel) {
// Prevents [Escape] from closing the dialog. In many places, this is handled
// by external logic.
// See https://stackoverflow.com/a/61021326
event.preventDefault();
}
});
dialog.addEventListener('close', () => {
const closedByCancel = dialog.returnValue !== removedReturnValue && dialog.returnValue !== hiddenReturnValue;
// A cancel leaves the return value as it was, so it is cleared here rather than on show, which can
// happen before this event arrives.
dialog.returnValue = '';
if (closedByCancel) {
if (!preventAutoCloseOnCancel || !skipCloseCancelRef.current) {
onCancelRef.current?.();
}
}
skipCloseCancelRef.current = false;
// Work around what seems to be an Electron bug -- if an input or contenteditable region is refocused after
// dismissing a dialog, it won't be editable.
// Note: While this addresses the issue in the note title input, it does not address the issue in the Rich Text Editor.
if (containerDocument.activeElement?.tagName === 'INPUT') {
const element = containerDocument.activeElement as HTMLElement;
blur('Dialog', element);
focus('Dialog', element);
}
});
containerDocument.body.appendChild(dialog);
setDialogElement(dialog);
return () => {
if (dialog.open) {
// .close: Instructs the browser to restore keyboard focus to whatever was focused
// before the dialog.
dialog.close(removedReturnValue);
}
dialog.remove();
};
}, [containerDocument, preventAutoCloseOnCancel]);
return dialogElement;
};
const useDialogClassNames = (dialogElement: HTMLElement|null, classNames: undefined|string) => {
useEffect(() => {
if (!dialogElement || !classNames) {
return () => {};
}
// The React className prop can include multiple space-separated classes
const newClassNames = classNames
.split(/\s+/)
.filter(name => name.length && !dialogElement.classList.contains(name));
dialogElement.classList.add(...newClassNames);
return () => {
dialogElement.classList.remove(...newClassNames);
};
}, [dialogElement, classNames]);
};
export default Dialog;