Touch mode

Learn how an extension can receive and handle pointer events.

By default, editing extensions don't receive pointer events. To receive pointer events, an extension must enable touch mode.

When touch mode is enabled:

  • The iframe that contains the user's image:

    • stretches to the edge of the page

    • is rotated the right way up

    • isn't cropped

  • Canva hides the controls that appear around the edges of the page.

  • Done and Cancel buttons appear in the toolbar.

  • The background color of the editor changes.

This creates a more focused editing experience that is ideal for making precise edits to an image.

This tutorial explains how to add touch mode to an editing extension.

An editing extension that enables touch mode can receive all types of pointer events — not just touch events.

Step 1: Enable touch interactions

  1. Navigate to an app via the Developer Portal.

  2. From the Extensions page, expand the Editing panel.

  3. Select Enable touch interactions.

Step 2: Enable touch mode

When Enable touch interactions is turned on, touch mode is still disabled by default. This lets the extension control exactly when touch mode is enabled.

To enable touch mode, call the toggleTouchMode method with an argument of true. To turn it off, call the same method with an argument of false.

The following snippet demonstrates how to create a button for toggling touch mode:

const { imageHelpers } = window.canva;
const canva = window.canva.init();
const state = {
canvas: null,
touchModeEnabled: false,
};
canva.onReady(async (opts) => {
const image = await imageHelpers.fromElement(opts.element);
state.canvas = await imageHelpers.toCanvas(image);
document.body.appendChild(state.canvas);
renderControls();
});
canva.onControlsEvent(async (opts) => {
if (opts.message.controlId === 'touchModeButton') {
if (state.touchModeEnabled) {
await disableTouchMode();
} else {
await enableTouchMode();
}
}
});
function renderControls() {
const controls = [
canva.create('button', {
id: 'touchModeButton',
label: state.touchModeEnabled
? 'Disable touch mode'
: 'Enable touch mode',
}),
];
canva.updateControlPanel(controls);
}
async function enableTouchMode() {
// Enable touch mode
state.touchModeEnabled = true;
canva.toggleTouchMode(true);
// Re-render the controls
renderControls();
}
async function disableTouchMode() {
// Disable touch mode
state.touchModeEnabled = false;
canva.toggleTouchMode(false);
// Re-render the controls
renderControls();
}

This is what the extension looks like with touch mode disabled:

This is what the extension looks like with touch mode disabled:

Step 4: Position the user's image

The previous code snippet has a couple of problems:

  • The user's image is stretched to the edges of the iframe.

  • The image isn't perfectly centered.

The first problem happens because the toCanvas method assigns style.width and style.height attributes to the HTMLCanvasElement:

canvas.style.width = '100%';
canvas.style.height = '100%';

When an extension doesn't use touch mode, this is convenient, since the user's image is always the same aspect ratio as the iframe. In touch mode though, the iframe stretches to the edges of the page, which means the image doesn't necessarily have the same aspect ratio as the iframe.

The second problem happens because the HTMLCanvasElement is in the top-left corner of the iframe. This doesn't matter when touch mode is off, but it looks strange when touch mode is on.

The following snippet demonstrates one way to solve these problems via the enableTouchMode function:

async function enableTouchMode() {
// Enable touch mode
state.touchModeEnabled = true;
canva.toggleTouchMode(true);
// Set the height of the "html" and "body" elements
document.querySelector('html').style.height = '100%';
document.body.style.height = '100%';
// Center everything in the "body" element
document.body.style.display = 'grid';
document.body.style.alignItems = 'center';
document.body.style.justifyContent = 'center';
// Don't stretch the canvas to the edges of the iframe
state.canvas.style.width = null;
state.canvas.style.height = null;
// Don't let the canvas extend beyond the dimensions of the iframe
state.canvas.style.maxWidth = '100%';
state.canvas.style.maxHeight = '100%';
// Re-render the controls
renderControls();
}

This snippet:

  • Sets the height of the html and body element to "100%".

  • Aligns the HTMLCanvasElement in the center of the page.

  • Sets the width and height of the HTMLCanvasElement to null.

  • Sets max width and max height of the HTMLCanvasElement to "100%".

You also need to add the following statements to the disableTouchMode function to reset the width and height of the HTMLCanvasElement when the user disables touch mode:

