diff --git a/apps/web/src/components/PlainTextEditor.tsx b/apps/web/src/components/PlainTextEditor.tsx new file mode 100644 index 00000000..8525ad1c --- /dev/null +++ b/apps/web/src/components/PlainTextEditor.tsx @@ -0,0 +1,130 @@ +import Placeholder from "@tiptap/extension-placeholder"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { useEffect, useRef } from "react"; +import { twMerge } from "tailwind-merge"; + +interface PlainTextEditorProps { + content: string; + onChange?: (value: string) => void; + onBlur?: (value: string) => void; + onEnter?: (value: string) => void; + onEscape?: () => void; + readOnly?: boolean; + placeholder?: string; + className?: string; +} + +export default function PlainTextEditor({ + content, + onChange, + onBlur, + onEnter, + onEscape, + readOnly = false, + placeholder, + className, +}: PlainTextEditorProps) { + const onEnterRef = useRef(onEnter); + const onEscapeRef = useRef(onEscape); + const onBlurRef = useRef(onBlur); + const onChangeRef = useRef(onChange); + const contentRef = useRef(content); + + useEffect(() => { + onEnterRef.current = onEnter; + }, [onEnter]); + useEffect(() => { + onEscapeRef.current = onEscape; + }, [onEscape]); + useEffect(() => { + onBlurRef.current = onBlur; + }, [onBlur]); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + useEffect(() => { + contentRef.current = content; + }, [content]); + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + bold: false, + italic: false, + strike: false, + code: false, + codeBlock: false, + blockquote: false, + heading: false, + bulletList: false, + orderedList: false, + listItem: false, + horizontalRule: false, + hardBreak: false, + }), + Placeholder.configure({ placeholder }), + ], + content, + editable: !readOnly, + onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()), + onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()), + editorProps: { + handleKeyDown: (view, event) => { + if (event.key === "Enter") { + event.preventDefault(); + onEnterRef.current?.(view.state.doc.textContent.trim()); + return true; + } + if (event.key === "Escape") { + event.preventDefault(); + // Reset to original content before calling the callback + editor?.commands.setContent(contentRef.current, false); + editor?.commands.blur(); + onEscapeRef.current?.(); + return true; + } + return false; + }, + attributes: { + class: "outline-none focus:outline-none focus-visible:ring-0", + }, + }, + }, + [], + ); + + useEffect(() => { + if (!editor) return; + if (content !== editor.getText()) { + editor.commands.setContent(content, false); + } + }, [content, editor]); + + useEffect(() => { + if (!editor) return; + editor.setEditable(!readOnly); + }, [readOnly, editor]); + + return ( + <> + + + + ); +} diff --git a/apps/web/src/views/card/components/ChecklistItemRow.tsx b/apps/web/src/views/card/components/ChecklistItemRow.tsx index c8afa9c2..32c689dd 100644 --- a/apps/web/src/views/card/components/ChecklistItemRow.tsx +++ b/apps/web/src/views/card/components/ChecklistItemRow.tsx @@ -1,11 +1,11 @@ import type { DraggableProvided } from "react-beautiful-dnd"; import { t } from "@lingui/core/macro"; -import { useEffect, useState } from "react"; -import ContentEditable from "react-contenteditable"; +import { useState } from "react"; import { HiXMark } from "react-icons/hi2"; import { RiDraggable } from "react-icons/ri"; import { twMerge } from "tailwind-merge"; +import PlainTextEditor from "~/components/PlainTextEditor"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; import { invalidateCard } from "~/utils/cardInvalidation"; @@ -33,9 +33,7 @@ export default function ChecklistItemRow({ }: ChecklistItemRowProps) { const utils = api.useUtils(); const { showPopup } = usePopup(); - - const [title, setTitle] = useState(""); - const [completed, setCompleted] = useState(false); + const [completed, setCompleted] = useState(item.completed); const updateItem = api.checklist.updateItem.useMutation({ onMutate: async (vars) => { @@ -103,21 +101,6 @@ export default function ChecklistItemRow({ }, }); - // Only resync from props when switching items to avoid clobbering edits - useEffect(() => { - setTitle(item.title); - setCompleted(item.completed); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item.publicId]); - - const sanitizeHtmlToPlainText = (html: string): string => - html - .replace(/(\n)?/gi, "\n") - .replace(/
<\/div>/gi, "") - .replace(/<[^>]*>/g, "") - .replace(/ /g, " ") - .trim(); - const handleToggleCompleted = () => { if (viewOnly) return; setCompleted((prev) => !prev); @@ -127,14 +110,8 @@ export default function ChecklistItemRow({ }); }; - const commitTitle = (rawHtml: string) => { - if (viewOnly) return; - const plain = sanitizeHtmlToPlainText(rawHtml); - if (!plain || plain === item.title) { - setTitle(item.title); - return; - } - setTitle(plain); + const commitTitle = (plain: string) => { + if (!plain || plain === item.title) return; updateItem.mutate({ checklistItemPublicId: item.publicId, title: plain, @@ -183,36 +160,26 @@ export default function ChecklistItemRow({ )} /> +
- setTitle(e.target.value)} - // @ts-expect-error - valid event - onBlur={(e: Event) => { - const innerHTML = (e.target as HTMLElement).innerHTML; - commitTitle(innerHTML); + { + commitTitle(plain); + onCreateNewItem?.(); }} + onEscape={() => undefined} className={twMerge( - "m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950", + "m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 dark:text-dark-950", viewOnly && "cursor-default", )} - placeholder={t`Add details...`} - onKeyDown={(e) => { - if (viewOnly) return; - if (e.key === "Enter") { - e.preventDefault(); - const innerHTML = (e.currentTarget as HTMLElement).innerHTML; - commitTitle(innerHTML); - onCreateNewItem?.(); - } - if (e.key === "Escape") { - e.preventDefault(); - setTitle(item.title); - } - }} />
+ {!viewOnly && (