import { observer } from "mobx-react"; import { Slice } from "prosemirror-model"; import { Selection } from "prosemirror-state"; import { __parseFromClipboard } from "prosemirror-view"; import * as React from "react"; import { mergeRefs } from "react-merge-refs"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import isMarkdown from "@shared/editor/lib/isMarkdown"; import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize"; import { extraArea, s } from "@shared/styles"; import { light } from "@shared/styles/theme"; import { getCurrentDateAsString, getCurrentDateTimeAsString, getCurrentTimeAsString, } from "@shared/utils/date"; import { DocumentValidation } from "@shared/validations"; import ContentEditable, { RefHandle } from "~/components/ContentEditable"; import { useDocumentContext } from "~/components/DocumentContext"; import Flex from "~/components/Flex"; import Icon from "~/components/Icon"; import { PopoverButton } from "~/components/IconPicker/components/PopoverButton"; import useBoolean from "~/hooks/useBoolean"; import usePolicy from "~/hooks/usePolicy"; import { isModKey } from "~/utils/keyboard"; const IconPicker = React.lazy(() => import("~/components/IconPicker")); type Props = { /** ID of the associated document */ documentId: string; /** Title to display */ title: string; /** Icon to display */ icon?: string | null; /** Icon color */ color: string; /** Placeholder to display when the document has no title */ placeholder?: string; /** Should the title be editable, policies will also be considered separately */ readOnly?: boolean; /** Callback called on any edits to text */ onChangeTitle?: (text: string) => void; /** Callback called when the user selects an icon */ onChangeIcon?: (icon: string | null, color: string | null) => void; /** Callback called when the user expects to move to the "next" input */ onGoToNextInput?: (insertParagraph?: boolean) => void; /** Callback called when the user expects to save (CMD+S) */ onSave?: (options: { publish?: boolean; done?: boolean }) => void; /** Callback called when focus leaves the input */ onBlur?: React.FocusEventHandler; }; const lineHeight = "1.25"; const fontSize = "2.25em"; const DocumentTitle = React.forwardRef(function _DocumentTitle( { documentId, title, icon, color, readOnly, onChangeTitle, onChangeIcon, onSave, onGoToNextInput, onBlur, placeholder, }: Props, externalRef: React.RefObject ) { const ref = React.useRef(null); const [iconPickerIsOpen, handleOpen, setIconPickerClosed] = useBoolean(); const { editor } = useDocumentContext(); const can = usePolicy(documentId); const handleClick = React.useCallback(() => { ref.current?.focus(); }, [ref]); const restoreFocus = React.useCallback(() => { ref.current?.focusAtEnd(); }, [ref]); const handleBlur = React.useCallback( (ev: React.FocusEvent) => { // Do nothing and simply return if the related target is the parent // or a sibling of the current target element(the // containing document title) if ( ev.currentTarget.parentElement === ev.relatedTarget || (ev.relatedTarget && ev.currentTarget.parentElement === ev.relatedTarget.parentElement) ) { return; } if (onBlur) { onBlur(ev); } }, [onBlur] ); const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (event.nativeEvent.isComposing) { return; } if (event.key === "Enter") { event.preventDefault(); if (isModKey(event)) { onSave?.({ done: true, }); return; } onGoToNextInput?.(true); return; } if (event.key === "Tab" || event.key === "ArrowDown") { event.preventDefault(); onGoToNextInput?.(); return; } if (event.key === "s" && isModKey(event)) { event.preventDefault(); onSave?.({}); return; } }, [onGoToNextInput, onSave] ); const handleChange = React.useCallback( (input: string) => { let value = input; if (/\/date\s$/.test(input)) { value = getCurrentDateAsString(); ref?.current?.focusAtEnd(); } else if (/\/time$/.test(input)) { value = getCurrentTimeAsString(); ref?.current?.focusAtEnd(); } else if (/\/datetime$/.test(input)) { value = getCurrentDateTimeAsString(); ref?.current?.focusAtEnd(); } onChangeTitle?.(value); }, [ref, onChangeTitle] ); // Custom paste handling so that if a multiple lines are pasted we // only take the first line and insert the rest directly into the editor. const handlePaste = React.useCallback( (event: React.ClipboardEvent) => { event.preventDefault(); const text = event.clipboardData.getData("text/plain"); const html = event.clipboardData.getData("text/html"); const [firstLine, ...rest] = text.split(`\n`); const content = rest.join(`\n`).trim(); window.document.execCommand( "insertText", false, firstLine.replace(/^#+\s?/, "") ); if (editor && content) { const { view, pasteParser } = editor; let slice; if (isMarkdown(text)) { const paste = pasteParser.parse(normalizePastedMarkdown(content)); if (paste) { slice = paste.slice(0); } } else { const defaultSlice = __parseFromClipboard( view, text, html, false, view.state.selection.$from ); // remove first node from slice slice = defaultSlice.content.firstChild ? new Slice( defaultSlice.content.cut( defaultSlice.content.firstChild.nodeSize ), defaultSlice.openStart, defaultSlice.openEnd ) : defaultSlice; } if (slice) { view.dispatch( view.state.tr .setSelection(Selection.atStart(view.state.doc)) .replaceSelection(slice) ); } } }, [editor] ); const handleClose = React.useCallback(() => { setIconPickerClosed(); restoreFocus(); }, [setIconPickerClosed, restoreFocus]); const handleIconChange = React.useCallback( (chosenIcon: string | null, iconColor: string | null) => { if (icon !== chosenIcon || color !== iconColor) { onChangeIcon?.(chosenIcon, iconColor); } }, [icon, color, onChangeIcon] ); const dir = ref.current?.getComputedDirection(); const fallbackIcon = icon ? ( ) : null; return ( {can.update && !readOnly ? ( <IconWrapper align="center" justify="center" dir={dir}> <React.Suspense fallback={fallbackIcon}> <StyledIconPicker icon={icon ?? null} color={color} size={40} popoverPosition="bottom-start" onChange={handleIconChange} onOpen={handleOpen} onClose={handleClose} allowDelete borderOnHover /> </React.Suspense> </IconWrapper> ) : icon ? ( <IconWrapper align="center" justify="center" dir={dir}> {fallbackIcon} </IconWrapper> ) : null} ); }); type TitleProps = { $containsIcon: boolean; $iconPickerIsOpen: boolean; }; // Extra area prevents gap between icon and beginning of title const StyledIconPicker = styled(IconPicker)` ${extraArea(8)} `; const Title = styled(ContentEditable)` position: relative; line-height: ${lineHeight}; margin-top: 6vh; margin-bottom: 0.5em; margin-left: ${(props) => props.$containsIcon || props.$iconPickerIsOpen ? "40px" : "0px"}; font-size: ${fontSize}; font-weight: 600; border: 0; padding: 0; cursor: ${(props) => (props.readOnly ? "default" : "text")}; > span { outline: none; } &::placeholder { color: ${s("placeholder")}; -webkit-text-fill-color: ${s("placeholder")}; opacity: 1; } &:focus-within, &:focus { margin-left: 40px; ${PopoverButton} { opacity: 1 !important; } } ${PopoverButton} { opacity: ${(props: TitleProps) => props.$containsIcon ? "1 !important" : 0}; } ${breakpoint("tablet")` margin-left: 0; &:focus-within, &:focus { margin-left: 0; } &:hover { ${PopoverButton} { opacity: 0.5; &:hover { opacity: 1; } } }`}; @media print { color: ${light.text}; -webkit-text-fill-color: ${light.text}; background: none; } `; const IconWrapper = styled(Flex)<{ dir?: string }>` position: absolute; top: 3px; height: 40px; width: 40px; // Always move above TOC z-index: 1; ${(props: { dir?: string }) => props.dir === "rtl" ? "right: -48px" : "left: -48px"}; `; export default observer(DocumentTitle);