npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sann-tech/react-image-markup

v0.6.5

Published

React component for filling paper-style forms and annotating images on an HTML5 canvas — handwriting with real pen pressure, typed fields, and a flattened image export at the original resolution.

Readme

@sann-tech/react-image-markup

React component để điền form và ghi chú lên ảnh bằng HTML5 canvas, rồi xuất ra một ảnh mới đã ghép ảnh gốc với toàn bộ nét vẽ — ở đúng độ phân giải của ảnh gốc.

Dành cho tình huống người dùng đã quen làm việc đó trên giấy và không nên phải học lại trên màn hình: đưa họ tờ form, để họ viết lên.

English documentation →

  • Canvas thuần, không phụ thuộc thư viện đồ hoạ ngoài
  • Nhập ảnh: URL, data URL, File/Blob, chọn file, hoặc kéo thả
  • Xuất ảnh: toDataURL / toBlob / toFile / download
  • Điền form theo ô: gõ chữ vào đúng ô, viết tay vào ô, Tab đi ô kế tiếp, biết ô nào đã điền
  • Chống tì tay khi dùng bút cảm ứng
  • Công cụ: bút vẽ, bút dạ quang, tẩy, mũi tên, đường thẳng, chữ nhật, elip, chữ
  • Nét bút nhấn nhá theo lực nhấn, có khử rung và vuốt nhọn hai đầu
  • Bảng màu kèm bộ chọn màu tuỳ ý, độ dày nét chỉnh từ 0,5 px
  • Chế độ bảng kẹp giấy: tờ giấy kẹp trên tấm ván đặt giữa mặt bàn, tạo được giấy trắng / kẻ ngang / ô ly để vẽ
  • Thanh công cụ và thanh xuất ảnh đều kéo ra đẩy vào được, tờ giấy không bị che thường trực
  • Phóng to để viết vào ô nhỏ, thu nhỏ để nhìn cả tờ; nét vẫn lưu theo toạ độ ảnh gốc
  • Hoàn tác / làm lại theo ảnh chụp từng bước (xoá hết cũng hoàn tác được), phím tắt Ctrl+Z, Ctrl+Shift+Z
  • Hỗ trợ chuột, cảm ứng và bút stylus (Pointer Events)
  • Sắc nét trên màn hình Retina, CSS tự nhúng nên không cần import file css

Thử ngay

Mở docs/demo.html bằng trình duyệt. File này đã nhúng sẵn cả React lẫn thư viện nên chạy được offline, không cần cài gì.

Muốn sửa mã và xem ngay thì chạy máy chủ dev:

npm install
npm run dev -- --host

Cờ --host mở cổng ra mạng nội bộ, nhờ vậy mở được trên máy tính bảng — địa chỉ LAN hiện ngay trong terminal. Đây là cách duy nhất thử đúng thứ cần thử: lực nhấn thật của bút, và chống tì tay khi cổ tay đặt lên màn hình. Chuột trên máy tính không tái hiện được hai thứ đó.

Cài đặt

npm i @sann-tech/react-image-markup

react và react-dom (>= 17) là peer dependency.

Thử ở dự án local trước khi publish:

cd du-an-cua-ban
npm install /duong/dan/toi/react-image-markup

Nên dùng đường dẫn file: thay cho npm link — npm link hay gây lỗi Invalid hook call do dự án nạp hai bản React khác nhau.

Dùng nhanh

import { useRef } from 'react';
import { ImageAnnotator, type ImageAnnotatorHandle } from '@sann-tech/react-image-markup';

export default function Editor() {
  const ref = useRef<ImageAnnotatorHandle>(null);

  const save = async () => {
    const blob = await ref.current!.toBlob({ type: 'image/jpeg', quality: 0.9 });
    const form = new FormData();
    form.append('file', blob!, 'annotated.jpg');
    await fetch('/api/upload', { method: 'POST', body: form });
  };

  return (
    <>
      <ImageAnnotator ref={ref} src="/anh-goc.jpg" maxHeight={600} />
      <button onClick={save}>Lưu ảnh</button>
    </>
  );
}

Cách hoạt động

Component dựng hai canvas chồng lên nhau: một canvas vẽ ảnh nền, một canvas trong suốt chứa nét vẽ. Nhờ vậy công cụ tẩy chỉ xoá nét vẽ chứ không đục thủng ảnh.

Mọi nét vẽ được lưu dưới dạng danh sách object (Shape[]) theo hệ toạ độ của ảnh gốc, không phải toạ độ màn hình. Khi xuất, thư viện tạo một canvas mới đúng bằng naturalWidth × naturalHeight, vẽ lại ảnh gốc rồi vẽ lại toàn bộ nét lên trên. Kết quả: khung hiển thị có thu nhỏ thế nào thì ảnh xuất ra vẫn giữ nguyên độ nét, và bạn có thể lưu Shape[] xuống database để mở ra chỉnh sửa tiếp.

Nét bút nhấn nhá

Bút vẽ và tẩy không dùng stroke() mà tô một polygon dựng riêng cho từng nét, nhờ vậy độ dày thay đổi được dọc theo nét. Ba bước:

  1. Khử rung — trung bình trượt chạy nhiều lượt nhẹ trên cả toạ độ lẫn độ đậm, chạy khi dãy điểm còn dày. Một lượt chỉ gọt được nhiễu tần số cao, tay run thì gợn ở tần số thấp hơn nên phải lặp.
  2. Rút gọn RDP — giữ lại những điểm định hình đường nét, bỏ phần còn lại. Khác với làm trơn, bước này xoá hẳn rung tay thay vì chỉ hạ biên độ.
  3. Lấy mẫu lại theo Catmull-Rom hướng tâm với khoảng cách đều. Bản tham số hoá đều thông dụng hơn nhưng tạo mấu nhọn khi điểm điều khiển thưa không đều, đúng tình huống sau RDP.
  4. Dựng đường bao với bán kính riêng từng điểm, bù miter ở góc cua gấp và vuốt nhọn hai đầu theo chiều dài cung.

Hai chỗ tinh tế đáng biết nếu bạn định chỉnh bộ lọc. Thứ nhất, làm trơn bắt buộc phải chạy trước RDP: chạy sau, mỗi lượt sẽ kéo tụt chính những điểm định hình nét và vài lượt là góc nhọn bị san phẳng. Thứ hai, RDP chỉ nhìn hình học nên nó vứt điểm mà không quan tâm điểm đó mang lực nhấn gì — một nét thẳng vẽ với lực nhấn thay đổi sẽ bị rút còn hai điểm và mất sạch đậm nhạt. Vì vậy độ đậm được lấy lại từ dãy điểm gốc, chiếu theo tỉ lệ chiều dài cung. Cả hai đều có test canh trong scripts/stroke-test.mjs.

