diff --git a/lib/editor/draft.test.ts b/lib/editor/draft.test.ts new file mode 100644 index 0000000..98a76fb --- /dev/null +++ b/lib/editor/draft.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import type { SerializedBlock } from "@/lib/blocks"; + +import { + createEditorBlock, + duplicateBlock, + moveBlock, + moveBlockTo, +} from "./draft"; + +const blocks: SerializedBlock[] = ["a", "b", "c"].map((id, sortOrder) => ({ + id, + type: "separator", + schemaVersion: 1, + config: {}, + sortOrder, +})); + +describe("editor block operations", () => { + it("moves blocks with keyboard-friendly relative operations", () => { + expect(moveBlock(blocks, "b", -1).map((block) => block.id)).toEqual(["b", "a", "c"]); + expect(moveBlock(blocks, "a", -1)).toEqual(blocks); + expect(moveBlock(blocks, "b", 1).map((block) => block.sortOrder)).toEqual([0, 1, 2]); + }); + + it("supports pointer drop targets and duplication with stable order", () => { + expect(moveBlockTo(blocks, "a", "c").map((block) => block.id)).toEqual(["b", "c", "a"]); + expect(duplicateBlock(blocks, "b", "copy").map((block) => block.id)).toEqual(["a", "b", "copy", "c"]); + }); + + it("requires a configured source for data-backed blocks", () => { + expect(createEditorBlock("comparison-chart")).toBeNull(); + expect(createEditorBlock("heading", undefined, "heading-id")).toMatchObject({ + id: "heading-id", + type: "heading", + config: { text: "หัวข้อใหม่" }, + }); + }); +}); diff --git a/lib/editor/draft.ts b/lib/editor/draft.ts new file mode 100644 index 0000000..7265788 --- /dev/null +++ b/lib/editor/draft.ts @@ -0,0 +1,179 @@ +import type { SerializedBlock } from "@/lib/blocks"; +import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types"; + +export interface EditorDraft { + pageId: string; + title: string; + slug: string; + visible: boolean; + publicNote: string | null; + blocks: SerializedBlock[]; +} + +export function draftFromSnapshot(snapshot: PageSnapshot): EditorDraft { + return { + pageId: snapshot.page.id, + title: snapshot.page.title, + slug: snapshot.page.slug, + visible: snapshot.page.visible, + publicNote: snapshot.page.publicNote, + blocks: structuredClone(snapshot.blocks), + }; +} + +export function normalizeBlockOrder( + blocks: readonly SerializedBlock[], +): SerializedBlock[] { + return blocks.map((block, sortOrder) => ({ ...block, sortOrder })); +} + +export function moveBlock( + blocks: readonly SerializedBlock[], + blockId: string, + direction: -1 | 1, +): SerializedBlock[] { + const current = blocks.findIndex((block) => block.id === blockId); + const target = current + direction; + if (current < 0 || target < 0 || target >= blocks.length) return [...blocks]; + const next = [...blocks]; + [next[current], next[target]] = [next[target], next[current]]; + return normalizeBlockOrder(next); +} + +export function moveBlockTo( + blocks: readonly SerializedBlock[], + blockId: string, + targetId: string, +): SerializedBlock[] { + const from = blocks.findIndex((block) => block.id === blockId); + const target = blocks.findIndex((block) => block.id === targetId); + if (from < 0 || target < 0 || from === target) return [...blocks]; + const next = [...blocks]; + const [moved] = next.splice(from, 1); + next.splice(target, 0, moved); + return normalizeBlockOrder(next); +} + +export function duplicateBlock( + blocks: readonly SerializedBlock[], + blockId: string, + id = crypto.randomUUID(), +): SerializedBlock[] { + const index = blocks.findIndex((block) => block.id === blockId); + if (index < 0) return [...blocks]; + const clone = structuredClone(blocks[index]); + clone.id = id; + const next = [...blocks]; + next.splice(index + 1, 0, clone); + return normalizeBlockOrder(next); +} + +function sourceFields(source: DataSourceSnapshot | undefined) { + const text = source?.fields.find((field) => field.type !== "number")?.key ?? "name"; + const numbers = source?.fields.filter((field) => field.type === "number" || field.formula) ?? []; + return { text, numbers }; +} + +export function createEditorBlock( + type: string, + source?: DataSourceSnapshot, + id = crypto.randomUUID(), +): SerializedBlock | null { + const nestedId = () => crypto.randomUUID(); + const { text, numbers } = sourceFields(source); + const numeric = numbers[0]?.key ?? "value"; + const configs: Record | null> = { + profile: { title: "หัวข้อโปรไฟล์", summary: "เพิ่มคำแนะนำสั้น ๆ", badges: [] }, + "rich-text": { content: [{ type: "paragraph", text: "เริ่มเขียนเนื้อหาที่นี่" }] }, + heading: { text: "หัวข้อใหม่", level: 2 }, + callout: { title: "ข้อควรรู้", body: "เพิ่มรายละเอียด", tone: "info" }, + "stat-cards": { + title: "ค่าสำคัญ", + items: [{ id: nestedId(), label: "ค่า", value: "—" }], + }, + "key-value": { + items: [{ id: nestedId(), label: "หัวข้อ", value: "รายละเอียด" }], + columns: 2, + }, + ranking: source + ? { + title: "อันดับ", + dataSourceId: source.id, + labelField: text, + valueField: numeric, + higherIsBetter: true, + } + : null, + "team-composition": source + ? { + title: "ทีมแนะนำ", + dataSourceId: source.id, + teamField: text, + memberFields: [text], + } + : null, + rotation: { + title: "ลำดับการเล่น", + steps: [{ id: nestedId(), action: "เพิ่มขั้นตอน" }], + }, + table: source + ? { + dataSourceId: source.id, + columns: source.fields.slice(0, 4).map((field) => ({ + field: field.key, + label: field.label, + align: field.type === "number" ? "end" : "start", + })), + } + : null, + "comparison-chart": source && numbers.length > 0 + ? { + title: "กราฟเปรียบเทียบ", + dataSourceId: source.id, + categoryField: text, + series: [{ field: numeric, label: numbers[0].label, color: "chart-1" }], + presentation: "horizontal-bar", + normalization: "raw", + sort: "authored", + showValues: true, + showLegend: false, + tableFallback: true, + } + : null, + image: null, + gallery: null, + tabs: { + tabs: [ + { id: nestedId(), label: "ตัวเลือก 1", content: "เพิ่มรายละเอียด" }, + { id: nestedId(), label: "ตัวเลือก 2", content: "เพิ่มรายละเอียด" }, + ], + }, + accordion: { + items: [{ id: nestedId(), title: "หัวข้อ", content: "เพิ่มรายละเอียด" }], + }, + section: { title: "ส่วนใหม่" }, + columns: { columns: 2, gap: "normal" }, + links: { + links: [{ id: nestedId(), label: "ลิงก์", href: "/", description: "เพิ่มคำอธิบาย" }], + }, + separator: {}, + }; + const config = configs[type]; + if (!config) return null; + return { + id, + type, + schemaVersion: 1, + config, + responsiveLayout: { + width: ["ranking", "table", "comparison-chart", "team-composition", "gallery"].includes(type) + ? "wide" + : "content", + mobileSpan: 4, + tabletSpan: 8, + desktopSpan: 12, + hideOnMobile: false, + }, + sortOrder: 0, + }; +}