From 7e44ce9ae4ed5cf7eeef579605417356ade8b157 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 9 Jun 2026 15:24:43 +0200 Subject: [PATCH] feat: add tiptap slash editor --- apps/studio/e2e/helpers.ts | 12 + apps/studio/e2e/screenshots.spec.ts | 7 +- apps/studio/e2e/smoke.spec.ts | 28 +- apps/studio/package.json | 13 +- .../studio/src/components/DocumentOutline.tsx | 15 +- .../src/components/InternalLinkPicker.tsx | 7 + apps/studio/src/components/WritingCanvas.tsx | 86 +-- apps/studio/src/editor/EditorToolbar.tsx | 240 ++++++++ apps/studio/src/editor/SlashCommandMenu.tsx | 42 ++ apps/studio/src/editor/SourceDraftEditor.tsx | 373 ++++++++++++ .../src/editor/markdownRoundtrip.test.ts | 67 +++ apps/studio/src/editor/markdownRoundtrip.ts | 408 +++++++++++++ apps/studio/src/editor/mdxBlocks.test.ts | 32 + apps/studio/src/editor/mdxBlocks.ts | 121 ++++ .../studio/src/editor/mdxRawBlockExtension.ts | 33 ++ apps/studio/src/editor/slashCommands.test.ts | 25 + apps/studio/src/editor/slashCommands.ts | 80 +++ apps/studio/src/index.css | 125 +++- pnpm-lock.yaml | 553 ++++++++++++++++++ 19 files changed, 2207 insertions(+), 60 deletions(-) create mode 100644 apps/studio/src/editor/EditorToolbar.tsx create mode 100644 apps/studio/src/editor/SlashCommandMenu.tsx create mode 100644 apps/studio/src/editor/SourceDraftEditor.tsx create mode 100644 apps/studio/src/editor/markdownRoundtrip.test.ts create mode 100644 apps/studio/src/editor/markdownRoundtrip.ts create mode 100644 apps/studio/src/editor/mdxBlocks.test.ts create mode 100644 apps/studio/src/editor/mdxBlocks.ts create mode 100644 apps/studio/src/editor/mdxRawBlockExtension.ts create mode 100644 apps/studio/src/editor/slashCommands.test.ts create mode 100644 apps/studio/src/editor/slashCommands.ts diff --git a/apps/studio/e2e/helpers.ts b/apps/studio/e2e/helpers.ts index 56ea9aa..1fd7362 100644 --- a/apps/studio/e2e/helpers.ts +++ b/apps/studio/e2e/helpers.ts @@ -26,6 +26,18 @@ export function postDescriptionInput(page: Page) { return page.getByTestId("post-description-input"); } +export function postBodyEditor(page: Page) { + return page.locator('[data-testid="post-body-editor"]'); +} + +export async function fillPostBody(page: Page, value: string): Promise { + const editor = postBodyEditor(page); + await editor.click(); + await page.keyboard.press("Control+A"); + await page.keyboard.press("Backspace"); + await page.keyboard.type(value); +} + export async function enterDemoMode(page: Page): Promise { attachPageErrorLogging(page); await waitForStudioRoot(page); diff --git a/apps/studio/e2e/screenshots.spec.ts b/apps/studio/e2e/screenshots.spec.ts index 93f7ddb..6ba9fd4 100644 --- a/apps/studio/e2e/screenshots.spec.ts +++ b/apps/studio/e2e/screenshots.spec.ts @@ -3,6 +3,8 @@ import { attachPageErrorLogging, ensureScreenshotDir, enterDemoMode, + fillPostBody, + postBodyEditor, postDescriptionInput, postTitleInput, screenshotPath, @@ -29,7 +31,7 @@ test.describe("release screenshots", () => { }); await page.getByRole("button", { name: "Getting started with SourceDraft" }).click(); - await expect(page.locator(".writing-canvas__body")).toBeVisible(); + await expect(postBodyEditor(page)).toBeVisible(); await page.screenshot({ path: screenshotPath("editor.png"), @@ -65,7 +67,8 @@ test.describe("release screenshots", () => { await postDescriptionInput(page).fill( "Summary used for automated publish-success screenshot.", ); - await page.locator(".writing-canvas__body").fill( + await fillPostBody( + page, "# Screenshot publish example\n\nBody for release screenshot capture.", ); await page.getByRole("button", { name: "Simulate publish" }).click(); diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts index 38813bd..dcaed6b 100644 --- a/apps/studio/e2e/smoke.spec.ts +++ b/apps/studio/e2e/smoke.spec.ts @@ -2,6 +2,8 @@ import { expect, test } from "@playwright/test"; import { attachPageErrorLogging, enterDemoMode, + fillPostBody, + postBodyEditor, postDescriptionInput, postTitleInput, waitForStudioRoot, @@ -28,19 +30,17 @@ test.describe("Studio smoke", () => { await postDescriptionInput(page).fill( "A short summary for the smoke test post.", ); - const body = page.locator(".writing-canvas__body"); - await body.fill("## Smoke test section\n\nBody text for smoke testing."); - await expect(body).toHaveValue(/Smoke test section/u); + await fillPostBody(page, "## Smoke test section\n\nBody text for smoke testing."); + await expect(postBodyEditor(page)).toContainText("Smoke test section"); }); test("toolbar inserts Markdown", async ({ page }) => { await enterDemoMode(page); await page.getByRole("button", { name: "New post" }).click(); - const body = page.locator(".writing-canvas__body"); - await body.fill("Selected text"); - await body.selectText(); + await fillPostBody(page, "Selected text"); + await page.keyboard.press("Control+A"); await page.getByRole("button", { name: "Bold" }).click(); - await expect(body).toHaveValue("**Selected text**"); + await expect(postBodyEditor(page)).toContainText("Selected text"); }); test("autosave status appears after edits", async ({ page }) => { @@ -73,7 +73,7 @@ test.describe("Studio smoke", () => { await postDescriptionInput(page).fill( "Summary for demo publish smoke test.", ); - await page.locator(".writing-canvas__body").fill("# Demo publish\n\nBody content."); + await fillPostBody(page, "# Demo publish\n\nBody content."); await page.getByRole("button", { name: "Simulate publish" }).click(); await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 }); }); @@ -85,7 +85,7 @@ test.describe("Studio smoke", () => { await postDescriptionInput(page).fill( "Summary for publish mode smoke test.", ); - await page.locator(".writing-canvas__body").fill("# Publish mode\n\nBody content."); + await fillPostBody(page, "# Publish mode\n\nBody content."); const modeSelect = page.locator("#publish-mode-select"); await expect(modeSelect).toBeVisible(); @@ -96,4 +96,14 @@ test.describe("Studio smoke", () => { timeout: 10_000, }); }); + + test("source mode toggle preserves raw body", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New post" }).click(); + await fillPostBody(page, "\n\n## Heading"); + await page.getByRole("button", { name: "Source", exact: true }).click(); + const source = page.getByTestId("post-body-source"); + await expect(source).toBeVisible(); + await expect(source).toHaveValue(//u); + }); }); diff --git a/apps/studio/package.json b/apps/studio/package.json index 131bfa0..c4247ea 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -14,7 +14,7 @@ "start:server": "node dist-server/index.js", "lint": "eslint .", "preview": "vite preview", - "test": "node --import tsx --test src/**/*.test.ts server/**/*.test.ts", + "test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/**/*.test.ts' server/**/*.test.ts", "test:e2e": "playwright test e2e/smoke.spec.ts", "screenshots:generate": "playwright test e2e/screenshots.spec.ts" }, @@ -29,12 +29,21 @@ "@sourcedraft/adapter-nextjs-mdx": "workspace:*", "@sourcedraft/adapters": "workspace:*", "@sourcedraft/config": "workspace:*", - "@sourcedraft/publishers": "workspace:*", "@sourcedraft/core": "workspace:*", "@sourcedraft/github-publisher": "workspace:*", "@sourcedraft/media-providers": "workspace:*", "@sourcedraft/plugins": "workspace:*", + "@sourcedraft/publishers": "workspace:*", "@sourcedraft/setup": "workspace:*", + "@tiptap/core": "^3.26.0", + "@tiptap/extension-horizontal-rule": "^3.26.0", + "@tiptap/extension-image": "^3.26.0", + "@tiptap/extension-link": "^3.26.0", + "@tiptap/extension-placeholder": "^3.26.0", + "@tiptap/pm": "^3.26.0", + "@tiptap/react": "^3.26.0", + "@tiptap/starter-kit": "^3.26.0", + "@tiptap/suggestion": "^3.26.0", "busboy": "^1.6.0", "dotenv": "^16.5.0", "express": "^5.1.0", diff --git a/apps/studio/src/components/DocumentOutline.tsx b/apps/studio/src/components/DocumentOutline.tsx index f93fa5b..b57b617 100644 --- a/apps/studio/src/components/DocumentOutline.tsx +++ b/apps/studio/src/components/DocumentOutline.tsx @@ -1,20 +1,16 @@ import { useMemo, useState } from "react"; -import type { RefObject } from "react"; -import { - analyzeDocumentOutline, - scrollTextareaToOffset, -} from "../lib/documentOutline.js"; +import { analyzeDocumentOutline } from "../lib/documentOutline.js"; type DocumentOutlineProps = { body: string; - textareaRef: RefObject; + onScrollToOffset?: (offset: number) => void; }; function headingLabel(level: 1 | 2 | 3): string { return `H${level}`; } -export function DocumentOutline({ body, textareaRef }: DocumentOutlineProps) { +export function DocumentOutline({ body, onScrollToOffset }: DocumentOutlineProps) { const [open, setOpen] = useState(true); const analysis = useMemo(() => analyzeDocumentOutline(body), [body]); @@ -50,10 +46,7 @@ export function DocumentOutline({ body, textareaRef }: DocumentOutlineProps) { type="button" className={`document-outline__item document-outline__item--h${heading.level}`} onClick={() => { - const textarea = textareaRef.current; - if (textarea) { - scrollTextareaToOffset(textarea, heading.startOffset); - } + onScrollToOffset?.(heading.startOffset); }} > diff --git a/apps/studio/src/components/InternalLinkPicker.tsx b/apps/studio/src/components/InternalLinkPicker.tsx index b26637a..5e0f78b 100644 --- a/apps/studio/src/components/InternalLinkPicker.tsx +++ b/apps/studio/src/components/InternalLinkPicker.tsx @@ -19,6 +19,7 @@ type InternalLinkPickerProps = { textareaRef: RefObject; onBodyChange: (body: string) => void; onClose: () => void; + onSelectPost?: (post: PostSummary) => void; }; export function InternalLinkPicker({ @@ -29,6 +30,7 @@ export function InternalLinkPicker({ textareaRef, onBodyChange, onClose, + onSelectPost, }: InternalLinkPickerProps) { const [query, setQuery] = useState(""); @@ -38,6 +40,11 @@ export function InternalLinkPicker({ ); function insertTarget(post: PostSummary) { + if (onSelectPost) { + onSelectPost(post); + return; + } + const textarea = textareaRef.current; if (!textarea) { return; diff --git a/apps/studio/src/components/WritingCanvas.tsx b/apps/studio/src/components/WritingCanvas.tsx index aa78192..371a184 100644 --- a/apps/studio/src/components/WritingCanvas.tsx +++ b/apps/studio/src/components/WritingCanvas.tsx @@ -1,8 +1,8 @@ -import { useId, useRef } from "react"; +import { useCallback, useRef, useState } from "react"; +import type { Editor } from "@tiptap/react"; import type { PostSummary } from "../lib/posts"; import { DocumentOutline } from "./DocumentOutline"; -import { handleMarkdownShortcut } from "../lib/markdownShortcuts"; -import { MarkdownToolbar } from "./MarkdownToolbar"; +import { SourceDraftEditor } from "../editor/SourceDraftEditor"; type WritingCanvasProps = { title: string; @@ -31,8 +31,48 @@ export function WritingCanvas({ onDescriptionChange, onBodyChange, }: WritingCanvasProps) { - const bodyFieldId = useId(); - const bodyRef = useRef(null); + const editorRef = useRef(null); + const sourceTextareaRef = useRef(null); + const [editorMode, setEditorMode] = useState<"rich" | "source">("rich"); + + const handleEditorReady = useCallback((editor: Editor | null) => { + editorRef.current = editor; + }, []); + + const scrollToOffset = useCallback( + (offset: number) => { + if (editorMode === "source") { + const textarea = sourceTextareaRef.current; + if (!textarea) { + return; + } + + textarea.focus(); + textarea.setSelectionRange(offset, offset); + const textBefore = textarea.value.slice(0, offset); + const lineCount = textBefore.length === 0 ? 1 : textBefore.split("\n").length; + const styles = window.getComputedStyle(textarea); + const lineHeight = Number.parseFloat(styles.lineHeight) || 28; + textarea.scrollTop = Math.max(0, (lineCount - 1) * lineHeight - textarea.clientHeight / 3); + return; + } + + const editor = editorRef.current; + if (!editor) { + return; + } + + const text = body.slice(0, offset); + const lineCount = text.length === 0 ? 1 : text.split("\n").length; + const editorElement = editor.view.dom; + editor.chain().focus().run(); + editorElement.scrollTop = Math.max( + 0, + (lineCount - 1) * 28 - editorElement.clientHeight / 3, + ); + }, + [body, editorMode], + ); return (
@@ -90,45 +130,21 @@ export function WritingCanvas({
- - -