Độ dày đã chọn là độ dày viết ra. Bán kính từng điểm được chuẩn hoá quanh lực nhấn vừa tay: chọn ngòi 4px thì ở lực vừa tay nét ra đúng 4px, ấn mạnh dày hơn (trần 1,3 lần), ấn nhẹ mảnh hơn. Lấy con số đã chọn làm trần — chỉ với tới khi ấn hết cỡ — thì mọi nét đều mảnh hơn con số trên thanh công cụ và chỉnh mãi không khớp.

Mức lọc còn hạ theo độ nhỏ của nét, không chỉ theo cỡ ngòi. Bán kính lọc đo bằng pixel: với một cú vuốt dài thì chẳng thấy gì, nhưng chỗ uốn của chữ viết nhỏ — dấu ngã, vòng chữ e, cái móc chữ h — chỉ rộng đúng vài pixel, tức nằm gọn trong bán kính ấy và bị san phẳng. Đường chéo khung bao, đo theo cỡ ngòi, chính là thước đo "nét này nhỏ cỡ nào". Cùng lý đó, đoạn vuốt nhọn hai đầu phải nhẹ tay lại với nét ngắn: viết chữ nhỏ thì mỗi nét chữ chỉ dài vài lần cỡ ngòi, hai đầu vuốt gặp nhau ở giữa là cả nét thành cái dằm nhọn.

Mức nhấn nhá không cố định mà giảm dần theo độ dày ngòi. Ngoài đời bút kim mảnh cho nét lúc mảnh lúc đậm rõ rệt với đầu nét nhọn hoắt, còn bút dạ bản rộng cho nét gần như đều với đầu nét cùn. Cho cả hai cùng một mức nhấn nhá thì nét dày biến thành hình chiếc lá — đó chính là cái trông giả. Prop dynamics cho phép đặt tay mức này nếu bạn muốn.

Độ đậm lấy từ đâu: bút cảm ứng cho lực nhấn thật qua PointerEvent.pressure. Chuột và ngón tay không có, nên thư viện mô phỏng từ vận tốc — di nhanh nét mảnh, di chậm nét đậm, đúng cách mực chảy trên giấy. Trên màn hình tần số quét cao, getCoalescedEvents() lấy hết các điểm bị gộp trong một sự kiện nên nét bám tay hơn.

Chỗ này có một cái bẫy: vận tốc phải tính bằng event.timeStamp của chính từng điểm, không phải đồng hồ đọc lúc xử lý. Cả gói điểm gộp được xử lý trong cùng một tích tắc nên nếu đọc đồng hồ thì mọi điểm mang cùng một mốc, khoảng thời gian tụt về sàn, vận tốc bị thổi lên gấp mấy lần và độ đậm gợn lên xuống đúng theo nhịp khung hình — nhìn ra mép nét lượn sóng trong khi đường tâm vẫn thẳng. Cũng vì vậy mà bước kéo độ đậm về giá trị đích đi theo thời gian chứ không theo số điểm: chuột báo 60 điểm mỗi giây còn bút trên màn 120Hz báo cả trăm, một hệ số cố định mỗi điểm thì cùng cú vuốt ra hai dáng nét khác hẳn.

<ImageAnnotator
  src={src}
  variableWidth   // mặc định bật, tắt đi thì nét đều tăm tắp
  smoothing={0.5} // 0 = giữ nguyên đường tay đi, 1 = mượt tối đa
  taper           // vuốt nhọn hai đầu
/>

Bút dạ quang cố tình giữ bề rộng cố định, vì bút dạ thật vốn vậy.

Chế độ bảng kẹp giấy

paper kẹp tờ ảnh lên một tấm bảng kẹp đặt trên mặt bàn tối: ván ép, cái kẹp kim loại vắt ngang đầu bảng, bóng đổ nhiều lớp và vân giấy. Tất cả chỉ để nhìn, không đi vào ảnh xuất ra.

<ImageAnnotator src={src} paper="plain" deskColor="#2b2f36" boardColor="#8d6d4a" />

Đã là bảng kẹp thì thanh công cụ và thanh xuất ảnh treo luôn ở mép khung thay vì xếp bên ngoài, mỗi thanh sau một cái tai để kéo ra hoặc đẩy vào — nhờ vậy tờ giấy không bị che thường trực. Muốn giữ kiểu cũ, hai thanh xếp ngoài khung, thì đặt floatingToolbar={false}.

Tấm ván vẽ vòng ra ngoài tờ giấy chứ không phải cái hộp chứa tờ giấy, nên tờ giấy vẫn tự quyết kích thước và mép ván luôn thò ra đủ bốn cạnh.

Thu phóng

zoom đổi cỡ ảnh trên màn hình. 1 là vừa khung — cỡ mà thư viện tự tính theo bề ngang khung, maxWidth và maxHeight. Phóng quá khổ thì khung cuộn ngang, còn hai thanh vẫn đứng nguyên chỗ cũ.

<ImageAnnotator src={src} defaultZoom={1.5} minZoom={0.5} maxZoom={3} />

Đây chỉ là cỡ nhìn thấy. Nét lưu theo toạ độ ảnh gốc, nên phóng to lên để viết vào một ô nhỏ không đổi gì ở ảnh xuất ra — cỡ ảnh xuất ra là ExportOptions.scale, một núm khác hẳn.

Không có ảnh nền thì blank dựng cho bạn một tờ giấy để vẽ. Dòng kẻ ở đây được vẽ thẳng vào ảnh nền chứ không phải overlay, nên ảnh xuất ra giống hệt lúc soạn:

// A4 ở 150 dpi
<ImageAnnotator blank={{ width: 1240, height: 1754, lineGap: 72 }} paper="ruled" />

paper nhận 'plain', 'ruled' (kẻ ngang, có lề đỏ) hoặc 'grid' (ô ly).

Điền form

Khai báo các ô của tờ form qua prop fields, toạ độ tính theo hệ toạ độ ảnh gốc — khai một lần là đúng ở mọi cỡ hiển thị.

const FIELDS: FormField[] = [
  { id: 'ten', x: 460, y: 270, width: 680, height: 64, label: 'Họ và tên' },
  { id: 'chuky', x: 460, y: 790, width: 680, height: 150, label: 'Chữ ký', accepts: 'ink' },
];

<ImageAnnotator src="/to-khai.png" fields={FIELDS} defaultTool="text" />

