feat: keyboard navigate between title and content

This commit is contained in:
Aslam H
2024-08-08 22:35:20 +07:00
parent 36e0e19212
commit 2d352605a2
3 changed files with 182 additions and 154 deletions

View File

@@ -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<React.HTMLProps<HTMLDivElement>, "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<LAEditorRef, LAEditorProps>(
(
{
initialContent,
value,
placeholder,
output = "html",
@@ -42,6 +40,7 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
onUpdate,
onBlur,
onNewBlock,
handleKeyDown,
throttleDelay = 1000,
...props
},
@@ -66,9 +65,7 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
typeof customEditor.previousBlockCount === "number" &&
currentBlockCount > customEditor.previousBlockCount
) {
requestAnimationFrame(() => {
onNewBlock?.(newContent)
})
onNewBlock?.(newContent)
}
customEditor.previousBlockCount = currentBlockCount
@@ -79,6 +76,7 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
const editor = useEditor({
autofocus: false,
immediatelyRender: false,
extensions: createExtensions({ placeholder }),
editorProps: {
attributes: {
@@ -86,44 +84,29 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
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]
)

View File

@@ -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<PersonalPage>)
const contentEditorRef = useRef<LAEditorRef>(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(`<p>${page.title}</p>`)
})
} 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(`<p>${page.title}</p>`)
})
}
}, [page, editor])
if (!editor) {
return null
}
if (!page) return <div>Loading...</div>
return (
<div className="flex flex-row">
<div className="flex h-full w-full">
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
<DetailPageHeader pageId={pageId as ID<PersonalPage>} />
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
<form className="flex shrink-0 flex-col">
<div className="mb-2 mt-8 py-1.5">
<EditorContent
editor={editor}
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
/>
</div>
<div className="flex flex-auto flex-col">
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
<LAEditor
ref={contentEditorRef}
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto"
initialContent={page?.content}
placeholder="Add content..."
output="json"
throttleDelay={3000}
onUpdate={handleContentUpdate}
onBlur={updatePageContent}
onNewBlock={updatePageContent}
/>
</div>
</div>
</form>
</div>
</div>
<DetailPageForm page={page} />
</div>
</div>
</div>
)
}
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const { me } = useAccount()
const titleEditorRef = useRef<Editor | null>(null)
const contentEditorRef = useRef<LAEditorRef>(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(`<p>${page.title}</p>`)
},
onBlur: ({ editor }) => handleTitleBlur(editor)
})
useEffect(() => {
if (titleEditor) {
titleEditorRef.current = titleEditor
}
}, [titleEditor])
return (
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
<form className="flex shrink-0 flex-col">
<div className="mb-2 mt-8 py-1.5">
<EditorContent
editor={titleEditor}
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
/>
</div>
<div className="flex flex-auto flex-col">
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
<LAEditor
ref={contentEditorRef}
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto"
value={page.content}
placeholder="Add content..."
output="json"
throttleDelay={3000}
onUpdate={c => updatePageContent(c, page)}
handleKeyDown={handleContentKeyDown}
onBlur={c => updatePageContent(c, page)}
onNewBlock={c => updatePageContent(c, page)}
/>
</div>
</div>
</form>
</div>
</div>
)
}

View File

@@ -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"
}
}