פעם סטארטאפ פנה אלינו וביקש להטמיע הערות PDF באפליקציית ווב. הם השתמשו בשכבת SVG וחוו פיגור במסמך בן 200 עמודים — כל משיכת קו לקחה שנייה להגיב. העברנו אותם ל-Canvas עם מצב retained (Fabric.js) — ומהירות העיבוד גדלה פי 8, והמשתמשים הפסיקו להתלונן על פיגור. במשך 5+ שנים, יישמנו מעל 50 כלי Canvas: ממברשות פשוטות ועד עורכי דיאגרמות מלאים.
ללא קשר לתרחיש — ציור חופשי, הערות מסמכים או דיאגרמות — הארכיטקטורה הבסיסית מסתכמת בבחירה בין מצב מיידי למצב retained. לכל גישה יש את הפשרות שלה, ואנחנו נעזור לכם לבחור את המתאימה למשימות שלכם.
אתגרים טכניים בפיתוח כלי Canvas
ציור חופשי עם רגישות ללחץ. לא כל הדפדפנים מעבירים נכון לחץ של עט — אנחנו צריכים לחקות את רוחב הקו דרך מהירות העט. ב-80% מהמקרים, הבעיה נפתרת ביום: מוסיפים polyfill ל-Pointer Events ומכיילים את האלגוריתם למכשירים ספציפיים (Windows Ink, iPad, Wacom).
הערות PDF. קואורדינטות ההערות חייבות להיות מקושרות לעמוד, לא למסך. בעת זום וגלילה, המיקומים מחושבים מחדש ביחס לקנה המידה הנוכחי. אנחנו משתמשים בשכבות scalable ומיירטים אירועי transform ב-Konva.js.
אימות ייצוא. ההערות חייבות לחפוף במדויק למקור. אנחנו קודם מרנדרים את ה-PDF כתמונה, ואז מרכיבים את ה-Canvas עם ההערות — זה מבטל פערי DPI.
למה מצב Retained עדיף להערות?
מצב מיידי (Canvas 2D טהור) מהיר אבל דורש בדיקת hit-testing ידנית וניהול סצנה. מצב retained (Fabric.js, Konva.js) מספק מודל אובייקטים: כל צורה היא אובייקט שניתן לבחור, להזיז או לשנות. לעורכים עם ביטול/חזרה וייצוא, מצב retained מקצר את זמן הפיתוח בחצי. בנוסף, מצב retained נוח יותר לעבודת צוות: שינויים במודל האובייקטים מסונכרנים בקלות דרך WebSocket — מה שמאפשר ציור שיתופי מקוון.
| מאפיין | מצב מיידי | מצב retained |
|---|---|---|
| ביצועים | גבוהים (עומס נמוך) | בינוניים (תלוי במספר האובייקטים) |
| מודל אובייקטים | אין, נכתב ידנית | מובנה (צורות, קבוצות) |
| ביטול/חזרה | מסורבל (מחרוזות פיקסלים) | פשוט (תמונות מצב של אובייקטים) |
| בדיקת hit-testing | חישוב קואורדינטות | מובנה (מבוסס על תיבות חוסמות) |
| מתאים ל | ציור חופשי, מברשות, קווים אלסטיים | צורות, טקסט, בחירת אובייקטים |
איך אנחנו מיישמים הערות עם Konva.js
אנחנו משתמשים ב-Konva.js 9 עם React-Konva. כל הערה היא אובייקט מסוג Rect, Line, Circle, Text או Arrow. המשתמש בוחר כלי ומצייר על הבמה — האובייקט מתווסף למערך ומעובד. בחירה, הזזה ועריכה מובנים בתוך המערכת.
import { Stage, Layer, Line, Rect, Circle, Text, Transformer } from 'react-konva';
import Konva from 'konva';
type AnnotationType = 'line' | 'rect' | 'circle' | 'arrow' | 'text';
interface Annotation {
id: string;
type: AnnotationType;
points?: number[];
x?: number;
y?: number;
width?: number;
height?: number;
text?: string;
color: string;
}
function AnnotationTool({ backgroundImage }: { backgroundImage: string }) {
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeTool, setActiveTool] = useState<AnnotationType>('rect');
const [isDrawing, setIsDrawing] = useState(false);
const stageRef = useRef<Konva.Stage>(null);
function getRelativePosition() {
const stage = stageRef.current!;
const pos = stage.getPointerPosition()!;
return { x: pos.x, y: pos.y };
}
function handleMouseDown() {
setSelectedId(null);
const pos = getRelativePosition();
const newAnnotation: Annotation = {
id: crypto.randomUUID(),
type: activeTool,
color: '#ef4444',
x: pos.x,
y: pos.y,
width: 0,
height: 0,
};
if (activeTool === 'line') {
newAnnotation.points = [pos.x, pos.y, pos.x, pos.y];
}
setAnnotations((prev) => [...prev, newAnnotation]);
setIsDrawing(true);
}
function handleMouseMove() {
if (!isDrawing) return;
const pos = getRelativePosition();
const lastIndex = annotations.length - 1;
const last = annotations[lastIndex];
const updated = { ...last };
if (activeTool === 'line') {
updated.points = [last.points![0], last.points![1], pos.x, pos.y];
} else {
updated.width = pos.x - last.x!;
updated.height = pos.y - last.y!;
}
setAnnotations((prev) => [...prev.slice(0, lastIndex), updated]);
}
function handleMouseUp() {
setIsDrawing(false);
}
return (
<Stage
ref={stageRef}
width={800}
height={600}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<Layer>
{annotations.map((ann) => {
if (ann.type === 'rect') {
return (
<Rect
key={ann.id}
x={ann.x}
y={ann.y}
width={ann.width}
height={ann.height}
stroke={ann.color}
strokeWidth={2}
fill="transparent"
onClick={() => setSelectedId(ann.id)}
draggable={selectedId === ann.id}
/>
);
}
if (ann.type === 'line') {
return (
<Line
key={ann.id}
points={ann.points}
stroke={ann.color}
strokeWidth={2}
lineCap="round"
/>
);
}
return null;
})}
</Layer>
</Stage>
);
} איך ליישם ביטול/חזרה בעורך Canvas?
לביטול וחזרה אנחנו משתמשים במחסנית מצבים. כל פעולה יוצרת תמונת מצב של ההערות הנוכחיות ומכניסה אותה להיסטוריה. נתמך branch — אם מתבצעת פעולה חדשה לאחר ביטול, ההיסטוריה נחתכת.
function useUndoRedo<T>(initialState: T) {
const [history, setHistory] = useState<T[]>([initialState]);
const [cursor, setCursor] = useState(0);
const current = history[cursor];
function push(newState: T) {
// Обрезаем историю после текущей позиции (ветвление)
const newHistory = [...history.slice(0, cursor + 1), newState];
setHistory(newHistory);
setCursor(newHistory.length - 1);
}
function undo() {
if (cursor > 0) setCursor((c) => c - 1);
}
function redo() {
if (cursor < history.length - 1) setCursor((c) => c + 1);
}
return { current, push, undo, redo, canUndo: cursor > 0, canRedo: cursor < history.length - 1 };
} תהליך פיתוח כלי Canvas
| שלב | תיאור | לוח זמנים |
|---|---|---|
| ניתוח | לימוד תרחישים: ציור חופשי, הערות, דיאגרמות. קביעת פורמט serialization (JSON, SVG). | יום אחד |
| עיצוב | בחירת מחסנית (Canvas 2D, Konva, Fabric). עיצוב מודל אובייקטים ומבנה שכבות. | 1-2 ימים |
| יישום | פיתוח כלים (מברשת, צורות, טקסט). שילוב ביטול/חזרה, ייצוא, תמיכה בעט. | 3-5 ימים |
| בדיקות | בדיקות על מכשירים שונים (iPad, Windows, Android). תיקון באגים באירועי מגע ולחץ. | 1-2 ימים |
| פריסה | תיעוד API, הגדרת build, הדרכת צוות הלקוח. | יום אחד |
איך אנחנו מייצאים את התמונה עם ההערות
הרקע (PDF או תמונה) מעובד כשכבה הראשונה, ההערות כשכבה השנייה. לאחר מכן, כל קנבס הבמה מומר ל-Blob דרך import { Stage, Layer, Line, Rect, Circle, Text, Transformer } from 'react-konva'; import Konva from 'konva'; type AnnotationType = 'line' | 'rect' | 'circle' | 'arrow' | 'text'; interface Annotation { id: string; type: AnnotationType; points?: number[]; x?: number; y?: number; width?: number; height?: number; text?: string; color: string; } function AnnotationTool({ backgroundImage }: { backgroundImage: string }) { const [annotations, setAnnotations] = useState<Annotation[]>([]); const [selectedId, setSelectedId] = useState<string | null>(null); const [activeTool, setActiveTool] = useState<AnnotationType>('rect'); const [isDrawing, setIsDrawing] = useState(false); const stageRef = useRef<Konva.Stage>(null); function getRelativePosition() { const stage = stageRef.current!; const pos = stage.getPointerPosition()!; return { x: pos.x, y: pos.y }; } function handleMouseDown() { setSelectedId(null); const pos = getRelativePosition(); const newAnnotation: Annotation = { id: crypto.randomUUID(), type: activeTool, color: '#ef4444', x: pos.x, y: pos.y, width: 0, height: 0, }; if (activeTool === 'line') { newAnnotation.points = [pos.x, pos.y, pos.x, pos.y]; } setAnnotations((prev) => [...prev, newAnnotation]); setIsDrawing(true); } function handleMouseMove() { if (!isDrawing) return; const pos = getRelativePosition(); const lastIndex = annotations.length - 1; const last = annotations[lastIndex]; const updated = { ...last }; if (activeTool === 'line') { updated.points = [last.points![0], last.points![1], pos.x, pos.y]; } else { updated.width = pos.x - last.x!; updated.height = pos.y - last.y!; } setAnnotations((prev) => [...prev.slice(0, lastIndex), updated]); } function handleMouseUp() { setIsDrawing(false); } return ( <Stage ref={stageRef} width={800} height={600} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} > <Layer> {annotations.map((ann) => { if (ann.type === 'rect') { return ( <Rect key={ann.id} x={ann.x} y={ann.y} width={ann.width} height={ann.height} stroke={ann.color} strokeWidth={2} fill="transparent" onClick={() => setSelectedId(ann.id)} draggable={selectedId === ann.id} /> ); } if (ann.type === 'line') { return ( <Line key={ann.id} points={ann.points} stroke={ann.color} strokeWidth={2} lineCap="round" /> ); } return null; })} </Layer> </Stage> ); } . HiDPI נלקח בחשבון: מכפילים את הממדים ב-function useUndoRedo<T>(initialState: T) { const [history, setHistory] = useState<T[]>([initialState]); const [cursor, setCursor] = useState(0); const current = history[cursor]; function push(newState: T) { // Обрезаем историю после текущей позиции (ветвление) const newHistory = [...history.slice(0, cursor + 1), newState]; setHistory(newHistory); setCursor(newHistory.length - 1); } function undo() { if (cursor > 0) setCursor((c) => c - 1); } function redo() { if (cursor < history.length - 1) setCursor((c) => c + 1); } return { current, push, undo, redo, canUndo: cursor > 0, canRedo: cursor < history.length - 1 }; } .
function exportAnnotated(stage: Konva.Stage, bgImage: HTMLImageElement) {
const canvas = document.createElement('canvas');
canvas.width = stage.width();
canvas.height = stage.height();
const ctx = canvas.getContext('2d')!;
// Сначала фон
ctx.drawImage(bgImage, 0, 0);
// Поверх — аннотации из Konva
const stageCanvas = stage.toCanvas();
ctx.drawImage(stageCanvas, 0, 0);
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob!);
const a = document.createElement('a');
a.href = url;
a.download = 'annotated.png';
a.click();
URL.revokeObjectURL(url);
});
} רשימת בדיקה של טעויות נפוצות בפיתוח כלי Canvas
-
toBlob()לא מוגדר — אירועי מגע מיורטים על ידי הדפדפן. - קואורדינטות לא מכוילות מחדש בעת שינוי גודל חלון — השתמשו ב-handler לשינוי גודל עם חישוב קנה מידה מחדש.
- ביטול/חזרה מאחסן מצבים מלאים (תמונות מצב מלאות) — למסמכים גדולים הזיכרון יכול לגדול; בסביבת production השתמשו בתבנית Command עם closures.
- ייצוא ל-PNG ללא התחשבות ב-devicePixelRatio — התמונה מטושטשת ב-Retina.
-
window.devicePixelRatioאירוע לא מטופל — הפרעה למחווה משאירה שארית.
לוח זמנים ומה כלול
לוח זמנים: כלי בסיסי עם מברשת וצורות — מ-3 עד 5 ימים. הערות PDF עם שמירת קואורדינטות — מ-7 עד 10 ימים. המחיר מחושב באופן אישי לאחר ניתוח המפרט הטכני שלכם. בזכות אופטימיזציה של הארכיטקטורה, תוכלו לחסוך עד 40% מזמן הפיתוח.
מה כלול:
- קוד מקור עם הערות
- תיעוד API (מתודות ואירועים עיקריים)
- הוראות פריסה
- שעתיים הדרכה לצוות הלקוח
- אחריות ל-30 יום על תיקוני באגים
למה לבחור בנו: 5+ שנות ניסיון בפיתוח פתרונות Canvas, 50+ פרויקטים מיושמים, מפתחי React ו-Node.js מוסמכים. צרו קשר לייעוץ על ארכיטקטורת Canvas — נגיב תוך יום.
להשוואה: Canvas API הוא חלק מתקן ה-HTML Living Standard של WHATWG, מצב retained הוא פרדיגמת תכנות גרפית.