Có fields rồi thì:

  • Bấm vào một ô khi đang chọn công cụ Chữ sẽ mở ô nhập phủ đúng ô đó, cỡ chữ suy từ chiều cao ô. Chữ dài hơn ô tự bóp lại cho vừa thay vì tràn sang ô bên cạnh.
  • Tab / Shift+Tab đi ô kế tiếp. Phím này chỉ bị chiếm khi con trỏ đang nằm trong ô nhập của component, ngoài ra Tab vẫn là Tab của cả trang.
  • Nét viết tay rơi vào ô nào thì được gán fieldId của ô đó, nên biết được ô nào đã điền: ref.current.getFilledFields(), hoặc tự lọc từ Shape[].
  • Ô đặt accepts: 'ink' chỉ nhận nét viết tay (dùng cho chữ ký), accepts: 'text' thì chỉ nhận chữ gõ.
  • showFieldHints vẽ viền mờ đánh dấu ô. Viền là lớp DOM nên không đi vào ảnh xuất ra.

Cùng một tờ form nhận được cả hai lối nhập: chỗ nào có bút thì viết tay — máy tính bảng, hoặc bảng vẽ cắm vào máy tính để bàn — chỗ nào chỉ có chuột thì gõ chữ vào ô, vì chuột không viết tay ra chữ đọc được. Cả hai dẹt xuống cùng một ảnh kết quả.

Tờ giấy sinh từ schema — <PaperForm>

Điểm vào riêng, không nằm trong gói chính: ai chỉ chú thích lên ảnh thì không phải tải theo bố cục, bảng đo chữ và bộ sinh nét của chế độ này.

import { PaperForm, type SheetSchema } from '@sann-tech/react-image-markup/paper-form';

Khai dòng và cột, thư viện in ra tờ giấy có sẵn lựa chọn, rồi người dùng khoanh tròn, tích ô, gõ số và viết tay lên đó như trên giấy thật. Cái khác hẳn phần còn lại của thư viện: nét bút ở đây đồng thời là dữ liệu — khoanh một lựa chọn vừa để lại vệt mực trên ảnh, vừa trả về đúng lựa chọn đó cho app ghi vào cơ sở dữ liệu. Không cần nhận dạng chữ viết tay, vì ta biết người dùng đã chạm vào cái gì.

Khối tiêu đề và bảng màu

const sheet: SheetSchema = {
  id: 'shien-kiroku',
  width: 1240,
  header: {
    title: '支援記録票',
    stamp: 'さくら荘 ユニットA',
    meta: [
      { key: '記録日', value: '2026年9月4日(金)' },
      { key: '利用者', value: '佐藤 花子' },
      { key: '記入者', value: '山田 太郎', hanko: '山' },
    ],
  },
  palette: { paper: '#FCFBF7', paperEdge: '#F1EDE2', stamp: '#C0403C' },
  // …columns, rows, typography
};

header in ở đầu tờ: tên biểu mẫu giãn chữ, ô tên đơn vị đóng khung đỏ ở góc phải, rồi dải ô thông tin. Không phải trang trí — một tờ ghi chép rời khỏi màn hình sẽ được in, ký, scan và lưu trữ, nên nó phải tự nói được nó là biểu mẫu gì, của đơn vị nào, ngày nào và ai ghi.

Giá trị trong meta in bằng nét bút mực xanh, không phải chữ in: ngày và tên là thứ được điền vào tờ, dù app đã biết sẵn. In bằng 明朝 thì chúng trông như phần cứng của biểu mẫu và người đọc mất manh mối để biết chỗ nào là nội dung của riêng tờ này. hanko đóng thêm một con dấu đỏ nghiêng 7° cạnh giá trị.

palette có bảy màu, mỗi màu một nghĩa chứ không phải một sắc độ cho đẹp:

| Khoá | Mặc định | Dùng cho | | --- | --- | --- | | paper | #FCFBF7 | Màu giấy | | paperEdge | #F1EDE2 | Nền cột 項目 và dải tiêu đề cột | | rule | #D3CCBC | Khung kẻ giữa các dòng | | ruleHard | #8C8474 | Ranh giới có nghĩa: hết tiêu đề, hết một phần | | printSoft | #8D8574 | Nhãn ô, đơn vị, tiêu đề cột | | printFaint | #B6AE9C | Dấu ngăn ・, dấu └ của dòng phụ | | stamp | #C0403C | Con dấu 印, ô tên đơn vị |

Cỡ chữ suy ra từ typography.print.size: lựa chọn giữ nguyên cỡ đó vì đó là thứ phải bấm trúng, nhãn ô nhỏ hơn 15%, đơn vị và tiêu đề cột nhỏ hơn 22%. Dòng phụ (children) in nhạt hơn, nhỏ hơn, thụt vào và có dấu └ — nó chỉ có nghĩa khi gắn với dòng trên nó.

Thanh công cụ gắn sẵn, gấp lại được

Tờ giấy là thứ người dùng tới để xem; thanh công cụ chỉ là phương tiện. Nên nó treo ở mép dưới tờ giấy và gấp lại được — gấp vào thì cả khung còn đúng tờ giấy, cái tai vẫn ở đó để mở lại.

<PaperForm schema={sheet} />                        {/* thanh hiện sẵn, gấp được */}
<PaperForm schema={sheet} defaultToolbarOpen={false} />  {/* mở trang ra là chỉ có giấy */}
<PaperForm schema={sheet} showToolbar={false} />    {/* app tự dựng, điều khiển qua `mode` */}

Thanh chừa sẵn một dải trống bên dưới tờ giấy cho chính nó, và dải đó có mặt kể cả khi đã gấp: treo hẳn lên mặt giấy thì nó che đúng dòng dưới cùng — mà dòng dưới cùng của một tờ ghi chép là chỗ ký tên — còn co giãn theo trạng thái thì tờ giấy nhảy chỗ giữa lúc đang viết.

Trên thanh có ba chế độ, Hoàn tác và Xoá hết mực. Chữ trên nút đổi được qua labels.

Ba chế độ

<PaperForm schema={sheet} defaultMode="tap" onModeChange={setMode} />

mode có kiểm soát được nếu app muốn tự giữ trạng thái; bỏ trống thì component tự quản qua defaultMode.

| mode | Việc của nó | | --- | --- | | 'tap' (mặc định) | Chạm để khoanh lựa chọn, tích ô, mở bàn phím số / giờ / chữ | | 'draw' | Viết tay lên cả tờ giấy | | 'erase' | Quét lên nét nào thì bỏ nét đó |

Bút cảm ứng viết được ngay ở chế độ chạm, không phải đổi chế độ. Trên giấy thật không ai "đổi chế độ": cầm bút lên là viết, đưa ngón tay là lật trang. Đầu bút phân biệt được hai việc đó nên thư viện tin nó; ngón tay và chuột thì không, nên chúng vẫn phải theo mode. Tắt bằng penDraws={false}.

Bút đặt xuống ngay trên một lựa chọn in sẵn thì ra mực, không khoanh chọn cái đó — pointerdown được bắt trước cả nút chạm nằm bên dưới.

