Compare commits
2 commits
4934b9d0b3
...
b3b884c736
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b884c736 | ||
|
|
6ddc35d6d5 |
7 changed files with 225 additions and 28 deletions
|
|
@ -23,8 +23,9 @@ paths:
|
|||
- DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand
|
||||
- Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `<main>` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น)
|
||||
- HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight
|
||||
- **จอสัมผัส (iOS/Android) ไม่มี `mouseup` หลังคลุมข้อความด้วยนิ้ว** (synthetic mouse events เกิดเฉพาะ tap — การ commit ตอนปล่อยเมาส์ใช้ไม่ได้): อุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง + tool highlight → effect `selectionchange` debounce ~400ms หลัง selection หยุดนิ่ง แล้ว snapshot (rects + ข้อความผ่าน `readTextSelection(pi, { withText: true })`) เก็บใน `touchSelRef` + แสดงปุ่ม "ไฮไลต์" ลอย**ใต้**บรรทัดสุดท้ายของ selection (ด้านบนถูกเมนู/handles ของ OS ยึด — ใกล้ขอบล่างหน้าเกินพลิกขึ้นเหนือ rect แรก) — commit ต้องใช้ **snapshot ใน ref เท่านั้น ไม่ใช่ selection สด** เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง; ทั้ง desktop (mouseup) และ touch (ปุ่มยืนยัน) ผ่าน `commitHighlight` ชุดเดียวกัน; เคลียร์ปุ่ม+timer ทุกครั้งที่ selection หาย/ออกนอกหน้า/เปลี่ยน tool
|
||||
- **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject `<style>` ซ่อน native selection + ฟัง `selectionchange` วาดของเราเอง (highlight = preview มาร์ก, ที่เหลือ = selection สีฟ้า) + tracking เส้นทางลาก (`selDragStart/EndRef` ที่ mousedown/move) + Ctrl+C กรองข้อความตามเส้นทาง — comment/textbox/stamp ปล่อยเมาส์จากการลากคลุม**ไม่วางรายการ** (guard `!selection.isCollapsed` ต้น `handlePageClick`: click ที่ยิงต่อจาก drag คลุมต้องเฉยไป เหมือน tool เลือกข้อความ คลิกล้วน ๆ ต่างหากที่เปิด composer/วางตรายาง)
|
||||
- กรอบ selection (F1) ของทุกชนิดที่เป็นรายการของตัวเองมี**แผงเครื่องมือ** (`w-60` เหมือน dropdown ของ tool) ที่ใช้**ตัวควบคุมชุดเดียวกับ dropdown ของ tool ผู้สร้าง** (จาก `components/pdf/ToolControls.tsx` — ColorSwatches/NumberCombo/PanelRow แชร์กับ PDFViewer) ตามชนิด: HIGHLIGHT = สี+รูปแบบมาร์ก, DRAW/SHAPE = สี+ความหนา (×10), STAMP = สี+ขนาด, TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ปิดท้ายด้วย **ปุ่มลบ** (`onDelete`) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — **HIGHLIGHT ไม่มี handle ย้าย/ขยาย** (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว)
|
||||
- กรอบ selection (F1) ของทุกชนิดที่เป็นรายการของตัวเองมี**แผงเครื่องมือ** (`w-60` เหมือน dropdown ของ tool) ที่ใช้**ตัวควบคุมชุดเดียวกับ dropdown ของ tool ผู้สร้าง** (จาก `components/pdf/ToolControls.tsx` — ColorSwatches/NumberCombo/PanelRow แชร์กับ PDFViewer) ตามชนิด: HIGHLIGHT = สี+รูปแบบมาร์ก, DRAW/SHAPE = สี+ความหนา (×10), STAMP = สี+ขนาด, TEXTBOX = สี+ขนาด+ความทึบ+ตัวหนา+จัดแนว — ปิดท้ายด้วย **ปุ่มลบ** (`onDelete`) เสมอ; ชนิดที่ไม่มีสไตล์ให้แก้ (REDACTION/COMMENT) ได้อย่างน้อยปุ่มลบ — **HIGHLIGHT ไม่มี handle ย้าย/ขยาย** (ผูกกับตำแหน่งข้อความจริง เลือกเพื่อเปลี่ยนสี/รูปแบบ/ลบได้อย่างเดียว) — handle ทุกตัว (ขอบ 4 + มุม 4) ต้องมี `touch-none`: จอสัมผัสไม่มี touch-action:none แล้ว browser ยึด gesture เป็น scroll ส่ง `pointercancel` กลายเป็นเลื่อนหน้าแทนการลาก (InkLayer มี `touch-none` อยู่แล้ว)
|
||||
- TEXTBOX (F17) `position` เป็น**มุมซ้ายบนของกล่องเสมอ** — `align` จัดข้อความ*ใน*กล่องอย่างเดียว (`textAlign`, แบบ Smallpdf: กดจัดแนวแล้วกล่องไม่ขยับ ขยับแค่ข้อความข้างใน — เคยทดลองให้ align เป็น "จุดยึด" ที่ขยับทั้งกล่องแล้วถอดออกเพราะผู้ใช้รู้สึกว่ากล่องเด้ง); ความกว้างสูงสุดจาก `textboxMaxWidthPct(x)` (`utils/geometry.ts`) บีบตามพื้นที่จริงถึงขอบขวา (cap 50% ของหน้า, ขั้นต่ำ 1%) **กันข้อความล้นขอบกระดาษ** — ใช้สูตรเดียวกันทั้ง render (`TextboxStampLayer`), bbox กรอบ selection/hit-test (`annotationBBox`) และ export PDF
|
||||
- TEXTBOX กล่องกว้างคงที่ (`width?` สไตล์ Smallpdf/Acrobat): กล่องที่สร้างใหม่คลิกเดียวได้ `width = min(TEXTBOX_DEFAULT_WIDTH_PCT=30, textboxMaxWidthPct)` — **ลากขอบซ้าย/ขวาของกรอบ selection = ปรับความกว้าง** (mode `wleft`/`wright` ใน AnnotationSelectionLayer: ขอบซ้ายขยับ = เลื่อนกล่อง+เปลี่ยนกว้าง (`position.x` = ขอบซ้ายตรง ๆ), ขอบขวาขยับ = เปลี่ยนกว้างอย่างเดียว, clamp กว้าง 2–50% และอยู่ในหน้า) ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ — กล่องแบบนี้**ซ่อนมุมกรอบ** (scale ไม่มีความหมาย) ย้ายกล่องใช้ขอบบน/ล่าง; กล่องเก่าที่ไม่มี `width` = shrink-to-fit หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด
|
||||
- คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = `setSelectedId` ผ่าน `hitTestAnnotation` (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ `visibleAnnotations` (เคารพ filter รายคน F4)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@ paths:
|
|||
- Event ของ `replies` (F9): create → append + dedupe ด้วย id, delete → filter ตาม id — และเมื่อ annotation แม่ถูกลบ **ต้องเคลียร์ replies ของแม่ใน state ฝั่ง client ด้วย** (CASCADE ใน DB ไม่ส่ง event ราย reply)
|
||||
- `restoreVersion` (F19) ก็ publish **create** event ต่อรายการที่ insert สำเร็จเหมือน action เขียนอื่น ๆ — แท็บของผู้กดเองก็ได้ event ผ่าน SSE ของตัวเอง จึงไม่ต้อง sync state ซ้ำใน client
|
||||
- SSE route: ต้อง cleanup (unsubscribe + close) เมื่อ client ตัดการเชื่อมต่อ (`req.signal` abort / `cancel`)
|
||||
- Dedupe ด้วย id: event ที่ id มีอยู่ใน state แล้ว ไม่ต้อง append ซ้ำ — ทำงานคู่กับ `useOptimistic` ใน PDFViewer: SSE event อาจมาถึงก่อน action เสร็จ → duplicate ชั่วขณะที่หายเองเมื่อ optimistic row ถูกถอด — **อย่าเพิ่ม logic กัน duplicate เกินกว่า dedupe ด้วย id**
|
||||
- Dedupe ด้วย id: event ที่ id มีอยู่ใน state แล้ว ไม่ต้อง append ซ้ำ — ทำงานคู่กับ `useOptimistic` ใน PDFViewer: SSE event อาจมาถึงก่อน action เสร็จ — `createAnnotation` รับ `id` ที่ client สร้างของ optimistic row มาใช้ซ้ำ (validate เป็น UUID) เพื่อให้ dedupe ด้วย id เดียวกันตั้งแต่แรก **ไม่เกิด duplicate ชั่วขณะ** (row โผล่แล้วหาย) — **อย่าเพิ่ม logic กัน duplicate เกินกว่า dedupe ด้วย id**
|
||||
- pub/sub เป็น in-memory ของ process เดียว — ถ้ามีวันที่ต้องรันหลาย instance ให้เพิ่มตัวกลาง (Redis/LISTEN-NOTIFY) ก่อน อย่าแอบ sync ผ่าน DB polling
|
||||
- **Map ของ listener ใน `lib/annotate-events.ts` ต้องเก็บบน `globalThis`** — module-level state จะถูก duplicate เป็น 2 อินสแตนซ์ใน Next dev (bundle ของ "use server" กับ route handler แยกกัน) แล้ว event ไม่ไหลถึงกัน — **ห้ามเปลี่ยนกลับเป็น module-level Map**
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export type CreateAnnotationInput = {
|
|||
userName: string;
|
||||
type: AnnotationType;
|
||||
data: AnnotationData;
|
||||
/** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน
|
||||
* จะได้ไม่เห็นรายการ duplicate ชั่วขณะ (row ชั่วคราว + row จริงคนละ id) — ไม่ส่ง = สร้างใหม่ */
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type CreateBookmarkInput = {
|
||||
|
|
@ -407,11 +410,19 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise<An
|
|||
}
|
||||
const type = raw.type as AnnotationType;
|
||||
const data = validateAnnotationData(type, raw?.data);
|
||||
// id จาก client (ถ้ามา) = id ของ optimistic row — SSE event/dedupe ทุกฝั่งเทียบ id เดียวกัน
|
||||
const id = typeof raw?.id === "string" ? vUuid(raw.id, "id") : undefined;
|
||||
|
||||
const [row] = await db
|
||||
.insert(annotations)
|
||||
.values({ fileId, pageIndex, userId, userName, type, data })
|
||||
.returning();
|
||||
const values: typeof annotations.$inferInsert = {
|
||||
fileId,
|
||||
pageIndex,
|
||||
userId,
|
||||
userName,
|
||||
type,
|
||||
data,
|
||||
};
|
||||
if (id) values.id = id;
|
||||
const [row] = await db.insert(annotations).values(values).returning();
|
||||
const dto = toAnnotationDTO(row);
|
||||
publishAnnotateEvent(fileId, { table: "annotations", action: "create", row: dto });
|
||||
revalidateFilePages(fileId);
|
||||
|
|
|
|||
|
|
@ -398,9 +398,10 @@ export default function AnnotationSelectionLayer({
|
|||
สี/รูปแบบ/ลบได้อย่างเดียว) จึงไม่มี handle ขอบกรอบและมุม */}
|
||||
{editable && !disabled && !isHighlightMark && (
|
||||
<>
|
||||
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม */}
|
||||
{/* ขอบกรอบ 4 ด้าน — ลาก = translate ทั้งกลุ่ม (touch-none: จอสัมผัสลาก
|
||||
ได้จริง ไม่ถูก browser ยึดเป็น scroll แล้วส่ง pointercancel) */}
|
||||
<div
|
||||
className="absolute cursor-move"
|
||||
className="absolute cursor-move touch-none"
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y - STRIP_PX / 2,
|
||||
|
|
@ -414,7 +415,7 @@ export default function AnnotationSelectionLayer({
|
|||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className="absolute cursor-move"
|
||||
className="absolute cursor-move touch-none"
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y + boxPx.height - STRIP_PX / 2,
|
||||
|
|
@ -428,7 +429,7 @@ export default function AnnotationSelectionLayer({
|
|||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
style={{
|
||||
left: boxPx.x - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
|
|
@ -444,7 +445,7 @@ export default function AnnotationSelectionLayer({
|
|||
onPointerCancel={cancelDrag}
|
||||
/>
|
||||
<div
|
||||
className={`absolute ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
className={`absolute touch-none ${isFixedTextbox ? "cursor-ew-resize" : "cursor-move"}`}
|
||||
style={{
|
||||
left: boxPx.x + boxPx.width - STRIP_PX / 2,
|
||||
top: boxPx.y,
|
||||
|
|
@ -465,7 +466,7 @@ export default function AnnotationSelectionLayer({
|
|||
{!isFixedTextbox && corners.map((c) => (
|
||||
<div
|
||||
key={c.key}
|
||||
className="absolute rounded-sm border border-primary bg-primary/50"
|
||||
className="absolute touch-none rounded-sm border border-primary bg-primary/50"
|
||||
style={{
|
||||
left: c.x - HANDLE_PX / 2,
|
||||
top: c.y - HANDLE_PX / 2,
|
||||
|
|
|
|||
|
|
@ -366,6 +366,17 @@ const TOOLS: { key: Tool; label: string; Icon: LucideIcon }[] = [
|
|||
{ key: "stamp", label: "ตรายาง (stamp)", Icon: Stamp },
|
||||
];
|
||||
|
||||
/** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์)
|
||||
* ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */
|
||||
const AUTO_SELECT_TYPES = new Set<AnnotationType>([
|
||||
"DRAW",
|
||||
"SHAPE",
|
||||
"TEXTBOX",
|
||||
"STAMP",
|
||||
"HIGHLIGHT",
|
||||
"REDACTION",
|
||||
]);
|
||||
|
||||
/** cursor รูปยางลบของ tool erase — SVG inline สไตล์ lucide (path เดียวกับ icon บน
|
||||
* แถบ tool) hotspot ที่ปลายยางมุมล่างซ้าย = จุดที่ใช้ลบจริง */
|
||||
const ERASER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
|
||||
|
|
@ -1550,6 +1561,7 @@ export default function PDFViewer({
|
|||
applyAnnotation({ kind: "add", row: temp });
|
||||
try {
|
||||
const saved = await createAnnotation({
|
||||
id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย)
|
||||
fileId: file.id,
|
||||
pageIndex,
|
||||
userId: user.userId,
|
||||
|
|
@ -1566,6 +1578,12 @@ export default function PDFViewer({
|
|||
setAnnotations((prev) =>
|
||||
prev.some((a) => a.id === saved.id) ? prev : [...prev, saved],
|
||||
);
|
||||
// สร้าง "วัตถุ" เสร็จ = เลือกตัวใหม่ทันที (ใช้ id จาก server — id ชั่วคราวของ
|
||||
// optimistic row ถูกถอดตอน transition จบ เลือก id นั้นไฮไลต์จะหายเอง)
|
||||
if (AUTO_SELECT_TYPES.has(type)) {
|
||||
setSelectedId(saved.id);
|
||||
setPreviewData(null);
|
||||
}
|
||||
// F2: undo ของ create = ลบ id ปัจจุบัน; redo = สร้างใหม่ (id ใหม่) แล้วส่งต่อให้ undo รอบถัดไป
|
||||
let currentId = saved.id;
|
||||
pushHistory({
|
||||
|
|
@ -1758,6 +1776,23 @@ export default function PDFViewer({
|
|||
raw: string;
|
||||
} | null>(null);
|
||||
|
||||
/** จอสัมผัส: selection ล่าสุดที่หยุดนิ่งแล้ว (snapshot) + ตำแหน่งปุ่มยืนยัน —
|
||||
* บน touch ไม่มี mouseup หลังคลุมข้อความด้วยนิ้ว (synthetic mouse events เกิด
|
||||
* เฉพาะ tap) จึง commit ผ่านปุ่ม "ไฮไลต์" ลอยใต้ selection แทน — ปุ่มอ่านจาก
|
||||
* snapshot ใน ref เพราะ iOS เคลียร์ selection ทันทีที่แตะปุ่ม ก่อน click ยิง */
|
||||
const [touchConfirm, setTouchConfirm] = useState<{
|
||||
pageIndex: number;
|
||||
x: number; // จุดยึดกึ่งกลางแนวนอนของแถบ (px อิงหน้า)
|
||||
y: number; // ขอบบนของแถบ (px อิงหน้า)
|
||||
} | null>(null);
|
||||
const touchSelRef = useRef<{
|
||||
pageIndex: number;
|
||||
rects: HighlightData["rects"];
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const touchSelTimerRef = useRef<number | null>(null);
|
||||
const touchHideTimerRef = useRef<number | null>(null);
|
||||
|
||||
/** ความลึกของ bookmark (root = 1) บน state ปัจจุบัน — popup ใช้กรองเป้าหมายระดับ 1–2
|
||||
* (cap 100 กันลูปไม่รู้จบหากข้อมูลวนซ้ำ) */
|
||||
function bookmarkDepthOf(b: BookmarkDTO): number {
|
||||
|
|
@ -1964,6 +1999,7 @@ export default function PDFViewer({
|
|||
// — text ใช้แค่ค้นหา/preview/ชื่อ bookmark
|
||||
text: opts?.withText ? selectionTextWithin(sweptRects, range) : "",
|
||||
first: sweptRects[0],
|
||||
last: sweptRects[sweptRects.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1975,13 +2011,44 @@ export default function PDFViewer({
|
|||
if (!TEXT_SELECTION_TOOLS.has(tool)) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
setTouchConfirm(null);
|
||||
touchSelRef.current = null;
|
||||
return;
|
||||
}
|
||||
// จอสัมผัส: หลังคลุมข้อความด้วยนิ้วไม่มี mouseup ให้ commit (synthetic mouse
|
||||
// events เกิดเฉพาะ tap) → tool highlight แสดงปุ่ม "ไฮไลต์" ลอยใต้ selection
|
||||
// แทนการสร้างตอนปล่อยเมาส์ — เฉพาะอุปกรณ์ที่ pointer หลักเป็น coarse (เมาส์
|
||||
// ยังใช้ mouseup ตามเดิม ไม่โผล่ปุ่มมารบกวน)
|
||||
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||
// ซ่อนปุ่ม + ล้าง snapshot แบบหน่วงเวลา: บน iOS การแตะปุ่มเองทำให้ OS เคลียร์
|
||||
// selection ก่อน tap จะถึงปุ่ม (selectionchange collapsed ยิงก่อน pointerdown
|
||||
// ของปุ่ม) — หน่วงไว้ให้ tap นั้น commit จาก snapshot ได้ก่อน ปุ่มจึงไม่หายกลางคัน
|
||||
const scheduleTouchHide = () => {
|
||||
if (touchHideTimerRef.current !== null) {
|
||||
window.clearTimeout(touchHideTimerRef.current);
|
||||
}
|
||||
touchHideTimerRef.current = window.setTimeout(() => {
|
||||
touchHideTimerRef.current = null;
|
||||
touchSelRef.current = null;
|
||||
setTouchConfirm(null);
|
||||
}, 250);
|
||||
};
|
||||
const onSelectionChange = () => {
|
||||
// selection กำลังเปลี่ยนอยู่ — ยกเลิก snapshot ที่ค้าง + การซ่อนปุ่มที่ค้าง
|
||||
// (selection กลับมา = ผู้ใช้เริ่ม/ปรับ selection ใหม่ ปุ่มต้องอยู่ต่อ)
|
||||
if (touchSelTimerRef.current !== null) {
|
||||
window.clearTimeout(touchSelTimerRef.current);
|
||||
touchSelTimerRef.current = null;
|
||||
}
|
||||
if (touchHideTimerRef.current !== null) {
|
||||
window.clearTimeout(touchHideTimerRef.current);
|
||||
touchHideTimerRef.current = null;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
scheduleTouchHide();
|
||||
return;
|
||||
}
|
||||
const node = selection.getRangeAt(0).commonAncestorContainer;
|
||||
|
|
@ -1990,6 +2057,7 @@ export default function PDFViewer({
|
|||
if (!pageEl) {
|
||||
setLiveMark(null);
|
||||
setSelectPreview(null);
|
||||
scheduleTouchHide();
|
||||
return;
|
||||
}
|
||||
const pi = Number(pageEl.dataset.page);
|
||||
|
|
@ -2012,6 +2080,34 @@ export default function PDFViewer({
|
|||
);
|
||||
return same ? prev : next;
|
||||
});
|
||||
// จอสัมผัส + tool highlight — selection หยุดนิ่ง ~400ms แล้ว snapshot (rects +
|
||||
// ข้อความ) ให้ปุ่มยืนยันใช้: debounce ข้ามช่วงที่ผู้ใช้ยังลาก handles อยู่
|
||||
if (coarse && tool === "highlight") {
|
||||
touchSelTimerRef.current = window.setTimeout(() => {
|
||||
touchSelTimerRef.current = null;
|
||||
const snap = readTextSelection(pi, { withText: true });
|
||||
if (!snap) {
|
||||
scheduleTouchHide();
|
||||
return;
|
||||
}
|
||||
touchSelRef.current = {
|
||||
pageIndex: pi,
|
||||
rects: snap.rects,
|
||||
text: snap.text,
|
||||
};
|
||||
// ชื่อ bookmark default จาก selection เหมือน mouseup ของ desktop
|
||||
lastSelectionTextRef.current = normalizeBookmarkTitle(snap.text);
|
||||
const pr = pageEl.getBoundingClientRect();
|
||||
// แถบลอยใต้บรรทัดสุดท้ายของ selection — ด้านบนถูกเมนู/handles ของ OS
|
||||
// ยึดไว้ ใกล้ขอบล่างหน้าเกิน → พลิกขึ้นเหนือ rect แรกแทน
|
||||
const below = snap.last.bottom - pr.top + 8;
|
||||
setTouchConfirm({
|
||||
pageIndex: pi,
|
||||
x: snap.first.left + snap.first.width / 2 - pr.left,
|
||||
y: below + 40 <= pr.height ? below : snap.first.top - pr.top - 44,
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
// เคลียร์เส้นทางลากตอนปล่อยเมาส์ — listener นี้ที่ document ยิงหลัง React
|
||||
|
|
@ -2024,6 +2120,14 @@ export default function PDFViewer({
|
|||
return () => {
|
||||
document.removeEventListener("selectionchange", onSelectionChange);
|
||||
document.removeEventListener("mouseup", onMouseUpDoc);
|
||||
if (touchSelTimerRef.current !== null) {
|
||||
window.clearTimeout(touchSelTimerRef.current);
|
||||
touchSelTimerRef.current = null;
|
||||
}
|
||||
if (touchHideTimerRef.current !== null) {
|
||||
window.clearTimeout(touchHideTimerRef.current);
|
||||
touchHideTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [tool]);
|
||||
|
||||
|
|
@ -2069,6 +2173,38 @@ export default function PDFViewer({
|
|||
return () => document.removeEventListener("copy", onCopy);
|
||||
}, [tool]);
|
||||
|
||||
/** สร้าง HIGHLIGHT จาก selection ที่อ่านได้ — ใช้ร่วมกันทั้ง desktop (mouseup บนหน้า)
|
||||
* และจอสัมผัส (ปุ่ม "ไฮไลต์" ลอยใต้ selection) */
|
||||
function commitHighlight(
|
||||
pageIndex: number,
|
||||
sel: { rects: HighlightData["rects"]; text: string },
|
||||
) {
|
||||
setLiveMark(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
void handleCreateAnnotation(pageIndex, "HIGHLIGHT", {
|
||||
text: sel.text,
|
||||
color: highlightColor,
|
||||
rects: sel.rects,
|
||||
...(highlightStyle !== "highlight" ? { style: highlightStyle } : {}),
|
||||
...(highlightOpacityPct < 100 ? { opacity: highlightOpacityPct / 100 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** จอสัมผัส: แตะปุ่ม "ไฮไลต์" ลอยใต้ selection — commit จาก snapshot ใน ref
|
||||
* (ไม่ใช่ selection สด เพราะ iOS เคลียร์ selection ก่อน tap จะถึงปุ่ม —
|
||||
* จึงผูกกับ onPointerDown ที่ยิงก่อน click ด้วย) */
|
||||
function handleTouchConfirmHighlight() {
|
||||
const snap = touchSelRef.current;
|
||||
touchSelRef.current = null;
|
||||
if (touchHideTimerRef.current !== null) {
|
||||
window.clearTimeout(touchHideTimerRef.current);
|
||||
touchHideTimerRef.current = null;
|
||||
}
|
||||
setTouchConfirm(null);
|
||||
if (!snap) return;
|
||||
commitHighlight(snap.pageIndex, snap);
|
||||
}
|
||||
|
||||
function handleMouseUpOnPage(pageIndex: number) {
|
||||
// N2: จำข้อความที่เพิ่งคลุมบนหน้า (ทุก tool) — ใช้เป็นชื่อ default ของ popup bookmark
|
||||
// (คลิกปุ่ม 🔖 ใน header ไม่ผ่าน mouseup ของหน้า ค่าจึงไม่โดนเคลียร์ก่อนเปิด popup)
|
||||
|
|
@ -2079,15 +2215,7 @@ export default function PDFViewer({
|
|||
// tool select คลุมข้อความเฉย ๆ — ไม่มี popup
|
||||
if (tool !== "highlight") return;
|
||||
if (!sel) return;
|
||||
setLiveMark(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
void handleCreateAnnotation(pageIndex, "HIGHLIGHT", {
|
||||
text: sel.text,
|
||||
color: highlightColor,
|
||||
rects: sel.rects,
|
||||
...(highlightStyle !== "highlight" ? { style: highlightStyle } : {}),
|
||||
...(highlightOpacityPct < 100 ? { opacity: highlightOpacityPct / 100 } : {}),
|
||||
});
|
||||
commitHighlight(pageIndex, sel);
|
||||
}
|
||||
|
||||
// ─── Comment (PLAN.md §6.3) ────────────────────────────────────────────────
|
||||
|
|
@ -2400,6 +2528,7 @@ export default function PDFViewer({
|
|||
onUpdateBookmark={(id, patch) => void handleUpdateBookmark(id, patch)}
|
||||
onGoToBookmark={goToBookmark}
|
||||
onGoToAnnotation={goToAnnotation}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
|
|
@ -3236,6 +3365,36 @@ export default function PDFViewer({
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* จอสัมผัส: ปุ่มยืนยันสร้าง highlight หลังคลุมข้อความเสร็จ (บน
|
||||
touch ไม่มี mouseup) — ลอยใต้ selection แตะแล้ว commit จาก
|
||||
snapshot; commit ที่ pointerdown (เร็วกว่า click) +
|
||||
stopPropagation กัน bubble ไป handler ของหน้า */}
|
||||
{touchConfirm?.pageIndex === i && (
|
||||
<div
|
||||
className="absolute z-[5] select-none"
|
||||
style={{
|
||||
left: touchConfirm.x,
|
||||
top: touchConfirm.y,
|
||||
transform: "translate(-50%, 0)",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTouchConfirmHighlight();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex touch-manipulation items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-lg"
|
||||
>
|
||||
<span
|
||||
className="h-3.5 w-3.5 rounded-[2px]"
|
||||
style={{ backgroundColor: highlightColor }}
|
||||
/>
|
||||
ไฮไลต์
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
|
|
@ -132,6 +132,7 @@ export default function AnnotationSidebar({
|
|||
onUpdateBookmark,
|
||||
onGoToBookmark,
|
||||
onGoToAnnotation,
|
||||
selectedId,
|
||||
}: {
|
||||
annotations: AnnotationDTO[];
|
||||
bookmarks: BookmarkDTO[];
|
||||
|
|
@ -157,9 +158,26 @@ export default function AnnotationSidebar({
|
|||
) => void;
|
||||
onGoToBookmark: (b: BookmarkDTO) => void;
|
||||
onGoToAnnotation: (a: AnnotationDTO) => void;
|
||||
/** id ของ annotation ที่ถูกเลือกอยู่บนหน้า PDF (null = ไม่มี) — แถวในแผงนี้ไฮไลต์ตาม */
|
||||
selectedId: string | null;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks");
|
||||
|
||||
// คลิกเลือก annotation บนหน้าเอกสาร = สลับมาแท็บ annotations (ถ้ายังอยู่ bookmarks)
|
||||
// แล้วเลื่อนแถวนั้นเข้ามุมมอง — แถว render หลัง setTab จึงค่อยเลื่อนใน rAF ถัดไป
|
||||
// (dep รวม tab ตาม lint — ผลข้างเคียง: เปิดแท็บนี้ตอนยังมีรายการถูกเลือก = เลื่อนไปแถวนั้นเช่นกัน)
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
if (tab !== "annotations") setTab("annotations");
|
||||
const raf = requestAnimationFrame(() => {
|
||||
listRef.current
|
||||
?.querySelector(`[data-ann-id="${CSS.escape(selectedId)}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [selectedId, tab]);
|
||||
|
||||
// ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ────
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<AnnotationDTO[] | null>(null);
|
||||
|
|
@ -488,9 +506,12 @@ export default function AnnotationSidebar({
|
|||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className={`group rounded px-2 py-1.5 hover:bg-slate-100 ${
|
||||
a.resolved ? "opacity-60" : ""
|
||||
}`}
|
||||
data-ann-id={a.id}
|
||||
className={`group rounded px-2 py-1.5 ${
|
||||
a.id === selectedId
|
||||
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
|
||||
: "hover:bg-slate-100"
|
||||
} ${a.resolved ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
|
@ -677,7 +698,11 @@ export default function AnnotationSidebar({
|
|||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start gap-2 rounded px-1 py-1 text-left hover:bg-slate-100"
|
||||
className={`flex w-full items-start gap-2 rounded px-1 py-1 text-left ${
|
||||
a.id === selectedId
|
||||
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
|
||||
: "hover:bg-slate-100"
|
||||
}`}
|
||||
onClick={() => onGoToAnnotation(a)}
|
||||
>
|
||||
<TypeIcon type={a.type} data={a.data} />
|
||||
|
|
@ -717,7 +742,7 @@ export default function AnnotationSidebar({
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
||||
{tab === "bookmarks" ? (
|
||||
bookmarks.length === 0 ? (
|
||||
<p className="p-4 text-center text-xs text-slate-400">
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT P
|
|||
- **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts`
|
||||
- **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=<id>` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง)
|
||||
- pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน
|
||||
- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) → หาก SSE event มาถึงก่อน action เสร็จ จะเห็น duplicate ชั่วขณะแล้วหายเองเมื่อ optimistic row ถูกถอด (self-healing) — hook ยัง dedupe ด้วย id กับ state ปัจจุบันอยู่
|
||||
- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) — client ส่ง id ของ optimistic row ให้ `createAnnotation` ใช้ซ้ำ (action validate เป็น UUID) เพื่อให้ SSE event ที่มาถึงก่อน action เสร็จ dedupe ด้วย id เดียวกันได้ทันที **ไม่เห็น duplicate ชั่วขณะ** (เดิม row จริงใช้ id ใหม่ทำให้แสดง 2 แถวแวบหนึ่งก่อน row ชั่วคราวถูกถอด)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue