Files
hudozka/modules/hudozka-writer/src/components/Editor.tsx
T
tmshv ed3f93de53 PB + Writer (#287)
* 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
2026-04-12 19:02:03 +03:00

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>
)
}