Ô viết tay, và điều nó cố ý không làm

{ run: 'ink', id: 'signInk', height: 58 }   // chữ ký, ghi chú viết bằng bút
{ run: 'text', id: 'memoText', lines: 2 }   // dòng kẻ để GÕ chữ

Nét viết vào ô ink được gán fieldId của ô đó, nên getValues() nói được ô chữ ký đã có người ký hay chưa:

{ kind: 'ink', recoverable: false, strokeCount: 2, bounds: { … } }

recoverable: false nằm thẳng trong kiểu để nơi gọi không thể vô tình tin rằng mình có dữ liệu. Thư viện không đọc chữ viết tay và không bao giờ bịa ra một giá trị cho những ô này — muốn dữ liệu đọc lại được thì khai run: 'text' và gõ bằng bàn phím.

Thu phóng tờ giấy

<PaperForm schema={sheet} defaultZoom={1.5} minZoom={0.5} maxZoom={3} />

Tờ ghi chép thì cao và hẹp, mà fitToWidth chỉ biết thu nhỏ — trên tablet thì ô 体温 còn vài milimet, không phải cái ô viết được bằng bút cảm ứng. zoom nhân lên từ mức vừa khung, đúng như ở <ImageAnnotator>: 1 là vừa khung, 2 là gấp đôi mức đó. Cùng bộ nấc tròn, cùng ba cái nút, nút giữa quay về vừa khung.

Mọi thứ người dùng chạm vào — nút chạm, lớp mực, khay chọn — đều nằm trong khung đã bị transform: scale(), và toạ độ con trỏ được chia cho đúng cái scale đó, nên nét bút rơi đúng đầu bút ở mọi mức phóng. Đó là lý do thu phóng phải nằm trong thư viện: app nào tự bọc một transform bên ngoài thì nét trôi khỏi đầu bút, mà component không có cách nào biết chuyện đó.

Hai thứ thu phóng cố ý KHÔNG đụng tới:

  • Ảnh xuất ra. toCanvas() luôn vẽ tờ giấy ở đúng cỡ đã khai. Thu phóng là chuyện hiển thị, không phải chuyện độ phân giải.
  • Độ phân giải của canvas. Nó đã là cỡ tờ × devicePixelRatio; với tờ 1240×4235 ở DPR 2 thì con số đó đã sát trần canvas của iOS Safari, nhân thêm theo mức phóng là vỡ đúng trên những cái tablet mà chế độ này sinh ra để phục vụ. Đổi lại chữ in phóng to hơi mềm nét — còn nét mực đang viết thì không.

Bộ nút treo cùng dải với thanh công cụ gắn sẵn, nên showZoom mặc định bám theo showToolbar. App tự dựng thanh công cụ (showToolbar={false}) thì tự điều khiển zoom/onZoomChange và đặt nút ở đâu tuỳ ý; ZOOM_STOPS, clampZoom, stepZoom xuất ra ở cả hai điểm vào để hai bên nhảy nấc giống nhau.

Trang A4, và một chồng trang xếp cạnh nhau

Tờ giấy cao theo số dòng schema khai, và mọi lớp canvas đều cao bằng cả tờ. Tờ 31 dòng là 1240×2963px — 14,7 triệu pixel mỗi lớp ở devicePixelRatio 2, đã sát trần mỗi canvas của iOS Safari (~16,7 triệu). Gấp đôi số dòng là vượt trần, mà vượt trần nghĩa là canvas TRẮNG, không phải canvas chậm. Còn khi thu đủ nhỏ để nhìn được cả tờ thì hơn nửa cửa sổ là chỗ trống bên phải một cột hẹp.

Khai pageHeight thì tờ giấy được vẽ thành một chồng trang, cắt chỉ ở ranh giới dòng — không trang nào cắt một dòng làm hai. Mỗi trang in lại dải tiêu đề của tờ, nên trang 5 vẫn nói được là tờ của ai, ngày nào.

<PaperForm schema={sheet} pageHeight="a4" maxPageColumns={2} />

maxPageColumns cho các trang dồn thành nhiều cột khi chúng đủ nhỏ để vừa, nên thu nhỏ là dùng hết bề ngang chứ không bỏ trống. Số cột suy ra từ mức thu đã chốt và không bao giờ hồi tiếp vào nó, nên đổi cỡ khung không thể dao động.

Hai thứ cố tình KHÔNG đổi. Phân trang là chuyện hiển thị: layout, toạ độ nét, getValues(), getShapes() và toCanvas() y nguyên từng byte, nên mực đã lưu vẫn rơi đúng ô cũ và ảnh xuất ra giống hệt khi bật hay tắt phân trang. Và chỉ những trang gần tầm nhìn mới giữ canvas thật — số còn lại nằm ở 1×1 — nên mỗi canvas là 8,7 triệu pixel bất kể tờ dài bao nhiêu, và trần canvas thôi là thứ chờ sẵn ở một biểu mẫu đủ lớn.

getPages() trả về đúng bộ ranh giới trang mà màn hình đang dùng. Đưa nó cho đường xuất PDF thì "trang 3 trên màn hình" là "trang 3 trong tệp"; tính phép cắt hai lần thì chỉ là vấn đề thời gian trước khi hai bên lệch nhau.

Danh sách dài thì mở khay chọn

{ run: 'multi', id: 'toiletries', options: [ … ] }                       // vừa một dòng: in ô vuông tích
{ run: 'tray', id: 'bodyParts', title: '部位', options: [ … 14 mục … ], printSelection: true }

Có những danh sách không in vừa một dòng — 14 部位, 15 診療科, 16 loại ヒヤリハット. tray chỉ in tiêu đề và một chỗ trống để viết; chạm vào là mở ra danh sách ô vuông tích thật, chốt xong thì thứ đã chọn được viết bằng mực lên đúng chỗ trống đó.

printSelection luôn bật, và đó chính là điểm mấu chốt: lựa chọn chỉ nằm trong dữ liệu thì ảnh xuất ra thiếu đúng phần mà JSON có — tờ giấy hết còn là bản ghi trung thực. Dòng chữ viết ra tự bóp cỡ cho vừa, nên chọn nhiều mục cũng không tràn sang ô bên cạnh.

Một dòng chữ là MỘT nét, và nó mang cả danh sách:

{ kind: 'tray', values: ['head', 'chest'], labels: ['頭部', '胸部'] }

Nên tẩy dòng đó là mất cả lựa chọn, y như gạch nó đi trên giấy thật. Khay không bao giờ in ô vuông cạnh tiêu đề — có cái ô ở đó là mời người dùng tích vào, mà cái tích ấy không thuộc về lựa chọn nào cả.

Ô chữ và khay chọn to ra theo nội dung

