feat: Add patterns to insert current date and time into doc (#3309)

* feat: Add patterns to insert current date and time into doc

* Add commands to title input too

* lint: Remove console.log
This commit is contained in:
Tom Moor
2022-03-31 19:51:55 -07:00
committed by GitHub
parent 4c0cd3d893
commit c66aca063e
6 changed files with 165 additions and 17 deletions

View File

@@ -18,6 +18,13 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "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<HTMLSpanElement>
ref: React.RefObject<RefHandle>
) => {
const innerRef = React.useRef<HTMLSpanElement>(null);
const ref = forwardedRef || innerRef;
const contentRef = React.useRef<HTMLSpanElement>(null);
const [innerValue, setInnerValue] = React.useState<string>(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<HTMLSpanElement>
@@ -55,7 +85,7 @@ const ContentEditable = React.forwardRef(
| React.KeyboardEventHandler<HTMLSpanElement>
| 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 (
<div className={className} dir={dir} onClick={onClick}>
<Content
ref={ref}
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
@@ -121,6 +151,20 @@ const ContentEditable = React.forwardRef(
}
);
function placeCaret(element: HTMLElement, atStart: boolean) {
if (
typeof window.getSelection !== "undefined" &&
typeof document.createRange !== "undefined"
) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(atStart);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
const Content = styled.span`
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};

View File

@@ -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<HTMLSpanElement>
ref: React.RefObject<RefHandle>
) => {
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 (
<Title
onClick={handleClick}
onChange={onChange}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
value={normalizedTitle}

View File

@@ -5,6 +5,7 @@ import { useRouteMatch } from "react-router-dom";
import fullPackage from "@shared/editor/packages/full";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import { RefHandle } from "~/components/ContentEditable";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
@@ -41,7 +42,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
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
}
/>
)}

View File

@@ -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;

View File

@@ -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;
}),
];
}
}

View File

@@ -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",
});
}