Приклад інструменту для малювання (не хвилюйтеся, ми не будемо створювати це повністю)
Вступ
Кілька тижнів тому мені потрібно було створити інструмент для малювання від руки на полотні для додатку дизайнерів на Fiverr, розробленого на SvelteKit. Оскільки вимоги були дещо складними і підлягали змінам, я вагався використовувати npm-пакет, оскільки він міг би не повністю відповідати потребам клієнта.
Замість того, щоб вибирати невизначений шлях, я обрав більш складний шлях і вирішив спочатку створити швидкий MVP самостійно, слідуючи статтям Харрісона Мілбрадта: Дослідження малювання від руки на HTML-канвас і Панорамування та масштабування в HTML-канвас.
Я настійно рекомендую прочитати їх — вони надзвичайно пізнавальні.
Цей посібник поєднує код з обох статей і доповнює його додатковою функціональністю. Зміст цього посібника наступний:
- Мета: У цьому розділі описується, що ми маємо намір створити, зокрема короткий опис додаткових потенційних функцій, деякі з яких розглядаються в статтях Харрісона.
- Реалізація: Тут ми обговорюємо технічну суть нашого рішення, даючи загальне уявлення про підхід перед тим, як заглиблюватися в деталі.
- Детальний посібник: Ця частина складається з шести кроків: створення двох полотен, малювання фону на фоні канваса, реалізація малювання та стирання, реалізація панорамування та реалізація масштабування.
Мета:
Метою є створити полотно з уже намальованим на ньому зображенням.
Поверх цього зображення дизайнер повинен мати можливість вільно малювати лінії та стирати будь-які попередні малюнки за допомогою інструменту для стирання.
Крім того, дизайнер повинен мати можливість збільшувати зображення та панорамувати по ньому, що дозволить зосередитися на конкретних частинах і взаємодіяти з інструментом малювання.
Цю функцію можна розширити, включивши можливості, такі як різні кольори ручки, регульовані ширини ліній, індикатор курсору для малювання та можливість малювати фігури. Однак ці вдосконалення легко реалізувати, коли основна функціональність буде налаштована.
Реалізація:
В основі нашого рішення лежать два полотна, які працюють безперервно разом. Полотно для фону відображатиме зображення, на якому ми хочемо малювати, забезпечуючи, щоб малювання та стирання ліній не впливали на пікселі зображення.
Переднє полотно, яке називається полотном для малювання, оброблятиме взаємодії, такі як малювання та стирання ліній.
Обробники подій миші (наприклад, mouseup, mousedown, mousemove та mousewheel) оновлюватимуть контекст полотна залежно від дій користувача. Будь-яке панорамування або масштабування на полотні для малювання повинно точно відображатися на фоні полотна, щоб уникнути спотворення ліній.
Крім того, контексти обох полотен повинні залишатися синхронізованими, щоб будь-які намальовані чи стерті лінії правильно масштабувалися та панорамувалися, зберігаючи візуальну узгодженість і точний результат.
Ця синхронізація є суттю нашого рішення.
Детальний посібник:
Крок 1: Створення 2 полотен
Ми будемо створювати два полотна, як описано нижче:
\
\ \
\ ``` Трохи стилізуємо їх для покращення зовнішнього вигляду та для того, щоб одне полотно накладалося на інше: ``` #drawingCanvas { position: absolute; top: 0; left: 0; z-index: 1; border: 2px solid black; } #backgroundLayerCanvas { border: 2px solid black; background-color: greenyellow; } ``` Результат: ![pic](https://drive.javascript.org.ua/85ba1057bf1_fF2g1L5rty23OGt31UYzNw_png) _Не звертайте увагу на не накладаючі межі. Це була незначна проблема CSS в додатку, створеному для досліджень і розробок, яку я не мав часу виправити, оскільки вона не виникала в основному додатку, над яким я працював._
## Крок 2: Малювання зображення на фоні полотна
Напишемо деякий JavaScript код для отримання елементів у нашому скрипті та малювання випадкового зображення качки на фоні полотна:
const backgroundCanvas = document.getElementById('backgroundLayerCanvas')
const backgroundCtx = backgroundCanvas.getContext('2d')
const canvas = document.getElementById('drawingCanvas')
const ctx = canvas.getContext('2d')
imageObj = new Image();
imageObj.onload = function () {
backgroundCtx.drawImage(imageObj, 0, 0);
}
const duckImageSrc = 'data:image/jpeg;base64,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'
imageObj.src = duckImageSrc; // Це також може бути зображенням, завантаженим користувачем
```
Результат:
Крок 3: Реалізація малювання та стирання ліній на полотні для малювання
Спочатку давайте визначимо функцію drawLine
, яка буде приймати координати x і y для початкових і кінцевих точок для малювання лінії:
const drawLine = (x, y, previousPosX, previousPosY) => {
ctx.beginPath(); // це буде малювання ліній лише на контексті полотна для малювання (ctx)
ctx.moveTo(previousPosX, previousPosY);
ctx.lineTo(x, y);
ctx.strokeStyle = penColor;
ctx.lineWidth = penWidth;
ctx.lineCap = 'round';
ctx.stroke();
}
Аналогічно, давайте визначимо функцію eraseLine
для стирання лінії, яка буде приймати ті ж аргументи, що й функція drawLine
:
const eraseLine = (x, y, previousPosX, previousPosY) => {
ctx.save()
ctx.beginPath()
ctx.moveTo(previousPosX, previousPosY)
ctx.lineTo(x, y)
ctx.globalCompositeOperation = 'destination-out'
ctx.lineWidth = penWidth
ctx.lineCap = 'round'
ctx.stroke()
ctx.restore()
}
Коли ми намагаємося намалювати лінію, ми спочатку викликаємо подію mousedown
один раз за допомогою миші, а потім кілька подій mousemove
, коли ми перетягуємо курсор.
Нам потрібно відслідковувати лише події mousemove
, що відбуваються між подіями mousedown
і mouseup
. Як тільки подія mouseup
відбудеться, ми від'єднаємо прослуховувач подій (Event Listener) mousemove
від полотна.
Перед тим, як визначити ці функції, нам потрібно кілька змінних, щоб відслідковувати координати x і y, які будуть оновлюватися з кожною подією миші.
Ініціалізуємо ці змінні разом з іншими, як показано нижче:
// НОВИЙ КОД
let drawModeType = 'freehand'
let previousXDrawing = 0, previousYDrawing = 0; // це попередні координати X та Y для подій миші на полотні TODO: розв’язати непорозуміння
let lines = [];
let isDrawingMode = true
const penWidth = 5
const penColor = 'red'
// СТАРИЙ КОД
const backgroundCanvas = document.getElementById('backgroundLayerCanvas')
const backgroundCtx = backgroundCanvas.getContext('2d')
const canvas = document.getElementById('drawingCanvas')
const ctx = canvas.getContext('2d')
Тепер давайте визначимо ці функції:
const onMouseDown = (e) => {
// Це моя спроба реалізувати логіку drawLine
if (isDrawingMode) {
var bounding = canvas.getBoundingClientRect();
var x = e.clientX - bounding.left;
var y = e.clientY - bounding.top;
const p = DOMPoint.fromPoint({ x, y }); // Створення DOMPoint для піксельних координат
const t = ctx.getTransform().inverse(); // Отримуємо обернену трансформацію для контексту полотна
const { x: adjustedX, y: adjustedY } = t.transformPoint(p); // Використовуємо для обчислення координат контексту для піксельної точки
previousXDrawing = adjustedX
previousYDrawing = adjustedY
}
canvas.addEventListener("mousemove", onMouseMove);
}
const onMouseMove = (e) => {
if (isDrawingMode) {
var bounding = canvas.getBoundingClientRect();
var x = e.clientX - bounding.left;
var y = e.clientY - bounding.top;
const p = DOMPoint.fromPoint({ x, y }); // Створення DOMPoint для піксельних координат
const t = ctx.getTransform().inverse(); // Отримуємо обернену трансформацію для контексту полотна
const { x: adjustedX, y: adjustedY } = t.transformPoint(p); // Використовуємо для обчислення координат контексту для піксельної точки
const xToDraw = adjustedX
const yToDraw = adjustedY
if (e.shiftKey) {
eraseLine(xToDraw, yToDraw, previousXDrawing, previousYDrawing)
lines.push({ x: xToDraw, y: yToDraw, previousX: previousXDrawing, previousY: previousYDrawing, isEraseLine: true }) // нам потрібно якось запам'ятати, що це стерта лінія.
і обробляємо це таким чином
} else {
if (drawModeType === 'freehand') {
drawLine(xToDraw, yToDraw, previousXDrawing, previousYDrawing)
lines.push({ x: xToDraw, y: yToDraw, previousX: previousXDrawing, previousY: previousYDrawing, isEraseLine: false })
}
}
previousXDrawing = adjustedX
previousYDrawing = adjustedY
}
}
const onMouseUp = (e) => {
canvas.removeEventListener("mousemove", onMouseMove);
}
Тепер давайте підключимо обробники подій до відповідних прослуховувачів подій (Event Listeners):
// Додаємо прослуховувач події mousedown до полотна для малювання
canvas.addEventListener("mousedown", onMouseDown)
// Додаємо прослуховувач події mouseup до полотна для малювання
canvas.addEventListener("mouseup", onMouseUp)
Результат:
Крок 4: Реалізація панорамування на обох полотнах
Перед тим, як продовжити, нам потрібно перемикатися між режимами малювання та перегляду.
Як для панорамування, так і для малювання потрібна подія mousedown
, за якою слідує серія подій mousemove
, що може призвести до конфліктів між цими двома режимами. Найпростіше рішення — це розділити їх на окремі режими:
\
\
\ \
\ \Режим малювання\ \ \
Тепер давайте ініціалізуємо додаткові змінні для окремого відстеження координат x і y для панорамування:
let previousX = 0, previousY = 0; // це попередні координати X та Y для подій миші на полотні
```
Тепер давайте змінемо обробник події mousedown
:
const onMouseDown = (e) => {
// НОВИЙ КОД: Це необхідно для забезпечення плавного панорамування
previousX = e.clientX;
previousY = e.clientY;
// СТАРИЙ КОД:
if (isDrawingMode) {
var bounding = canvas.getBoundingClientRect();
var x = e.clientX - bounding.left;
var y = e.clientY - bounding.top;
const p = DOMPoint.fromPoint({ x, y }); // Створення DOMPoint для піксельних координат
const t = ctx.getTransform().inverse(); // Отримання оберненої трансформації для контексту полотна
const { x: adjustedX, y: adjustedY } = t.transformPoint(p); // Використовуємо для обчислення координат контексту для піксельної точки
previousXDrawing = adjustedX
previousYDrawing = adjustedY
}
}
Для забезпечення узгодженості нам також потрібен viewportTransform
, який відстежуватиме всі трансформації та дії, виконані на полотнах:
const viewportTransform = {
x: 0,
y: 0,
scale: 1
}
Нарешті, давайте змінимо обробник події mousemove
, щоб врахувати панорамування:
const onMouseMove = (e) => {
// СТАРИЙ КОД ...
if (isDrawingMode) {
...
} else { // НОВИЙ КОД ...
updatePanning(e)
render()
}
Необхідне пояснення для цих критичних методів: updatePanning
і render
:
updatePanning
: Ця функція оновлює об'єктviewportTransform
, який відстежує трансформації, застосовані до полотна. Вона гарантує, що при панорамуванні позиції вже намальованих пікселів коректно коригуються. Таким чином, коли ми малюємо нові лінії, вони з'являються точно в тому місці, де знаходиться курсор, враховуючи будь-яке попереднє панорамування.render
: Функціяrender
спочатку скидає обидва полотна, очищаючи раніше намальований вміст. Потім вона встановлює трансформаційну матрицю для полотна для малювання, щоб відобразити будь-які дії, виконані під час панорамування (та масштабування, якщо це застосовно). Далі вона знову малює фонове зображення на фоні полотна і перерисовує раніше намальовані лінії на полотні для малювання.
Пам’ятаєте викликlines.push
, про який ми згадували раніше? Ось чому ми зберігали лінії — щоб точно перерисовувати їх під час кожного рендеру!
const updatePanning = (e) => {
const localX = e.clientX;
const localY = e.clientY;
viewportTransform.x += localX - previousX;
viewportTransform.y += localY - previousY;
previousX = localX;
previousY = localY;
}
const render = () => {
// Код для полотна для малювання
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(viewportTransform.scale, 0, 0, viewportTransform.scale, viewportTransform.x, viewportTransform.y);
// Код для фону полотна
backgroundCtx.setTransform(1, 0, 0, 1, 0, 0);
backgroundCtx.clearRect(0, 0, canvas.width, canvas.height);
backgroundCtx.setTransform(viewportTransform.scale, 0, 0, viewportTransform.scale, viewportTransform.x, viewportTransform.y);
backgroundCtx.drawImage(imageObj, 0, 0);
// Отже, нам ДІЙСНО потрібно перерисувати лінії.
Це означає, що нам ДІЙСНО потрібно ВІДСТЕЖУВАТИ їх!
// Це малювання потребує абсолютних значень, таких самих, як і були, коли лінія малювалася! `viewportTransform` автоматично коригуватиме зміщення тут і там.
lines.forEach((line, idx) => {
if (line.isEraseLine) {
eraseLine(line.x, line.y, line.previousX, line.previousY)
} else {
drawLine(line.x, line.y, line.previousX, line.previousY)
}
});
}
Ось інтерфейс TypeScript для об'єкта Line
:
interface Line {
x: number; // Координата x кінцевої точки лінії
y: number; // Координата y кінцевої точки лінії
previousX: number; // Координата x початкової точки лінії
previousY: number; // Координата y початкової точки лінії
isEraseLine: boolean; // Ми викликаємо або метод drawLine, або eraseLine на основі цього значення
}
Кінцевий результат:
Крок 5: Реалізація масштабування на обох полотнах
Нарешті, нам потрібно також реалізувати масштабування.
Давайте визначимо функцію для оновлення властивостей масштабування контексту полотна:
const updateZooming = (e) => {
const oldX = viewportTransform.x;
const oldY = viewportTransform.y;
const localX = e.clientX;
const localY = e.clientY;
const previousScale = viewportTransform.scale;
const newScale = viewportTransform.scale += e.deltaY * -0.01;
const newX = localX - (localX - oldX) * (newScale / previousScale);
const newY = localY - (localY - oldY) * (newScale / previousScale);
viewportTransform.x = newX;
viewportTransform.y = newY;
viewportTransform.scale = newScale;
}
Також нам потрібно визначити прослуховувач подій (Event Listener), який використовує метод updateZooming
:
const onMouseWheel = (e) => {
updateZooming(e)
render()
}
Ми також хочемо запобігти зменшенню масштабування, поки рівень масштабу не досягне нуля, оскільки це може спричинити баги (наприклад, малюнки взагалі не з'являються).
Для цього ми вводимо метод isZoomAllowed
:
// Якщо ми намагаємося зменшити масштаб до рівня, меншого за рівень масштабу 1, то нічого не робимо
const isZoomAllowed = (viewportTransform, deltaY) => {
return viewportTransform.scale + deltaY * -0.01 >= 1
}
const onMouseWheel = (e) => {
if (isZoomAllowed(viewportTransform, e.deltaY)) {
updateZooming(e) // так само, як updatePanning змінює матрицю трансформації, updateZooming також змінює її
render() // після внесення необхідних коригувань до матриці трансформації, потрібно знову перерисувати все, тому render викликається ще раз
}
}
// Додаємо прослуховувач події mousewheel до полотна для малювання
canvas.addEventListener("wheel", onMouseWheel);
Результат:
Завершення
Весь код для цього можна знайти тут.
Я планую створити npm пакет (специфічний для Svelte) для цієї функціональності найближчим часом, тому очікуйте його випуск через кілька тижнів.
Для глибшого розуміння того, як методи drawLine
та eraseLine
працюють з API полотна, я дуже рекомендую ознайомитися зі статтями Харрісона. Вони були надзвичайно корисними для поглиблення мого розуміння цієї теми:
Успіхів у програмуванні!
Перекладено з: Tutorial: Drawable and Pannable-Zoomable Canvas in Vanilla JS