Chữ đã gõ không bao giờ bị nhét cho vừa số dòng kẻ. Câu dài hay nhiều dòng thì ô (và chỗ viết của khay) thêm dòng để hiện đủ chữ ở cỡ chữ tay bình thường, giữ nguyên xuống dòng của người dùng. Các dòng bên dưới dời xuống theo. Mỗi ô to tối đa MAX_GROW_LINES (12) dòng; quá nữa thì chữ thu lại cho vừa, không chồng dòng.

Có hai bố cục, và phân biệt chúng là điều quan trọng nhất khi tự dựng nét:

| | Bố cục | Dùng cho | | --- | --- | --- | | getBaseLayout() | layoutSheet(schema, values, { measure }) — không bao giờ to ra | Toạ độ LƯU: getShapes(), defaultShapes, nét app tự dựng sẵn | | getLayout() | bố cục đã to ra theo giá trị | Thứ hiện ra và in ra: màn hình, getPages(), toCanvas() |

Nhờ vậy bản lưu cũ vẫn mở ra đúng, không có gì phải migrate: nét chữ được dựng lại từ giá trị vào khung đã to ra, còn mọi nét khác (vòng khoanh, chữ ký, viết tay) được dời theo đúng dòng, đúng ô của nó lúc vẽ. App dựng sẵn nét thì luôn dựng theo layoutSheet(schema, values, { measure }), không phải theo getLayout().

Câu mẫu (定型文) cho ô chữ

<PaperForm
  schema={sheet}
  textPhrases={(fieldId) =>
    fieldId === 'memoText' ? () => fetchPhrases('memo') : null   // null = ô này không có nút
  }
/>

textPhrases nhận fieldId của ô chữ và trả về một hàm tải (hoặc null). Quyết định có nút hay không là đồng bộ, còn việc tải thì chỉ chạy khi người dùng bấm nút 定型文 — mở ô chữ mà không dùng câu mẫu thì không tốn một lần đọc nào. Hàm tải trả TextPhrase[] (hoặc Promise của nó):

{ id: 'calm', label: '{利用者名}様は落ち着いていた', text: '山田様は落ち着いていた' }

label là thứ hiện trong danh sách (bỏ trống thì hiện text), text là thứ được chèn — tag thay hay không là việc của app. Danh sách hiện ngay trong khay (không phải modal: chạm ra ngoài khay là đóng khay), chọn được nhiều câu, 追加 chèn lên đầu chữ đang gõ theo đúng thứ tự tích. Ô nhiều dòng thì mỗi câu một dòng; ô một dòng thì nối bằng dấu cách, vì ô đó không nhận ký tự xuống dòng. Hàm thuần insertPhrases(phrases, current, multiline) được export để app dùng cùng một luật.

Tẩy bỏ nét, không khoét pixel

Bên <ImageAnnotator> tẩy bằng destination-out, hợp lý vì dưới lớp mực là ảnh của app. Ở đây dưới lớp mực là chữ in của tờ giấy, nên khoét pixel là khoét mất luôn nhãn 体温 in sẵn, và ảnh xuất ra là một tờ giấy bị đục lỗ. Chế độ 'erase' bỏ hẳn nét khỏi mảng — và nét mang giá trị thì giá trị chết theo, để màn hình và getValues() không bao giờ nói hai chuyện khác nhau.

Đo theo từng đoạn của nét, không theo khung bao: khung bao của một chữ ký dài phủ gần hết dòng, tẩy ở góc trống của khung đó mà mất cả chữ ký thì không ai hiểu vì sao.

Props của <PaperForm>

| Prop | Kiểu | Mặc định | Việc của nó | | --- | --- | --- | --- | | schema | SheetSchema | — | Mô tả tờ giấy: dòng, cột, chỗ để điền | | mode | 'tap' \| 'draw' \| 'erase' | — | Chế độ, có kiểm soát | | defaultMode | 'tap' \| 'draw' \| 'erase' | 'tap' | Chế độ ban đầu, không kiểm soát | | onModeChange | (mode) => void | — | Người dùng đổi chế độ trên thanh | | showToolbar | boolean | true | Thanh công cụ gắn sẵn ở mép tờ giấy | | collapsible | boolean | true | Gắn cái tai đóng/mở vào thanh | | toolbarOpen / defaultToolbarOpen | boolean | true | Thanh đang mở hay đã gấp | | onToolbarOpenChange | (open) => void | — | Người dùng gấp hoặc mở thanh | | penDraws | boolean | true | Bút cảm ứng viết được cả ở chế độ chạm | | penColor | string | #1F4488 | Màu mực; phải khác hẳn màu chữ in | | penWidth | number | 2.2 | Độ dày ngòi, theo pixel tờ giấy | | eraserRadius | number | 9 | Bán kính đầu tẩy, theo pixel tờ giấy | | variableWidth | boolean | true | Nét đậm nhạt theo lực nhấn / vận tốc | | smoothing | number \| { pen, mouse, touch } | theo con trỏ | Mức khử rung | | taper | boolean | true | Vuốt nhọn hai đầu nét | | palmRejection | boolean | true | Bỏ cú chạm của ngón tay sau khi đã thấy bút | | penOnly | boolean | false | Chỉ nhận bút cảm ứng | | shapes / defaultShapes | Shape[] | [] | Mực, có kiểm soát / không kiểm soát | | onChange | (shapes) => void | — | Mực đổi | | onValuesChange | (values) => void | — | Dữ liệu đọc ra đổi | | fitToWidth | boolean | true | Thu tờ giấy cho vừa bề ngang khung chứa | | zoom / defaultZoom | number | 1 | Mức phóng trên màn hình, 1 = vừa khung (kiểm soát / không kiểm soát) | | onZoomChange | (zoom: number) => void | — | Người dùng bấm nút thu phóng | | minZoom / maxZoom | number | 0.25 / 4 | Chặn hai đầu | | showZoom | boolean | theo showToolbar | Hiện nút thu phóng cạnh thanh công cụ | | pageHeight | number \| 'a4' | — | Cắt tờ giấy thành trang cao chừng này, luôn cắt ở ranh giới dòng. 'a4' = bề rộng × 297/210. Bỏ trống thì đọc schema.pageHeight; vẫn trống thì một tờ liền | | maxPageColumns | number | 1 | Trần số trang xếp cạnh nhau khi thu nhỏ | | pageColumns | number | — | Chốt cứng số cột, bỏ qua phép đo | | pageGap | number | 24 | Khoảng giữa hai trang, px màn hình | | showPageLabels | boolean | khi có nhiều hơn 1 trang | In số trang ở góc mỗi trang | | textPhrases | (fieldId) => (() => TextPhrase[] \| Promise<TextPhrase[]>) \| null | — | Câu mẫu (定型文) cho ô chữ: trả hàm tải thì ô đó có nút 定型文, null thì không | | showHitAreas | boolean | false | Tô màu vùng chạm — chỉ để soi lúc dựng giao diện |