state.canvas.style.width = '100%';
state.canvas.style.height = '100%';

Based on these changes, enabling touch mode doesn't distort or oddly position the user's image.

Step 5: Handle pointer events

When touch mode is enabled, the extension can receive pointer events. There are no strict requirements about what an extension does with these pointer events, but a common use-case is to add drawing features to an extension.

To add drawing features to an extension, add an isDrawing property to the state object:

const state = {
canvas: null,
isDrawing: false,
touchModeEnabled: false,
};

This property can track whether or not the user is pressing down on their left-mouse button.

Next, create event handlers for the following events:

  • onpointerdown

  • onpointermove

  • onpointerup

The following snippet demonstrates how to create, register, and deregister these events:

async function enableTouchMode() {
// Enable touch mode
state.touchModeEnabled = true;
canva.toggleTouchMode(true);
// Register event handlers
state.canvas.onpointerdown = handlePointerDown;
state.canvas.onpointermove = handlePointerMove;
state.canvas.onpointerup = handlePointerUp;
// Re-render the controls
renderControls();
}
async function disableTouchMode() {
// Disable touch mode
state.touchModeEnabled = false;
canva.toggleTouchMode(false);
// Remove event handlers
state.canvas.onpointerdown = null;
state.canvas.onpointermove = null;
state.canvas.onpointerup = null;
// Re-render the controls
renderControls();
}
function handlePointerDown(event) {
state.isDrawing = true;
const context = state.canvas.getContext('2d');
const { x, y } = getMousePosition(state.canvas, event);
context.moveTo(x, y);
}
function handlePointerMove(event) {
if (state.isDrawing) {
const context = state.canvas.getContext('2d');
const { x, y } = getMousePosition(state.canvas, event);
context.lineTo(x, y);
context.stroke();
}
}
function handlePointerUp() {
state.isDrawing = false;
}
function getMousePosition(canvas, event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
};
}

This code is based on snippets from Exploring canvas drawing techniques. To learn about how the code works, see the linked page.

Something worth highlighting is the getMousePosition function:

function getMousePosition(canvas, event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
};
}

This function accurately calculates the X and Y coordinates of the pointer device, even if:

  • The HTMLCanvasElement is scaled using CSS attributes.

  • The user adjusts the zoom level of the document.

Without this logic, interacting with the HTMLCanvasElement may result in surprising behavior, such as clicks not registering in the expected position. To learn more, see this StackOverflow answer.

Based on these changes, it's now possible to draw over the image:

Step 6: Reset the user's image

When a user enables touch mode, Done and Cancel buttons appear in the toolbar.

If the user clicks the Cancel button, the extension should reset the user's image to the state it was in before touch mode was enabled.

To do this, add a previousBlob property to the state object:

const state = {
canvas: null,
isDrawing: false,
previousBlob: null,
touchModeEnabled: false,
};

Then, when a user enables touch mode, store a copy of the user's image in the previousBlob property:

async function enableTouchMode() {
// Keep track of the previous image
canva.toggleSpinner('preview', true);
state.previousBlob = await imageHelpers.fromCanvas(
'image/jpeg',
state.canvas,
);
canva.toggleSpinner('preview', false);
// Enable touch mode
state.touchModeEnabled = true;
canva.toggleTouchMode(true);
// Register event handlers
state.canvas.onpointerdown = handlePointerDown;
state.canvas.onpointermove = handlePointerMove;
state.canvas.onpointerup = handlePointerUp;
// Re-render the controls
renderControls();
}

This snippet uses the toggleSpinner method to show and hide a spinner, as storing a copy of the user's image is not instant.

Next, add a commit parameter to the disableTouchMode function. If this parameter is false, replace the user's image with the previous version of the user's image:

async function disableTouchMode(commit) {
// Disable touch mode
state.touchModeEnabled = false;
canva.toggleTouchMode(false);
// Reset the image if the user clicks the "Cancel" button
if (!commit) {
document.body.removeChild(state.canvas);
state.canvas = await imageHelpers.toCanvas(state.previousBlob);
document.body.appendChild(state.canvas);
}
// Remove event handlers
state.canvas.onpointerdown = null;
state.canvas.onpointermove = null;
state.canvas.onpointerup = null;
// Re-render the controls
renderControls();
}

