diff --git a/app/components/ContentEditable.tsx b/app/components/ContentEditable.tsx index 4b127a2cf..5d156365b 100644 --- a/app/components/ContentEditable.tsx +++ b/app/components/ContentEditable.tsx @@ -18,6 +18,13 @@ type Props = Omit, "ref" | "onChange"> & { value: string; }; +export type RefHandle = { + focus: () => void; + focusAtStart: () => void; + focusAtEnd: () => void; + getComputedDirection: () => string; +}; + /** * Defines a content editable component with the same interface as a native * HTMLInputElement (or, as close as we can get). @@ -41,13 +48,36 @@ const ContentEditable = React.forwardRef( onClick, ...rest }: Props, - forwardedRef: React.RefObject + ref: React.RefObject ) => { - const innerRef = React.useRef(null); - const ref = forwardedRef || innerRef; + const contentRef = React.useRef(null); const [innerValue, setInnerValue] = React.useState(value); const lastValue = React.useRef(""); + React.useImperativeHandle(ref, () => ({ + focus: () => { + contentRef.current?.focus(); + }, + focusAtStart: () => { + if (contentRef.current) { + contentRef.current.focus(); + placeCaret(contentRef.current, true); + } + }, + focusAtEnd: () => { + if (contentRef.current) { + contentRef.current.focus(); + placeCaret(contentRef.current, false); + } + }, + getComputedDirection: () => { + if (contentRef.current) { + return window.getComputedStyle(contentRef.current).direction; + } + return "ltr"; + }, + })); + const wrappedEvent = ( callback: | React.FocusEventHandler @@ -55,7 +85,7 @@ const ContentEditable = React.forwardRef( | React.KeyboardEventHandler | undefined ) => (event: any) => { - const text = ref.current?.innerText || ""; + const text = contentRef.current?.innerText || ""; if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) { event?.preventDefault(); @@ -74,19 +104,19 @@ const ContentEditable = React.forwardRef( // case the component may be rendered with display: none. React 18 may solve // this in the future by delaying useEffect hooks: // https://github.com/facebook/react/issues/14536#issuecomment-861980492 - const isVisible = useOnScreen(ref); + const isVisible = useOnScreen(contentRef); React.useEffect(() => { if (autoFocus && isVisible && !disabled && !readOnly) { - ref.current?.focus(); + contentRef.current?.focus(); } - }, [autoFocus, disabled, isVisible, readOnly, ref]); + }, [autoFocus, disabled, isVisible, readOnly, contentRef]); React.useEffect(() => { - if (value !== ref.current?.innerText) { + if (value !== contentRef.current?.innerText) { setInnerValue(value); } - }, [value, ref]); + }, [value, contentRef]); // Ensure only plain text can be pasted into title when pasting from another // rich text editor @@ -102,7 +132,7 @@ const ContentEditable = React.forwardRef( return (
props.theme.background}; transition: ${(props) => props.theme.backgroundTransition}; diff --git a/app/scenes/Document/components/EditableTitle.tsx b/app/scenes/Document/components/EditableTitle.tsx index 656b54185..122c6c81e 100644 --- a/app/scenes/Document/components/EditableTitle.tsx +++ b/app/scenes/Document/components/EditableTitle.tsx @@ -4,8 +4,13 @@ import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { MAX_TITLE_LENGTH } from "@shared/constants"; import { light } from "@shared/theme"; +import { + getCurrentDateAsString, + getCurrentDateTimeAsString, + getCurrentTimeAsString, +} from "@shared/utils/date"; import Document from "~/models/Document"; -import ContentEditable from "~/components/ContentEditable"; +import ContentEditable, { RefHandle } from "~/components/ContentEditable"; import Star, { AnimatedStar } from "~/components/Star"; import useEmojiWidth from "~/hooks/useEmojiWidth"; import usePolicy from "~/hooks/usePolicy"; @@ -42,7 +47,7 @@ const EditableTitle = React.forwardRef( starrable, placeholder, }: Props, - ref: React.RefObject + ref: React.RefObject ) => { const can = usePolicy(document.id); const normalizedTitle = @@ -92,6 +97,24 @@ const EditableTitle = React.forwardRef( [onGoToNextInput, onSave] ); + const handleChange = React.useCallback( + (text: string) => { + if (/\/date\s$/.test(text)) { + onChange(getCurrentDateAsString()); + ref.current?.focusAtEnd(); + } else if (/\/time$/.test(text)) { + onChange(getCurrentTimeAsString()); + ref.current?.focusAtEnd(); + } else if (/\/datetime$/.test(text)) { + onChange(getCurrentDateTimeAsString()); + ref.current?.focusAtEnd(); + } else { + onChange(text); + } + }, + [ref, onChange] + ); + const emojiWidth = useEmojiWidth(document.emoji, { fontSize, lineHeight, @@ -100,7 +123,7 @@ const EditableTitle = React.forwardRef( return ( ) { activeLinkEvent, setActiveLinkEvent, ] = React.useState<MouseEvent | null>(null); - const titleRef = React.useRef<HTMLSpanElement>(null); + const titleRef = React.useRef<RefHandle>(null); const { t } = useTranslation(); const match = useRouteMatch(); @@ -114,9 +115,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) { : documentHistoryUrl(document) } rtl={ - titleRef.current - ? window.getComputedStyle(titleRef.current).direction === "rtl" - : false + titleRef.current?.getComputedDirection() === "rtl" ? true : false } /> )} diff --git a/shared/editor/packages/basic.ts b/shared/editor/packages/basic.ts index cf0685286..70c05b330 100644 --- a/shared/editor/packages/basic.ts +++ b/shared/editor/packages/basic.ts @@ -13,6 +13,7 @@ import Image from "../nodes/Image"; import Node from "../nodes/Node"; import Paragraph from "../nodes/Paragraph"; import Text from "../nodes/Text"; +import DateTime from "../plugins/DateTime"; import History from "../plugins/History"; import MaxLength from "../plugins/MaxLength"; import PasteHandler from "../plugins/PasteHandler"; @@ -39,6 +40,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [ PasteHandler, Placeholder, MaxLength, + DateTime, ]; export default basicPackage; diff --git a/shared/editor/plugins/DateTime.ts b/shared/editor/plugins/DateTime.ts new file mode 100644 index 000000000..554cff8c3 --- /dev/null +++ b/shared/editor/plugins/DateTime.ts @@ -0,0 +1,39 @@ +import { InputRule } from "prosemirror-inputrules"; +import { + getCurrentDateAsString, + getCurrentDateTimeAsString, + getCurrentTimeAsString, +} from "../../utils/date"; +import Extension from "../lib/Extension"; +import { EventType } from "../types"; + +/** + * An editor extension that adds commands to insert the current date and time. + */ +export default class DateTime extends Extension { + get name() { + return "date_time"; + } + + inputRules() { + return [ + // Note: There is a space at the end of the pattern here otherwise the + // /datetime rule can never be matched. + new InputRule(/\/date\s$/, ({ tr }, _match, start, end) => { + tr.delete(start, end).insertText(getCurrentDateAsString() + " "); + this.editor.events.emit(EventType.blockMenuClose); + return tr; + }), + new InputRule(/\/time$/, ({ tr }, _match, start, end) => { + tr.delete(start, end).insertText(getCurrentTimeAsString() + " "); + this.editor.events.emit(EventType.blockMenuClose); + return tr; + }), + new InputRule(/\/datetime$/, ({ tr }, _match, start, end) => { + tr.delete(start, end).insertText(`${getCurrentDateTimeAsString()} `); + this.editor.events.emit(EventType.blockMenuClose); + return tr; + }), + ]; + } +} diff --git a/shared/utils/date.ts b/shared/utils/date.ts index 94086e031..98d5f89c8 100644 --- a/shared/utils/date.ts +++ b/shared/utils/date.ts @@ -19,3 +19,44 @@ export function subtractDate(date: Date, period: DateFilter) { return date; } } + +/** + * Returns the current date as a string formatted depending on current locale. + * + * @returns The current date + */ +export function getCurrentDateAsString() { + return new Date().toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +/** + * Returns the current time as a string formatted depending on current locale. + * + * @returns The current time + */ +export function getCurrentTimeAsString() { + return new Date().toLocaleTimeString(undefined, { + hour: "numeric", + minute: "numeric", + }); +} + +/** + * Returns the current date and time as a string formatted depending on current + * locale. + * + * @returns The current date and time + */ +export function getCurrentDateTimeAsString() { + return new Date().toLocaleString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); +}