Qua ref: getShapes, setShapes, getValues, getLayout (bố cục đang hiện), getBaseLayout (bố cục của toạ độ lưu), getPages, undo, clear, toCanvas, toDataURL, toBlob, download.

Xem thử bằng tay: npm run build:paper-preview rồi mở docs/paper-form.html, hoặc npm run try:consumer để chạy trong một project React thật.

Bút cảm ứng và bàn tay

Tì cổ tay lên màn hình là thói quen viết trên giấy. palmRejection (mặc định bật) bỏ qua ngón tay và lòng bàn tay ngay khi đã thấy bút cảm ứng một lần trong phiên; nếu lòng bàn tay chạm xuống trước và đã kịp để lại vệt, vệt đó bị vứt đi lúc đầu bút thật sự chạm tới. penOnly thì bỏ hẳn chuột và ngón tay.

Bảng vẽ nối vào máy tính để bàn

Chuột vốn đã vẽ được, không có gì chặn. Vấn đề nằm chỗ khác: bảng vẽ Wacom hay màn hình bút cắm vào máy tính, tuỳ đời driver mà khai pointerType là 'pen' hoặc 'mouse', nên trình duyệt không phân biệt nổi bảng vẽ với con chuột thật. Mà hai thứ đó cần đối xử ngược nhau: chuột giật nấc nên phải lọc mạnh, còn chữ viết bằng bảng vẽ thì lọc mạnh là mất nét.

Chỉ người đang cầm thiết bị mới biết tay mình cầm cái gì, nên đây là công tắc chứ không phải thứ đi đoán:

<ImageAnnotator src="/to-khai.png" penTablet={laBangVe} onPenTabletChange={setLaBangVe} />

Bật penTablet thì đầu vào khai là chuột sẽ dùng mức khử rung của bút thay vì của chuột. Thanh công cụ có sẵn nút bật/tắt để người dùng tự lật; truyền showPenTabletToggle={false} để ẩn nút đó đi và tự điều khiển bằng prop. defaultPenTablet đặt giá trị khởi đầu ở chế độ không kiểm soát.

Bút cảm ứng khai đúng mình là 'pen' thì vốn đã được đối xử như bút rồi, công tắc này không đổi gì cho nó.

Mức khử rung theo thiết bị

Mức khử rung nên khác nhau theo từng loại con trỏ, nên smoothing nhận cả object:

<ImageAnnotator smoothing={{ pen: 0.25, mouse: 0.6, touch: 0.5 }} />

Đây cũng là mặc định. Bút cảm ứng lấy mẫu dày và sạch, mà chi tiết thật của chữ viết — dấu ngã, dấu hỏi, đỉnh nhọn của chữ — nằm đúng ở dải tần bộ lọc cắt đi, nên phải lọc nhẹ. Chuột thì giật nấc và lấy mẫu thưa hơn hẳn nên cần lọc mạnh. Truyền một con số thì áp cho mọi loại con trỏ như trước.

Props

| Prop | Kiểu | Mặc định | Mô tả | | --- | --- | --- | --- | | src | string \| File \| Blob | — | Ảnh nền | | defaultShapes | Shape[] | [] | Nét vẽ nạp sẵn | | tool / defaultTool | ToolType | 'pen' | Công cụ đang chọn (có kiểm soát / không kiểm soát) | | color / defaultColor | string | '#ff3b30' | Màu nét | | strokeWidth / defaultStrokeWidth | number | 4 | Độ dày nét, tính theo pixel màn hình | | fontSize | number | 22 | Cỡ chữ công cụ Text, theo pixel màn hình | | minStrokeWidth / maxStrokeWidth | number | 0.5 / 30 | Giới hạn thanh trượt độ dày. Các nút cỡ ngòi sẵn (1 / 2 / 4 / 8 / 16 px) chỉ hiện những cỡ nằm trong khoảng này | | variableWidth | boolean | true | Nét dày mỏng theo lực nhấn | | smoothing | number \| { pen?, mouse?, touch? } | { pen: 0.25, mouse: 0.6, touch: 0.5 } | Mức khử rung, 0 đến 1 | | taper | boolean | true | Vuốt nhọn hai đầu nét | | dynamics | number | tự tính | Mức nhấn nhá 0 đến 1, mặc định suy theo độ dày ngòi | | paper | false \| 'plain' \| 'ruled' \| 'grid' | false | Hiển thị như tờ giấy kẹp trên bảng | | deskColor | string | — | Màu mặt bàn sau tờ giấy | | blank | BlankPaper | — | Tạo giấy trắng để vẽ khi không có src | | colors | string[] | 6 màu | Bảng màu trên thanh công cụ | | tools | ToolType[] | tất cả | Công cụ hiển thị | | showToolbar | boolean | true | Ẩn đi nếu bạn tự dựng thanh công cụ | | toolbarSections | ToolbarSection[] | đủ nhóm | Nhóm nào hiện và theo thứ tự nào: 'tools', 'colors', 'sizes', 'history', 'penTablet', 'extra' | | toolbarExtra | ReactNode | — | Nút riêng của app, cắm vào chỗ 'extra' | | renderToolbar | (props: ToolbarProps) => ReactNode | — | Thay hẳn thanh công cụ bằng component của app, nhận đủ props để chạy | | floatingToolbar | boolean | theo paper | Treo hai thanh ở mép khung thay vì xếp bên ngoài | | showExportBar | boolean | true | Thanh xuất ảnh: chọn ảnh khác, định dạng, cỡ, mức nén, nút tải | | floatingExportBar | boolean | theo floatingToolbar | Treo thanh xuất ảnh ở mép khung | | exportFormats | ExportFormat[] | PNG / JPEG / WEBP | Các định dạng bày trên thanh xuất ảnh | | collapsible | boolean | true | Gắn cái tai đóng/mở cho mỗi thanh | | toolbarOpen / defaultToolbarOpen | boolean | true | Thanh công cụ đang mở hay đóng (kiểm soát / tự quản lý) | | onToolbarOpenChange | (open: boolean) => void | — | Bắn khi bấm cái tai của thanh công cụ | | exportBarOpen / defaultExportBarOpen | boolean | false | Thanh xuất ảnh đang mở hay đóng (kiểm soát / tự quản lý) | | onExportBarOpenChange | (open: boolean) => void | — | Bắn khi bấm cái tai của thanh xuất ảnh | | boardColor | string | '#8d6d4a' | Màu ván ép của bảng kẹp | | zoom / defaultZoom | number | 1 | Mức phóng trên màn hình, 1 = vừa khung (kiểm soát / tự quản lý) | | onZoomChange | (zoom: number) => void | — | Bắn khi bấm nút thu phóng | | minZoom / maxZoom | number | 0.25 / 4 | Giới hạn thu phóng | | showZoom | boolean | true | Hiện bộ nút thu phóng ở góc khung | | maxWidth / maxHeight | number | — | Giới hạn kích thước hiển thị | | allowUpscale | boolean | false | Cho phép phóng ảnh nhỏ vượt kích thước gốc | | fields | FormField[] | [] | Các ô của tờ form, toạ độ theo ảnh gốc | | showFieldHints | boolean | true | Viền mờ đánh dấu ô, không đi vào ảnh xuất ra | | activeFieldId / onActiveFieldChange | string \| null | — | Ô đang mở ô nhập (có kiểm soát / không kiểm soát) | | palmRejection | boolean | true | Bỏ qua bàn tay khi đã thấy bút cảm ứng | | penOnly | boolean | false | Chỉ nhận bút cảm ứng | | penTablet / defaultPenTablet | boolean | false | Coi đầu vào chuột là bảng vẽ (có kiểm soát / không kiểm soát) | | onPenTabletChange | (on: boolean) => void | — | Gọi khi nút trên thanh công cụ được lật | | showPenTabletToggle | boolean | true | Hiện nút chế độ bảng vẽ trên thanh công cụ | | allowDrop | boolean | true | Kéo thả ảnh vào khung | | enableShortcuts | boolean | true | Phím tắt hoàn tác / làm lại | | crossOrigin | 'anonymous' \| 'use-credentials' | 'anonymous' | Dùng cho ảnh khác domain | | exportOptions | ExportOptions | — | Tuỳ chọn xuất mặc định | | onChange | (shapes: Shape[]) => void | — | Gọi mỗi khi danh sách nét thay đổi | | onImageLoad | (img: HTMLImageElement) => void | — | Ảnh nạp xong | | onError | (err: Error) => void | — | Lỗi nạp ảnh | | labels | Partial<AnnotatorLabels> | tiếng Nhật | Mọi chữ hiện ra màn hình; chỉ cần truyền khoá muốn đổi | | theme | 'dark' \| 'light' | 'dark' | Bảng màu của thanh công cụ và các bảng điều khiển | | injectStyles | boolean | true | Tắt đi nếu app tự nạp annotatorCss (trang có CSP chặt) | | className / style | | — | Tuỳ biến khung ngoài |