To detect when a user clicks the Done or Cancel button, register a callback with the onTouchModeExit method:

canva.onTouchModeExit((opts) => {
console.log(opts);
});

This callback receives an opts object that contains a commit property. If the user selects the Done button, commit is true. If the user selects the Cancel button, commit is false.

Pass the commit property directly into the disableTouchMode function:

canva.onTouchModeExit((opts) => {
disableTouchMode(opts.commit);
});

Based on these changes, selecting the Done button persists the user's changes to the image and selecting the Cancel button resets the image.

But if the user selects the Disable touch mode button, the extension resets the image.

To fix this, pass true into the disableTouchMode function:

canva.onControlsEvent(async (opts) => {
if (opts.message.controlId === 'touchModeButton') {
if (state.touchModeEnabled) {
await disableTouchMode(true);
} else {
await enableTouchMode();
}
}
});

After making this change, the user's image is only reset when they select the Cancel button.

Example

const { imageHelpers } = window.canva;
const canva = window.canva.init();
const state = {
canvas: null,
isDrawing: false,
previousBlob: null,
touchModeEnabled: false,
};
canva.onReady(async (opts) => {
const image = await imageHelpers.fromElement(opts.element);
state.canvas = await imageHelpers.toCanvas(image);
document.body.appendChild(state.canvas);
renderControls();
});
canva.onControlsEvent(async (opts) => {
if (!opts.message.commit) {
return;
}
if (opts.message.controlId === 'touchModeButton') {
if (state.touchModeEnabled) {
await disableTouchMode(true);
} else {
await enableTouchMode();
}
}
});
canva.onTouchModeExit((opts) => {
disableTouchMode(opts.commit);
});
canva.onSaveRequest(async () => {
return await imageHelpers.fromCanvas('image/jpeg', state.canvas);
});
function renderControls() {
const controls = [
canva.create('button', {
id: 'touchModeButton',
label: state.touchModeEnabled
? 'Disable touch mode'
: 'Enable touch mode',
}),
];
canva.updateControlPanel(controls);
}
async function enableTouchMode() {
// Keep track of the previous image
canva.toggleSpinner('preview', true);
state.previousBlob = await imageHelpers.fromCanvas(
'image/jpeg',
state.canvas,
);
canva.toggleSpinner('preview', false);
// Enable touch mode
state.touchModeEnabled = true;
canva.toggleTouchMode(true);
// Set the height of the "html" and "body" elements
document.querySelector('html').style.height = '100%';
document.body.style.height = '100%';
// Center everything in the "body" element
document.body.style.display = 'grid';
document.body.style.alignItems = 'center';
document.body.style.justifyContent = 'center';
// Don't stretch the canvas to the edges of the iframe
state.canvas.style.width = null;
state.canvas.style.height = null;
// Don't let the canvas extend beyond the dimensions of the iframe
state.canvas.style.maxWidth = '100%';
state.canvas.style.maxHeight = '100%';
// Register event handlers
state.canvas.onpointerdown = handlePointerDown;
state.canvas.onpointermove = handlePointerMove;
state.canvas.onpointerup = handlePointerUp;
// Re-render the controls
renderControls();
}
async function disableTouchMode(commit) {
// Disable touch mode
state.touchModeEnabled = false;
canva.toggleTouchMode(false);
// Reset the image if the user clicks the "Cancel" button
if (!commit) {
document.body.removeChild(state.canvas);
state.canvas = await imageHelpers.toCanvas(state.previousBlob);
document.body.appendChild(state.canvas);
}
// Reset the width of the canvas
state.canvas.style.width = '100%';
state.canvas.style.height = '100%';
// Remove event handlers
state.canvas.onpointerdown = null;
state.canvas.onpointermove = null;
state.canvas.onpointerup = null;
// Re-render the controls
renderControls();
}
function handlePointerDown(event) {
state.isDrawing = true;
const context = state.canvas.getContext('2d');
const { x, y } = getMousePosition(state.canvas, event);
context.moveTo(x, y);
}
function handlePointerMove(event) {
if (state.isDrawing) {
const context = state.canvas.getContext('2d');
const { x, y } = getMousePosition(state.canvas, event);
context.lineTo(x, y);
context.stroke();
}
}
function handlePointerUp() {
state.isDrawing = false;
}
function getMousePosition(canvas, event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
};
}