Editing notes
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”เรามี storage layer แบบ typed แล้ว ตอนนี้เราวาง editor จริงไว้ด้านหน้า บทนี้ทำให้ <note-editor> — custom element ที่วาง skeleton ไว้ตั้งแต่ App Shell — ทำงานได้จริง: สร้าง note, แก้ title กับ body, ลบทิ้ง โดยทุกการเปลี่ยนแปลง persist ลง IndexedDB ผ่าน accessor จาก Notes object stores
บทนี้เป็น pre-CRDT โดยตั้งใจ ตอนนี้ title กับ body เป็น field ธรรมดา ไม่มี merge ไม่มี op log ไม่มี sync กลไกพวกนั้นจะมาใน Wiring the WASM Core (Module 7) การสร้าง UI บน persistence ธรรมดาก่อน แปลว่า storage round-trip ถูกพิสูจน์แล้วและน่าเบื่อ ก่อนที่เราจะแนะนำส่วนที่ยาก
มีการตัดสินใจเรื่อง shape หนึ่งที่ต้องพูดให้ชัดก่อน: store notes เก็บแค่ { id, title, updatedAt } — เป็น projection สำหรับ list ไม่มี body ดังนั้นแม้ตอนนี้ body ก็อยู่ใน snapshot ของ docs ({ id, actorId, snapshot }) เป็น object { body } ธรรมดา นี่ไม่ใช่งานเปล่าประโยชน์: Module 7 จะสลับ snapshot ธรรมดานี้เป็น CRDT snapshot จริง ใน store เดียวกัน ดังนั้นตำแหน่งที่ body อยู่ไม่ต้องเปลี่ยนเลย — เปลี่ยนแค่ว่า snapshot เก็บอะไรข้างใน
- custom element ไม่ใช่ framework component OfflineNotes เป็นแอป Web Components
<note-editor>เป็น DOM element จริงที่มี lifecycle ของตัวเอง อ่านและเขียน storage ตรง ๆ (pre-CRDT) และจะอ่าน/เขียน note store (post-CRDT) โดยไม่เปลี่ยน public surface ของตัวเอง - persist ตอน edit ไม่ใช่ตอนกดปุ่ม Save local-first แปลว่าการเขียนถูกและอยู่ในเครื่อง — ไม่มี network ให้รอ — โมเดลที่เป็นธรรมชาติคือ “note ของคุณถูกเซฟเสมอ” แบบเดียวกับแอป note บนเครื่อง เรา debounce เบา ๆ เพื่อเลี่ยงการเขียนทุกครั้งที่กดคีย์
- แยก projection ออกจาก body ตั้งแต่วันแรก การเขียน
title/updatedAtลงnotesและ body ลง snapshot ของdocsตอนนี้ แปลว่า list ยังถูกอยู่ และการสลับ CRDT ใน Module 7 เป็นแค่การเปลี่ยน snapshot ไม่ใช่ data migration
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Persist-on-input (debounced) vs. an explicit Save button
- Pros: ตรงกับความรู้สึกของแอป local-first — ไม่มีอะไรให้ลืม ไม่มีอะไรหายตอน reload; ไม่ต้อง track dirty-state; crash เสียแค่ช่วง debounce ล่าสุด
- Cons: เขียนมากขึ้น; คุณต้องมี debounce เพื่อไม่ให้การพิมพ์ถล่ม IndexedDB; “undo ทั้ง edit” ไม่ฟรีแบบการทิ้ง buffer ที่ยังไม่เซฟ
Body in the docs snapshot vs. adding a body to the notes store
- Pros: ทำให้ projection
notesเล็กมาก list จึงไม่ต้องโหลด body ของ note; ตรงกับการแยก authoritative/derived; CRDT (Module 7) หย่อนลงdocsได้ตรง ๆ โดยไม่ต้องเปลี่ยน schema - Cons: เขียนสอง store ต่อการเซฟหนึ่งครั้ง (projection + doc) และ title กับ body ของ note อยู่คนละ store — ความไม่สอดคล้องที่เราคุมด้วยการเขียนทั้งคู่พร้อมกันเสมอ
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. apps/web/src/notes.ts — a small persistence helper
หัวข้อที่มีชื่อว่า “1. apps/web/src/notes.ts — a small persistence helper”ห่อการเขียนสอง store ไว้ เพื่อ <note-editor> ไม่ต้องเขียนซ้ำ Pre-CRDT snapshot เป็น { body } ธรรมดา; post-CRDT helper นี้คือที่ที่ note store เข้ามารับช่วง
import { getActorId } from './actor';import { getNote, putNote, getDoc, putDoc, deleteNote, type NoteMeta } from './db';
export interface NoteView { id: string; title: string; body: string; updatedAt: number;}
// Load a note for editing: title from the projection, body from the doc snapshot.export async function loadNote(id: string): Promise<NoteView | undefined> { const meta = await getNote(id); if (!meta) return undefined; const doc = await getDoc(id); const body = (doc?.snapshot as { body?: string } | undefined)?.body ?? ''; return { id, title: meta.title, body, updatedAt: meta.updatedAt };}
// Create an empty note and persist it. Returns the new id.export async function createNote(): Promise<string> { const id = crypto.randomUUID(); await saveNote({ id, title: '', body: '', updatedAt: Date.now() }); return id;}
// Persist title + body: projection to `notes`, body to the `docs` snapshot.export async function saveNote(view: NoteView): Promise<void> { const actorId = await getActorId(); const meta: NoteMeta = { id: view.id, title: view.title, updatedAt: view.updatedAt, }; await putNote(meta); // Pre-CRDT the snapshot is just { body }. Module 7 replaces it with a // real CRDT snapshot in this same store. await putDoc({ id: view.id, actorId, snapshot: { body: view.body } });}
export async function removeNote(id: string): Promise<void> { await deleteNote(id);}2. apps/web/src/components/note-editor.ts — the element
หัวข้อที่มีชื่อว่า “2. apps/web/src/components/note-editor.ts — the element”เติม skeleton ให้เต็ม: title input, body textarea, ปุ่ม delete persist ตอน input หลัง debounce สั้น ๆ แล้วประกาศการเปลี่ยนแปลงเพื่อให้ <note-list> (บทถัดไป) refresh ได้
import { createNote, loadNote, removeNote, saveNote, type NoteView } from '../notes';
class NoteEditor extends HTMLElement { #current: NoteView | null = null; #timer: number | undefined;
connectedCallback() { this.innerHTML = ` <input class="title" type="text" placeholder="Title" /> <textarea class="body" placeholder="Write in markdown…"></textarea> <button class="delete" type="button">Delete</button> `; this.#input('.title').addEventListener('input', () => this.#onEdit()); this.#input('.body').addEventListener('input', () => this.#onEdit()); this.querySelector('.delete')!.addEventListener('click', () => this.#delete()); }
disconnectedCallback() { clearTimeout(this.#timer); }
// Open an existing note for editing. async open(id: string) { const view = await loadNote(id); if (!view) return; this.#current = view; this.#input('.title').value = view.title; this.#input('.body').value = view.body; }
// Start a fresh, empty note. async new() { const id = await createNote(); await this.open(id); this.#announce(); this.#input('.title').focus(); }
#onEdit() { if (!this.#current) return; this.#current = { ...this.#current, title: this.#input('.title').value, body: this.#input('.body').value, updatedAt: Date.now(), }; // Debounce: coalesce a burst of keystrokes into one write. clearTimeout(this.#timer); this.#timer = window.setTimeout(() => this.#persist(), 300); }
async #persist() { if (!this.#current) return; await saveNote(this.#current); this.#announce(); }
async #delete() { if (!this.#current) return; await removeNote(this.#current.id); this.#current = null; this.#input('.title').value = ''; this.#input('.body').value = ''; this.#announce(); }
// Let the rest of the app know the note set changed. #announce() { document.dispatchEvent(new CustomEvent('notes-changed')); }
#input(sel: string) { return this.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement; }}
customElements.define('note-editor', NoteEditor);window.setTimeout คืนค่าเป็น number ใน browser (ไม่ใช่ Timeout ของ Node) ดังนั้น #timer จึงมี type สะอาด event notes-changed เป็น DOM CustomEvent ธรรมดาบน document — list จะฟังในบทถัดไป และตอนนี้ยังไม่มีต้นทุนอะไร
3. apps/web/src/components/offline-notes-app.ts — wire “New”
หัวข้อที่มีชื่อว่า “3. apps/web/src/components/offline-notes-app.ts — wire “New””ให้ root มีวิธีเริ่ม note ด้วยการ delegate ไปที่ editor
class OfflineNotesApp extends HTMLElement { connectedCallback() { this.innerHTML = ` <header><button class="new" type="button">New note</button></header> <note-list></note-list> <note-editor></note-editor> `; const editor = this.querySelector('note-editor') as any; this.querySelector('.new')!.addEventListener('click', () => editor.new()); }}
customElements.define('offline-notes-app', OfflineNotesApp);ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”Type-check และ build:
pnpm --filter web exec tsc --noEmitpnpm --filter web buildจากนั้นรันแอปแล้วลอง loop ด้วยมือ:
pnpm --filter web devที่ http://localhost:4321/offlinenotes/: คลิก New note, พิมพ์ title กับ body ที่เป็น markdown แล้วรอสักครู่ ใน DevTools → Application → IndexedDB → offlinenotes store notes เก็บ { id, title, updatedAt } และ docs เก็บ { id, actorId, snapshot: { body } } สำหรับ id เดียวกัน Reload หน้า — record ยังอยู่ (นั่นคือหัวใจของ local-first) คลิก Delete — id หายไปจากทั้ง notes และ docs
ยืนยันการเขียนสอง store จาก console:
const db = await import('/src/db.ts');const [n] = await db.listNotes();console.log(n); // { id, title, updatedAt }console.log(await db.getDoc(n.id)); // { id, actorId, snapshot: { body: '…' } }Title อยู่ใน notes, body อยู่ใน snapshot ของ docs, id เดียวกัน — การแยกเป็นเรื่องจริงและรอด reload
Check your understanding:
- body ถูกเก็บใน snapshot ของ
docsไม่ใช่ใน storenotesเพราะอะไร และการตัดสินใจนั้นช่วยประหยัดงาน CRDT ของ Module 7 อย่างไร? #onEditdebounce ก่อน persist อะไรจะพังถ้าคุณตัด debounce ออกแล้วเขียนทุกครั้งที่กดคีย์ และ worst-case data loss ที่ window 300 ms คือเท่าไหร่?saveNoteเขียนสอง store ทุกครั้งที่เซฟ invariant ตัวไหนที่เรารับผิดชอบ ซึ่งการเขียน store เดียวจะให้เรามาฟรี ๆ?<note-editor>dispatchnotes-changedแต่ยังไม่มีใครฟัง ทำไมถึง emit ตั้งแต่ตอนนี้แทนที่จะเรียก list ตรง ๆ?
ตอนนี้ <note-editor> สร้าง แก้ไข และลบ note ที่ persist ลง IndexedDB ข้าม reload ได้ — title กับ body ธรรมดา ยังไม่มี CRDT title กับ updatedAt ไปที่ projection notes; body ไปกับ snapshot ของ docs ตรงตำแหน่งที่ CRDT snapshot จริงจะอยู่ใน Module 7 พอดี การอัปเกรดนั้นจึงไม่ต้องย้ายข้อมูลเลย edit จะ persist บน debounce เบา ๆ และ editor ประกาศการเปลี่ยนแปลงด้วย event notes-changed
ยังไม่มีอะไรแสดง collection และ body ที่เป็น markdown ก็ยังเป็น text ดิบ ต่อไป List and markdown → จะสร้าง <note-list> จาก IndexedDB และ render body เป็น markdown ใน editor