* add pb models * api rules has updated to view public * pb setup is completed * add migrate script * add pb migrations * ignore storage * add pocketbase SDK dependency * change Tag.id and PageCardDto.id to string for PocketBase * add DocV1 content block types * add PB client singleton and PB record types * rewrite factory for PocketBase record types * rewrite api.ts for PocketBase SDK * remove unused Strapi files, add PB to config * mark Task 7 complete in plan * feat: verify build, tests, and lint pass with PocketBase API layer * move completed plan: 2026-04-06-pocketbase-api-layer.md * scaffold hudozka-writer with Vite 8, React 19, TS 5.9, Tiptap * add PocketBase client and DocV1 types to writer * add Login component for PocketBase superuser auth * add App shell with auth gate and page loader * add DocV1 <-> Tiptap serialization layer * mark Task 5 complete in writer redesign plan * add custom Tiptap nodes for DocV1 block types * add Tiptap editor with toolbar and save to PocketBase * add image picker with browse and upload * feat: verify writer build and complete integration task * move completed plan: 2026-04-06-hudozka-writer-redesign.md * add local env * rm textblock from writer * add writer editor improvements design spec * add writer editor improvements implementation plan * feat: use @tiptap/markdown for text block serialization * feat: add global markdown/rendered toggle * feat: add BlockMenu component for block type selection * feat: add hover + button for block insertion * feat: add slash commands for block insertion * feat: add interactive file block editor with FilePicker * feat: show resolved page titles in card grid block * move completed plan: 2026-04-07-writer-editor-improvements.md * writer ui improved * pages migration * spec: draft/publish workflow for hudozka-writer * spec: rename draft_doc to draft in spec * plan: writer draft/publish implementation * plan has moved * gitignores * plans upd * feat(writer): update PbPage type for draft/publish workflow * feat(writer): add save/publish/discard handlers with dirty tracking * feat(writer): add DraftBanner component * feat(writer): add Publish button and update toolbar layout * feat(writer): add published toggle and update duplicate for new schema * docs: mark manual verification steps as skipped in writer draft/publish plan * move completed plan: 20260407-writer-draft-publish.md * feat(writer): add BlockActions component for inline block menus * feat(writer): add draggable NodeViews for text blocks * feat(writer): use BlockActions in custom block NodeViews * feat(writer): replace floating BlockInsert with inline BlockActions * fix(writer): ensure all block wrappers have position: relative for BlockActions * chore: mark manual verification tasks as skipped in writer block actions plan * move completed plan: 20260408-writer-block-actions.md * writer update * refactor(writer): simplify BlockActions to always-visible delete + drag handle * feat(writer): add BlockInsert component for inserting blocks between nodes * feat(writer): redesign block sidebar with always-visible actions and insert button * move completed plan: 20260408-writer-block-actions-redesign.md * add spec: textblock container redesign for writer editor * feat(writer): add TextBlock container node definition * feat(writer): replace DraggableXxx wrappers with TextBlock container * feat(writer): update Editor extensions for TextBlock container * feat(writer): add split button to BlockActions for text blocks * feat(writer): update serialization for TextBlock container * feat(writer): update BlockMenu and SlashCommands for TextBlock * fix(writer): use Fragment.from() for replaceWith calls in BlockActions and SlashCommands * move completed plan: 20260408-textblock-container.md * add air
175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
import { useEditor, EditorContent } from "@tiptap/react"
|
|
import StarterKit from "@tiptap/starter-kit"
|
|
import { Markdown } from "@tiptap/markdown"
|
|
import Link from "@tiptap/extension-link"
|
|
import { useState, useCallback, useRef, useEffect } from "react"
|
|
import { pb } from "../pb"
|
|
import { docToTiptap, tiptapToDoc } from "../lib/serialize"
|
|
import { Toolbar } from "./Toolbar"
|
|
import { DraftBanner } from "./DraftBanner"
|
|
import {
|
|
WriterDocument,
|
|
TextBlock,
|
|
ImageBlock,
|
|
DocumentBlock,
|
|
EmbedBlock,
|
|
CardGridBlock,
|
|
} from "../nodes"
|
|
import { SlashCommands } from "../extensions/SlashCommands"
|
|
import "../nodes/nodes.css"
|
|
import "./Editor.css"
|
|
import type { PbPage, DocV1 } from "../types"
|
|
|
|
function serializeDoc(doc: DocV1): string {
|
|
return JSON.stringify(doc)
|
|
}
|
|
|
|
export type EditorProps = {
|
|
page: PbPage
|
|
}
|
|
|
|
export function Editor({ page }: EditorProps) {
|
|
const [saving, setSaving] = useState(false)
|
|
const [publishing, setPublishing] = useState(false)
|
|
const [markdownMode, setMarkdownMode] = useState(false)
|
|
const [markdownText, setMarkdownText] = useState("")
|
|
const [dirty, setDirty] = useState(false)
|
|
const [hasDraft, setHasDraft] = useState(page.draft !== null)
|
|
const [publishedDoc, setPublishedDoc] = useState(page.doc)
|
|
|
|
const snapshotRef = useRef("")
|
|
|
|
const initialDoc = page.draft ?? page.doc
|
|
const tiptapDoc = docToTiptap(initialDoc)
|
|
|
|
// Set initial snapshot
|
|
useEffect(() => {
|
|
snapshotRef.current = serializeDoc(initialDoc)
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const editor = useEditor({
|
|
extensions: [
|
|
WriterDocument,
|
|
StarterKit.configure({
|
|
document: false,
|
|
}),
|
|
TextBlock,
|
|
Markdown,
|
|
Link.configure({ openOnClick: false }),
|
|
ImageBlock,
|
|
DocumentBlock,
|
|
EmbedBlock,
|
|
CardGridBlock,
|
|
SlashCommands,
|
|
],
|
|
content: tiptapDoc,
|
|
onUpdate: ({ editor }) => {
|
|
const json = editor.getJSON()
|
|
const doc = tiptapToDoc(json.content ?? [])
|
|
const current = serializeDoc(doc)
|
|
setDirty(current !== snapshotRef.current)
|
|
},
|
|
})
|
|
|
|
const getCurrentDoc = useCallback((): DocV1 => {
|
|
if (!editor) return { version: 1, blocks: [] }
|
|
if (markdownMode) {
|
|
editor.commands.setContent(markdownText, { contentType: "markdown" })
|
|
setMarkdownMode(false)
|
|
}
|
|
const json = editor.getJSON()
|
|
return tiptapToDoc(json.content ?? [])
|
|
}, [editor, markdownMode, markdownText])
|
|
|
|
const handleToggleMarkdown = useCallback(() => {
|
|
if (!editor) return
|
|
if (!markdownMode) {
|
|
const md = editor.getMarkdown()
|
|
setMarkdownText(md)
|
|
setMarkdownMode(true)
|
|
} else {
|
|
editor.commands.setContent(markdownText, { contentType: "markdown" })
|
|
setMarkdownMode(false)
|
|
}
|
|
}, [editor, markdownMode, markdownText])
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!editor) return
|
|
setSaving(true)
|
|
try {
|
|
const doc = getCurrentDoc()
|
|
await pb.collection("pages").update(page.id, { draft: doc })
|
|
snapshotRef.current = serializeDoc(doc)
|
|
setDirty(false)
|
|
setHasDraft(true)
|
|
} catch (err) {
|
|
console.error("Save failed:", err)
|
|
alert("Save failed. Check console.")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [editor, page.id, getCurrentDoc])
|
|
|
|
const handlePublish = useCallback(async () => {
|
|
if (!editor) return
|
|
setPublishing(true)
|
|
try {
|
|
const doc = getCurrentDoc()
|
|
await pb.collection("pages").update(page.id, { doc, draft: null })
|
|
snapshotRef.current = serializeDoc(doc)
|
|
setDirty(false)
|
|
setHasDraft(false)
|
|
setPublishedDoc(doc)
|
|
} catch (err) {
|
|
console.error("Publish failed:", err)
|
|
alert("Publish failed. Check console.")
|
|
} finally {
|
|
setPublishing(false)
|
|
}
|
|
}, [editor, page.id, getCurrentDoc])
|
|
|
|
const handleDiscard = useCallback(async () => {
|
|
if (!editor) return
|
|
try {
|
|
await pb.collection("pages").update(page.id, { draft: null })
|
|
const tiptap = docToTiptap(publishedDoc)
|
|
editor.commands.setContent(tiptap)
|
|
snapshotRef.current = serializeDoc(publishedDoc)
|
|
setDirty(false)
|
|
setHasDraft(false)
|
|
setMarkdownMode(false)
|
|
} catch (err) {
|
|
console.error("Discard failed:", err)
|
|
alert("Discard failed. Check console.")
|
|
}
|
|
}, [editor, page.id, publishedDoc])
|
|
|
|
return (
|
|
<div className="editor">
|
|
<Toolbar
|
|
editor={editor}
|
|
onSave={handleSave}
|
|
onPublish={handlePublish}
|
|
saving={saving}
|
|
publishing={publishing}
|
|
dirty={dirty}
|
|
hasDraft={hasDraft}
|
|
markdownMode={markdownMode}
|
|
onToggleMarkdown={handleToggleMarkdown}
|
|
/>
|
|
{hasDraft && (
|
|
<DraftBanner onDiscard={handleDiscard} />
|
|
)}
|
|
{markdownMode ? (
|
|
<textarea
|
|
className="editor-markdown"
|
|
value={markdownText}
|
|
onChange={(e) => setMarkdownText(e.target.value)}
|
|
/>
|
|
) : (
|
|
<EditorContent editor={editor} className="editor-content" />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|