feat(notes): rich-text editor with tiptap (task 85)
Replace plain textarea with shared tiptap editor and sanitized html rendering. Adds interactive checklists on read surfaces and mobile overflow fixes.
This commit is contained in:
+27
-8
@@ -7,12 +7,7 @@ test("notes happy path", async ({ page }) => {
|
||||
const suffix = Date.now().toString();
|
||||
const title = `E2E Note ${suffix}`;
|
||||
const editedTitle = `E2E Updated ${suffix}`;
|
||||
const body = `# Heading ${suffix}
|
||||
|
||||
- Milk
|
||||
- Bread
|
||||
|
||||
<script>window.__famappInjected = true</script>`;
|
||||
const heading = `Heading ${suffix}`;
|
||||
const reminder = "2026-05-20T09:30";
|
||||
|
||||
await page.goto("/notes");
|
||||
@@ -20,13 +15,24 @@ test("notes happy path", async ({ page }) => {
|
||||
|
||||
await page.getByRole("link", { name: "New note" }).click();
|
||||
await page.getByLabel("Title").fill(title);
|
||||
await page.getByLabel("Body").fill(body);
|
||||
await page.getByLabel("Body").click();
|
||||
await page.getByRole("button", { name: "Heading" }).click();
|
||||
await page.getByLabel("Body").type(heading);
|
||||
await page.getByLabel("Body").press("Enter");
|
||||
await page.getByRole("button", { name: "Bullet list" }).click();
|
||||
await page.getByLabel("Body").type("Milk");
|
||||
await page.getByLabel("Body").press("Enter");
|
||||
await page.getByLabel("Body").type("Bread");
|
||||
await page.getByLabel("Body").press("Enter");
|
||||
await page.getByLabel("Body").type("<script>window.__famappInjected = true</script>");
|
||||
await page.getByLabel("Reminder").fill(reminder);
|
||||
await page.getByRole("button", { name: "Save note" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/notes\/[0-9a-f-]+$/);
|
||||
await expect(page.getByRole("heading", { name: new RegExp(title) })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: `Heading ${suffix}` })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("complementary").getByRole("heading", { name: heading }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("complementary").getByText("Milk")).toBeVisible();
|
||||
await expect(page.locator("script", { hasText: "window.__famappInjected" })).toHaveCount(0);
|
||||
await expect
|
||||
@@ -63,3 +69,16 @@ test("notes happy path", async ({ page }) => {
|
||||
await expect(page).toHaveURL(/\/notes$/);
|
||||
await expect(page.getByRole("link", { name: new RegExp(editedTitle) })).toBeHidden();
|
||||
});
|
||||
|
||||
test("notes mobile layout has no horizontal overflow", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/notes/new");
|
||||
await expect(page.getByLabel("Title")).toBeVisible();
|
||||
|
||||
const overflow = await page.evaluate(() => {
|
||||
const doc = document.documentElement;
|
||||
return doc.scrollWidth > doc.clientWidth;
|
||||
});
|
||||
|
||||
expect(overflow).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { normalizeNoteBody } from "../../src/components/rich-text/normalize-body";
|
||||
import { sanitizeRichTextHtml } from "../../src/components/rich-text/sanitize";
|
||||
import { toggleTaskItemInHtml } from "../../src/components/rich-text/toggle-checklist";
|
||||
|
||||
describe("normalizeNoteBody", () => {
|
||||
it("wraps plain text paragraphs in HTML", () => {
|
||||
const html = normalizeNoteBody("Hello\n\nWorld");
|
||||
assert.match(html, /<p>Hello<\/p>/);
|
||||
assert.match(html, /<p>World<\/p>/);
|
||||
});
|
||||
|
||||
it("preserves existing HTML", () => {
|
||||
const input = "<p><strong>Bold</strong></p>";
|
||||
assert.equal(normalizeNoteBody(input), input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRichTextHtml", () => {
|
||||
it("strips script tags", () => {
|
||||
const safe = sanitizeRichTextHtml('<p>Hi</p><script>alert("x")</script>');
|
||||
assert.doesNotMatch(safe, /<script/i);
|
||||
assert.match(safe, /Hi/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleTaskItemInHtml", () => {
|
||||
it("flips the requested checklist item", () => {
|
||||
const html =
|
||||
'<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>One</p></li><li data-type="taskItem" data-checked="false"><p>Two</p></li></ul>';
|
||||
const next = toggleTaskItemInHtml(html, 1, true);
|
||||
assert.match(next, /data-checked="false"/);
|
||||
assert.match(next, /data-checked="true"/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user