ExportOptions: { type?: string; quality?: number; scale?: number; backgroundColor?: string; maxPixels?: number }.

ExportFormat: { type: string; label: string }. Người dùng chọn gì trên thanh xuất ảnh thì cái đó đè lên exportOptions cho cả mấy hàm gọi qua ref, nên toBlob() không tham số trả về đúng định dạng đang hiện trên màn hình. Tham số truyền thẳng vào lời gọi vẫn thắng cả hai.

maxPixels đặt trần số điểm ảnh cho ảnh xuất ra; vượt trần thì tỉ lệ tự hạ cho vừa. Trình duyệt trên di động từ chối cấp bộ nhớ cho canvas quá lớn và trả về ảnh trắng trơn mà không báo lỗi, nên với ảnh chụp từ máy ảnh hiện đại thì maxPixels: 16777216 là mức an toàn.

API qua ref

ref.current.toDataURL(options?)          // string
ref.current.toBlob(options?)             // Promise<Blob | null>
ref.current.toFile('ten.png', options?)  // Promise<File | null>
ref.current.download('ten.png')          // tải về máy
ref.current.loadImage(fileHoacUrl)       // đổi ảnh nền
ref.current.undo() / redo() / clear()   // clear() cũng hoàn tác được
ref.current.getShapes() / setShapes(shapes)
ref.current.getCanvas() / getImage()
ref.current.getFilledFields()          // id các ô đã có nội dung
ref.current.focusField('ten')          // mở ô nhập tại một ô, null để đóng

Tuỳ biến thanh công cụ

Bốn mức, từ tỉa bớt thanh có sẵn tới thay hẳn nó. Mức nào cũng giữ nguyên phần bạn không đụng tới.

Bớt nhóm và xếp lại thứ tự. Thanh công cụ ghép từ những nhóm có tên; liệt kê nhóm nào muốn giữ, theo thứ tự muốn bày. Vạch ngăn đi theo nhóm: nhóm không có gì để hiện thì vạch của nó cũng biến mất, nên thanh không bao giờ mở đầu hay kết thúc bằng một cái gạch cụt.

<ImageAnnotator src={src} toolbarSections={['tools', 'history']} />

Cắm nút của mình vào. toolbarExtra rơi vào chỗ 'extra', mặc định nằm cuối thanh — đưa 'extra' lên trước trong toolbarSections là nó đổi chỗ.

<ImageAnnotator
  src={src}
  toolbarExtra={<button type="button" onClick={save}>保存</button>}
  toolbarSections={['tools', 'extra', 'colors', 'sizes', 'history']}
/>

Thay hẳn thanh, giữ nguyên phần đấu nối. renderToolbar nhận đúng bộ props mà <Toolbar> gắn sẵn vẫn nhận — công cụ, màu, độ dày đang chọn, trạng thái hoàn tác/làm lại, và tất cả hàm gọi lại — nên component của app chạy đủ chức năng mà không phải nâng state lên ngoài. Nó vẫn nằm trong cái tai đóng/mở như thanh gốc. Rải props vào <Toolbar> để dùng lại phần lớn thanh cũ, và TOOL_ICONS để dùng lại bộ icon.

import { ImageAnnotator, Toolbar, TOOL_ICONS } from '@sann-tech/react-image-markup';

<ImageAnnotator
  src={src}
  renderToolbar={(props) => (
    <div className="my-bar">
      <button type="button" onClick={props.onUndo} disabled={!props.canUndo}>
        {props.labels.undo}
      </button>
      <button type="button" onClick={() => props.onToolChange('pen')}>
        {TOOL_ICONS.pen}
      </button>
      <Toolbar {...props} sections={['colors', 'sizes']} />
    </div>
  )}
/>

Bỏ hẳn thanh công cụ. Đặt showToolbar={false} rồi tự kiểm soát công cụ từ bên ngoài:

const [tool, setTool] = useState<ToolType>('arrow');

<ImageAnnotator
  ref={ref}
  src={src}
  showToolbar={false}
  tool={tool}
  color="#0a84ff"
  strokeWidth={6}
/>

Nhãn hiển thị

Mọi chữ hiện ra màn hình mặc định là tiếng Nhật và đều đi qua labels; chỉ cần truyền khoá muốn đổi, phần còn lại giữ nguyên. Chuỗi nào ghép từ một con số hay một nhãn khác thì là hàm, nên trật tự từ do bạn quyết định.

