diff --git a/web/components/la-editor/la-editor.tsx b/web/components/la-editor/la-editor.tsx index f55f4162..147a02b5 100644 --- a/web/components/la-editor/la-editor.tsx +++ b/web/components/la-editor/la-editor.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { EditorContent, useEditor } from "@tiptap/react" import { Editor, Content } from "@tiptap/core" @@ -9,21 +7,22 @@ import { createExtensions } from "./extensions" import "./styles/index.css" import { cn } from "@/lib/utils" import { getOutput } from "./lib/utils" +import { EditorView } from "@tiptap/pm/view" export interface LAEditorProps extends Omit, "value"> { - initialContent?: any output?: "html" | "json" | "text" placeholder?: string editorClassName?: string onUpdate?: (content: Content) => void onBlur?: (content: Content) => void onNewBlock?: (content: Content) => void - value?: Content + handleKeyDown?: (view: EditorView, event: KeyboardEvent) => boolean + value?: any throttleDelay?: number } export interface LAEditorRef { - focus: () => void + editor: Editor | null } interface CustomEditor extends Editor { @@ -33,7 +32,6 @@ interface CustomEditor extends Editor { export const LAEditor = React.forwardRef( ( { - initialContent, value, placeholder, output = "html", @@ -42,6 +40,7 @@ export const LAEditor = React.forwardRef( onUpdate, onBlur, onNewBlock, + handleKeyDown, throttleDelay = 1000, ...props }, @@ -66,9 +65,7 @@ export const LAEditor = React.forwardRef( typeof customEditor.previousBlockCount === "number" && currentBlockCount > customEditor.previousBlockCount ) { - requestAnimationFrame(() => { - onNewBlock?.(newContent) - }) + onNewBlock?.(newContent) } customEditor.previousBlockCount = currentBlockCount @@ -79,6 +76,7 @@ export const LAEditor = React.forwardRef( const editor = useEditor({ autofocus: false, + immediatelyRender: false, extensions: createExtensions({ placeholder }), editorProps: { attributes: { @@ -86,44 +84,29 @@ export const LAEditor = React.forwardRef( autocorrect: "off", autocapitalize: "off", class: editorClassName || "" - } + }, + handleKeyDown }, onCreate: ({ editor }) => { - if (editor.isEmpty && value) { - editor.commands.setContent(value) - } + editor.commands.setContent(value) }, onUpdate: ({ editor }) => handleUpdate(editor), onBlur: ({ editor }) => { - requestAnimationFrame(() => { - onBlur?.(getOutput(editor, output)) - }) + onBlur?.(getOutput(editor, output)) } }) - React.useEffect(() => { - if (editor && initialContent) { - // https://github.com/ueberdosis/tiptap/issues/3764 - setTimeout(() => { - editor.commands.setContent(initialContent) - }) - } - }, [editor, initialContent]) - React.useEffect(() => { if (lastThrottledContent !== throttledContent) { setLastThrottledContent(throttledContent) - - requestAnimationFrame(() => { - onUpdate?.(throttledContent!) - }) + onUpdate?.(throttledContent!) } }, [throttledContent, lastThrottledContent, onUpdate]) React.useImperativeHandle( ref, () => ({ - focus: () => editor?.commands.focus() + editor: editor }), [editor] ) diff --git a/web/components/routes/page/detail/wrapper.tsx b/web/components/routes/page/detail/wrapper.tsx index 85a3ee78..5abab1a5 100644 --- a/web/components/routes/page/detail/wrapper.tsx +++ b/web/components/routes/page/detail/wrapper.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useEffect, useRef } from "react" +import React, { useCallback, useRef, useEffect } from "react" import { LAEditor, LAEditorRef } from "@/components/la-editor" import { DetailPageHeader } from "./header" import { ID } from "jazz-tools" @@ -8,132 +8,177 @@ import { PersonalPage } from "@/lib/schema/personal-page" import { Content, EditorContent, useEditor } from "@tiptap/react" import { StarterKit } from "@/components/la-editor/extensions/starter-kit" import { Paragraph } from "@/components/la-editor/extensions/paragraph" -import { useCoState } from "@/lib/providers/jazz-provider" +import { useAccount, useCoState } from "@/lib/providers/jazz-provider" import { toast } from "sonner" import { EditorView } from "prosemirror-view" +import { Editor } from "@tiptap/core" +import { generateUniqueSlug } from "@/lib/utils" -const configureStarterKit = () => - StarterKit.configure({ - bold: false, - italic: false, - typography: false, - hardBreak: false, - listItem: false, - strike: false, - focus: false, - gapcursor: false, - history: false, - placeholder: { - placeholder: "Page title" - } - }) - -const editorProps = { - attributes: { - spellcheck: "true", - role: "textbox", - "aria-readonly": "false", - "aria-multiline": "false", - "aria-label": "Page title", - translate: "no" - } -} +const TITLE_PLACEHOLDER = "Page title" export function DetailPageWrapper({ pageId }: { pageId: string }) { const page = useCoState(PersonalPage, pageId as ID) - const contentEditorRef = useRef(null) - const handleKeyDown = (view: EditorView, event: KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - contentEditorRef.current?.focus() - return true - } - return false - } - - const handleTitleBlur = (title: string) => { - if (page && editor) { - if (!title) { - toast.error("Update failed", { - description: "Title must be longer than or equal to 1 character" - }) - - // https://github.com/ueberdosis/tiptap/issues/3764 - setTimeout(() => { - editor.commands.setContent(`

${page.title}

`) - }) - } else { - page.title = title - } - } - } - - const editor = useEditor({ - extensions: [configureStarterKit(), Paragraph], - editorProps: { - ...editorProps, - handleKeyDown: handleKeyDown as unknown as (view: EditorView, event: KeyboardEvent) => boolean | void - }, - onBlur: ({ editor }) => handleTitleBlur(editor.getText()) - }) - - const handleContentUpdate = (content: Content) => { - console.log("content", content) - } - - const updatePageContent = (content: Content) => { - if (page) { - page.content = content - } - } - - useEffect(() => { - if (page && editor) { - setTimeout(() => { - editor.commands.setContent(`

${page.title}

`) - }) - } - }, [page, editor]) - - if (!editor) { - return null - } + if (!page) return
Loading...
return (
} /> -
-
-
-
- -
-
-
- -
-
-
-
-
+
) } + +const DetailPageForm = ({ page }: { page: PersonalPage }) => { + const { me } = useAccount() + + const titleEditorRef = useRef(null) + const contentEditorRef = useRef(null) + + const updatePageContent = (content: Content, model: PersonalPage) => { + model.content = content + } + + const handleTitleBlur = (editor: Editor) => { + const newTitle = editor.getText().trim() + + if (!newTitle) { + toast.error("Update failed", { + description: "Title must be longer than or equal to 1 character" + }) + editor.commands.setContent(page.title) + return + } + + if (newTitle === page.title) return + + const personalPages = me.root?.personalPages?.toJSON() || [] + const slug = generateUniqueSlug(personalPages, page.slug) + + page.title = newTitle + page.slug = slug + } + + const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => { + const editor = titleEditorRef.current + if (!editor) return false + + const { state } = editor + const { selection } = state + const { $anchor } = selection + + switch (event.key) { + case "ArrowRight": + case "ArrowDown": + if ($anchor.pos === state.doc.content.size - 1) { + event.preventDefault() + contentEditorRef.current?.editor?.commands.focus("start") + return true + } + break + case "Enter": + if (!event.shiftKey) { + event.preventDefault() + contentEditorRef.current?.editor?.commands.focus("start") + return true + } + break + } + + return false + }, []) + + const handleContentKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => { + const editor = contentEditorRef.current?.editor + if (!editor) return false + + const { state } = editor + const { selection } = state + const { $anchor } = selection + + if ((event.key === "ArrowLeft" || event.key === "ArrowUp") && $anchor.pos - 1 === 0) { + event.preventDefault() + titleEditorRef.current?.commands.focus("end") + return true + } + + return false + }, []) + + const titleEditor = useEditor({ + immediatelyRender: false, + extensions: [ + Paragraph, + StarterKit.configure({ + bold: false, + italic: false, + typography: false, + hardBreak: false, + listItem: false, + strike: false, + focus: false, + gapcursor: false, + history: false, + placeholder: { + placeholder: TITLE_PLACEHOLDER + } + }) + ], + editorProps: { + attributes: { + spellcheck: "true", + role: "textbox", + "aria-readonly": "false", + "aria-multiline": "false", + "aria-label": TITLE_PLACEHOLDER, + translate: "no" + }, + handleKeyDown: handleTitleKeyDown + }, + onCreate: ({ editor }) => { + editor.commands.setContent(`

${page.title}

`) + }, + onBlur: ({ editor }) => handleTitleBlur(editor) + }) + + useEffect(() => { + if (titleEditor) { + titleEditorRef.current = titleEditor + } + }, [titleEditor]) + + return ( +
+
+
+
+ +
+
+
+ updatePageContent(c, page)} + handleKeyDown={handleContentKeyDown} + onBlur={c => updatePageContent(c, page)} + onNewBlock={c => updatePageContent(c, page)} + /> +
+
+
+
+
+ ) +} diff --git a/web/package.json b/web/package.json index efaeec5c..494bd9f1 100644 --- a/web/package.json +++ b/web/package.json @@ -57,20 +57,20 @@ "@tiptap/react": "^2.5.9", "@tiptap/suggestion": "^2.5.9", "axios": "^1.7.3", - "cheerio": "^1.0.0-rc.12", + "cheerio": "1.0.0-rc.12", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", - "jazz-react": "^0.7.23", - "jazz-tools": "^0.7.23", - "jotai": "^2.9.1", + "jazz-react": "^0.7.25", + "jazz-tools": "^0.7.25", + "jotai": "^2.9.2", "lowlight": "^3.1.0", "lucide-react": "^0.424.0", "next": "14.2.5", "next-themes": "^0.3.0", "react": "^18.3.1", - "react-day-picker": "^9.0.7", + "react-day-picker": "^9.0.8", "react-dom": "^18.3.1", "react-hook-form": "^7.52.2", "react-use": "^17.5.1", @@ -84,20 +84,20 @@ "zsa": "^0.5.1" }, "devDependencies": { - "@types/node": "^22.1.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "ts-jest": "^29.2.4", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@types/jest": "^29.5.12", + "@types/node": "^22.1.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "eslint": "^9.8.0", "eslint-config-next": "14.2.5", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "postcss": "^8.4.41", "prettier-plugin-tailwindcss": "^0.6.5", - "tailwindcss": "^3.4.7", + "tailwindcss": "^3.4.9", + "ts-jest": "^29.2.4", "typescript": "^5.5.4" } }