Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/studio/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
attachPageErrorLogging(page);
await waitForStudioRoot(page);
Expand Down
7 changes: 5 additions & 2 deletions apps/studio/e2e/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
attachPageErrorLogging,
ensureScreenshotDir,
enterDemoMode,
fillPostBody,
postBodyEditor,
postDescriptionInput,
postTitleInput,
screenshotPath,
Expand All @@ -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"),
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 19 additions & 9 deletions apps/studio/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { expect, test } from "@playwright/test";
import {
attachPageErrorLogging,
enterDemoMode,
fillPostBody,
postBodyEditor,
postDescriptionInput,
postTitleInput,
waitForStudioRoot,
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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 });
});
Expand All @@ -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();
Expand All @@ -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, "<CustomBlock />\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(/<CustomBlock \/>/u);
});
});
13 changes: 11 additions & 2 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
15 changes: 4 additions & 11 deletions apps/studio/src/components/DocumentOutline.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement | null>;
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]);

Expand Down Expand Up @@ -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);
}}
>
<span className="document-outline__level">
Expand Down
7 changes: 7 additions & 0 deletions apps/studio/src/components/InternalLinkPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type InternalLinkPickerProps = {
textareaRef: RefObject<HTMLTextAreaElement | null>;
onBodyChange: (body: string) => void;
onClose: () => void;
onSelectPost?: (post: PostSummary) => void;
};

export function InternalLinkPicker({
Expand All @@ -29,6 +30,7 @@ export function InternalLinkPicker({
textareaRef,
onBodyChange,
onClose,
onSelectPost,
}: InternalLinkPickerProps) {
const [query, setQuery] = useState("");

Expand All @@ -38,6 +40,11 @@ export function InternalLinkPicker({
);

function insertTarget(post: PostSummary) {
if (onSelectPost) {
onSelectPost(post);
return;
}

const textarea = textareaRef.current;
if (!textarea) {
return;
Expand Down
86 changes: 51 additions & 35 deletions apps/studio/src/components/WritingCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,8 +31,48 @@ export function WritingCanvas({
onDescriptionChange,
onBodyChange,
}: WritingCanvasProps) {
const bodyFieldId = useId();
const bodyRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<Editor | null>(null);
const sourceTextareaRef = useRef<HTMLTextAreaElement | null>(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 (
<div className="writing-canvas">
Expand Down Expand Up @@ -90,45 +130,21 @@ export function WritingCanvas({
</label>

<div className="writing-canvas__page">
<MarkdownToolbar
<SourceDraftEditor
body={body}
bodyFieldId={bodyFieldId}
latestImagePath={latestImagePath}
imageAlt={title.trim() || "Image"}
posts={posts}
editingPath={editingPath}
textareaRef={bodyRef}
fieldError={fieldErrors.body}
onBodyChange={onBodyChange}
onEditorReady={handleEditorReady}
onEditorModeChange={setEditorMode}
sourceTextareaRef={sourceTextareaRef}
/>

<label className="writing-canvas__body-field">
<span className="visually-hidden">Article body</span>
<textarea
id={bodyFieldId}
ref={bodyRef}
className={
fieldErrors.body
? "writing-canvas__body writing-canvas__body--error"
: "writing-canvas__body"
}
value={body}
onChange={(event) => onBodyChange(event.target.value)}
onKeyDown={(event) => {
handleMarkdownShortcut(event, body, onBodyChange);
}}
spellCheck={true}
placeholder="Start writing your article…"
aria-invalid={fieldErrors.body ? true : undefined}
/>
</label>
{fieldErrors.body && (
<p className="writing-canvas__body-error field__error" role="alert">
{fieldErrors.body}
</p>
)}
</div>

<DocumentOutline body={body} textareaRef={bodyRef} />
<DocumentOutline body={body} onScrollToOffset={scrollToOffset} />
</div>
);
}
Loading
Loading