import { DEFAULT_LABELS } from '@sann-tech/react-image-markup';

<ImageAnnotator src={src} labels={{ tools: { pen: 'Bút vẽ' }, undo: 'Hoàn tác' }} />

<PaperForm> có bảng nhãn riêng (PaperFormLabels, cũng mặc định tiếng Nhật) cho thanh công cụ và bàn phím của tờ giấy. Còn nội dung tờ giấy — nhãn dòng, tên lựa chọn, đơn vị — là dữ liệu đến từ schema, không phải chuỗi dịch.

Tích hợp vào web có sẵn

Next.js (App Router) — component dùng window và canvas nên phải chạy phía client:

'use client';
import { ImageAnnotator } from '@sann-tech/react-image-markup';

Hoặc const ImageAnnotator = dynamic(() => import('@sann-tech/react-image-markup').then(m => m.ImageAnnotator), { ssr: false }).

Trang không dùng React — gắn vào một thẻ div bất kỳ:

import { createRoot } from 'react-dom/client';
import { createElement } from 'react';
import { ImageAnnotator } from '@sann-tech/react-image-markup';

createRoot(document.getElementById('editor')).render(
  createElement(ImageAnnotator, { src: '/anh-goc.jpg' })
);

Lưu ý về CORS

Nếu ảnh nền nằm ở domain khác, server phải trả về header Access-Control-Allow-Origin phù hợp. Thiếu header này, canvas bị đánh dấu "tainted" và trình duyệt sẽ chặn toDataURL / toBlob với lỗi SecurityError. Ảnh từ File/Blob và data URL không gặp vấn đề này.

Khi xuất JPEG, nên truyền backgroundColor: '#fff' vì JPEG không có kênh alpha:

ref.current.toBlob({ type: 'image/jpeg', quality: 0.9, backgroundColor: '#ffffff' });

Cấu trúc repo

src/
  ImageAnnotator.tsx   Component chính: canvas, con trỏ, lịch sử, API qua ref
  Toolbar.tsx          Thanh công cụ mặc định
  drawing.ts           Vẽ từng shape lên context + type guard
  stroke.ts            Khử rung, lấy mẫu lại, dựng đường bao nét biến thiên
  compose.ts           Ghép ảnh gốc với lớp nét vẽ, nạp ảnh, xuất blob
  styles.ts            CSS tự nhúng vào <head>
  types.ts             Shape, ToolType, FormField, ExportOptions, handle
  index.ts             Điểm export
example/
  main.tsx             Trang demo cho `npm run dev`
  standalone.tsx       Bản demo đóng gói kèm React
  standalone.html      Khung HTML cho bản đóng gói
scripts/
  build-demo.mjs       Nhúng bundle vào docs/demo.html
  stroke-test.mjs      Canh thuật toán dựng nét
  shape-test.mjs       Canh lớp vẽ shape: tẩy, chữ bóp vừa ô, khung bao
  browser-test.mjs     Canh luồng điền form và chống tì tay trong Chrome thật
  smoke-test.mjs       Mở trang demo trong jsdom để bắt lỗi trắng trang
  consumer-test.mjs    Cài gói đã đóng vào một app Vite + React thật
docs/demo.html         Demo một file, mở trực tiếp là chạy
npm run typecheck    # kiểu
npm test             # thuật toán nét vẽ + lớp vẽ shape, thuần Node
npm run build:demo   # dựng lại docs/demo.html, kèm smoke test bằng jsdom
npm run test:browser # luồng điền form + chống tì tay, trong Chrome thật
npm run test:consumer # gói đã đóng, trong một project React thật

npm test chạy các bộ test thuần Node. npm run test:browser cần Chrome (hoặc npx playwright install chromium) và là bộ duy nhất chạm được tới focus, thứ tự sự kiện con trỏ và ảnh thật sự xuất ra.

Mở gói trên trình duyệt để bấm thử bằng tay

npm run try:consumer                  # dựng project React thật rồi mở dev server
npm run try:consumer -- --react 19
npm run try:consumer -- --port 5300
npm run try:consumer -- --no-open     # không tự mở trình duyệt

Nó npm pack, dựng app Vite + React + TS mới, cài gói vào, chạy vite (có hot reload) rồi mở trình duyệt mặc định. Trang đó có cả hai component dùng thật: tờ giấy kẻ để vẽ bằng bút, hai ô fields để gõ chữ, tờ giấy sinh từ schema để khoanh/tích, và khối JSON getValues() cập nhật ngay dưới. Ctrl+C để dừng và dọn thư mục tạm; thêm --keep nếu muốn giữ project lại để mở bằng editor.

Muốn xem Chrome tự chạy bộ test thay vì chạy ẩn:

npm run test:consumer -- --headed

Chạy thử gói trong project React thật

Mọi bộ test khác chạy trên mã nguồn. Bộ này chạy trên thứ sẽ phát hành: npm pack, dựng một app Vite + React + TS mới trong thư mục tạm, cài gói vào rồi bắt app đó dùng thật.

npm run test:consumer                   # React 18, đủ 5 bước
npm run test:consumer -- --react 19     # peer range khai >=17 nên thử luôn 19
npm run test:consumer -- --skip-build   # dùng dist/ đang có
npm run test:consumer -- --keep         # giữ project tạm lại để mở xem
npm run test:consumer -- --skip-browser

Năm thứ nó soi, đều là thứ chạy trên mã nguồn không bao giờ thấy:

  1. tsc --noEmit từ phía app, skipLibCheck: false — nên .d.ts trong gói cũng bị soi
  2. import và require theo tên gói ở Node trần, không có DOM — gói mà chạm ngay vào document thì lộ ra
  3. Bản ESM và bản CJS phải xuất đúng cùng một bộ tên
  4. Vite gom được cả hai điểm vào (. và ./paper-form) qua bảng exports
  5. Chrome thật: mount, chạm vào một lựa chọn, getValues() đọc lại đúng, toDataURL() ra ảnh thật

Dọn các tệp sinh ra (dist, demo-dist, preview-dist, *.tgz, paper-preview.png, .DS_Store):

npm run clean

Phát triển

npm install
npm run dev         # trang demo có hot reload
npm run typecheck   # kiểm tra kiểu
npm run build       # xuất dist/ (ESM + CJS + .d.ts)
npm run build:demo  # dựng lại docs/demo.html rồi chạy smoke test
npm run smoke       # mở docs/demo.html trong jsdom, bắt lỗi trắng trang
npm run test:stroke # test hồi quy thuật toán nét vẽ
npm run clean       # xoá dist/, demo-dist/, preview-dist/, *.tgz, ảnh xem thử

CI trên GitHub Actions chạy typecheck, các bộ test (kể cả test:consumer với React 18 và 19) và build cho mỗi push và pull request.

Giấy phép

MIT