mirror of
https://github.com/outline/outline.git
synced 2026-01-06 11:09:55 -06:00
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:
@@ -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};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
39
shared/editor/plugins/DateTime.ts
Normal file
39
shared/editor/plugins/DateTime.ts
Normal 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;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user