MagicLensImageBuilder

Source

index.js
import Monologue from 'monologue.js';
import now from 'mout/src/time/now';

import CanvasOffscreenBuffer from '../../../Common/Misc/CanvasOffscreenBuffer';

const IMAGE_READY_TOPIC = 'image-ready';
const MODEL_CHANGE_TOPIC = 'model-change';

// MagicLensImageBuilder Object ----------------------------------------------

export default class MagicLensImageBuilder {
constructor(
frontImageBuilder,
backImageBuilder,
lensColor = '#ff0000',
minZoom = 20,
maxZoom = 0.5,
lineWidth = 2
) {
// Keep track of internal image builders
this.frontImageBuilder = frontImageBuilder;
this.backImageBuilder = backImageBuilder;
this.frontEvent = null;
this.backEvent = null;
this.queryDataModel = this.frontImageBuilder.queryDataModel;

// Internal render
this.frontSubscription = this.frontImageBuilder.onImageReady(
(data, envelope) => {
this.frontEvent = data;
this.draw();
}
);

this.backSubscription = this.backImageBuilder.onImageReady(
(data, envelope) => {
this.backEvent = data;
this.draw();
}
);

// Lens informations
const { dimensions } = frontImageBuilder.getControlModels();
this.width = dimensions[0];
this.height = dimensions[1];

this.frontActive = true;

this.minZoom = minZoom;
this.maxZoom = Math.min(this.width, this.height) * maxZoom;
this.lineWidth = lineWidth;

this.lensColor = lensColor;
this.lensCenterX = this.width / 2;
this.lensCenterY = this.height / 2;
this.lensOriginalCenterX = this.lensCenterX;
this.lensOriginalCenterY = this.lensCenterY;
this.lensDragDX = 0;
this.lensDragDY = 0;
this.lensRadius = Math.floor(Math.min(this.width, this.height) / 5);
this.lensOriginalRadius = this.lensRadius;

this.lastDragTime = now();
this.lastZoomTime = now();
this.newMouseTimeout = 250;

this.lensDrag = false;
this.listenerDrag = false;
this.lensZoom = false;
this.listenerZoom = false;

// Rendering buffer
this.bgCanvas = new CanvasOffscreenBuffer(this.width, this.height);

// Create custom listener for lens drag + zoom
this.listener = {
drag: (event, envelope) => {
const time = now();
const newDrag = this.lastDragTime + this.newMouseTimeout < time;
let eventManaged = false;
const activeArea = event.activeArea;
let xRatio = (event.relative.x - activeArea[0]) / activeArea[2];
let yRatio = (event.relative.y - activeArea[1]) / activeArea[3];

// Clamp bounds
xRatio = xRatio < 0 ? 0 : xRatio > 1 ? 1 : xRatio;
yRatio = yRatio < 0 ? 0 : yRatio > 1 ? 1 : yRatio;

const xPos = Math.floor(xRatio * this.width);
const yPos = Math.floor(yRatio * this.height);
const distFromLensCenter =
(xPos - this.lensCenterX) ** 2 + (yPos - this.lensCenterY) ** 2;

if (newDrag) {
this.lensZoom = false;
this.listenerZoom = false;
this.lensDrag = false;
this.listenerDrag = false;

this.lensOriginalCenterX = this.lensCenterX;
this.lensOriginalCenterY = this.lensCenterY;

this.lensDragDX = xPos - this.lensCenterX;
this.lensDragDY = yPos - this.lensCenterY;
}

if (
(this.lensDrag || distFromLensCenter < this.lensRadius ** 2) &&
event.modifier === 0 &&
!this.listenerDrag
) {
eventManaged = true;
this.lensDrag = true;

this.lensCenterX = xPos - this.lensDragDX;
this.lensCenterY = yPos - this.lensDragDY;

// Make sure the lens can't go out of image
this.lensCenterX = Math.max(this.lensCenterX, this.lensRadius);
this.lensCenterY = Math.max(this.lensCenterY, this.lensRadius);
this.lensCenterX = Math.min(
this.lensCenterX,
this.width - this.lensRadius
);
this.lensCenterY = Math.min(
this.lensCenterY,
this.height - this.lensRadius
);

this.draw();
}

// Handle mouse listener if any
const ibListener = this.frontImageBuilder.getListeners();
if (!eventManaged && ibListener && ibListener.drag) {
this.listenerDrag = true;
eventManaged = ibListener.drag(event, envelope);
}

// Update dragTime
this.lastDragTime = time;

return eventManaged;
},
/* eslint-disable complexity */
zoom: (event, envelope) => {
const time = now();
const newZoom = this.lastZoomTime + this.newMouseTimeout < time;
let eventManaged = false;
const activeArea = event.activeArea;
let xRatio = (event.relative.x - activeArea[0]) / activeArea[2];
let yRatio = (event.relative.y - activeArea[1]) / activeArea[3];

// Reset flags
if (newZoom) {
this.lensZoom = false;
this.listenerZoom = false;
this.lensDrag = false;
this.listenerDrag = false;
}

// Clamp bounds
xRatio = xRatio < 0 ? 0 : xRatio > 1 ? 1 : xRatio;
yRatio = yRatio < 0 ? 0 : yRatio > 1 ? 1 : yRatio;

const xPos = Math.floor(xRatio * this.width);
const yPos = Math.floor(yRatio * this.height);
const distFromLensCenter =
(xPos - this.lensCenterX) ** 2 + (yPos - this.lensCenterY) ** 2;

if (
(this.lensZoom || distFromLensCenter < this.lensRadius ** 2) &&
event.modifier === 0 &&
!this.listenerZoom
) {
eventManaged = true;
this.lensZoom = true;

if (event.isFirst) {
this.lensOriginalRadius = this.lensRadius;
}
let zoom = this.lensOriginalRadius * event.scale;

if (zoom < this.minZoom) {
zoom = this.minZoom;
}
if (zoom > this.maxZoom) {
zoom = this.maxZoom;
}

if (this.lensRadius !== zoom) {
this.lensRadius = zoom;
this.draw();
}

if (event.isFinal) {
this.lensOriginalRadius = this.lensRadius;
}
}

// Handle mouse listener if any
const ibListener = this.frontImageBuilder.getListeners();
if (!eventManaged && ibListener && ibListener.zoom) {
this.listenerZoom = true;
eventManaged = ibListener.zoom(event, envelope);
}

// Update zoomTime
this.lastZoomTime = time;

return eventManaged;
},
/* eslint-enable complexity */
click: (event, envelope) => {
// Reset flags
this.lensDrag = false;
this.listenerDrag = false;
this.lensZoom = false;
this.listenerZoom = false;

return false;
},
};
}

// ------------------------------------------------------------------------

draw() {
if (!this.frontEvent || !this.backEvent) {
return;
}

// Draw
const ctx = this.bgCanvas.get2DContext();
ctx.clearRect(0, 0, this.width, this.height);

// Draw the outside
ctx.drawImage(
this.backEvent.canvas,
this.backEvent.area[0],
this.backEvent.area[1],
this.backEvent.area[2],
this.backEvent.area[3],
0,
0,
this.width,
this.height
);

// Record state for undo clip
ctx.save();

// Create lens mask
ctx.beginPath();
ctx.arc(
this.lensCenterX,
this.lensCenterY,
this.lensRadius,
0,
2 * Math.PI
);
ctx.clip();

// Empty lens content
ctx.clearRect(0, 0, this.width, this.height);

// Draw only in the lens
ctx.drawImage(
this.frontEvent.canvas,
this.frontEvent.area[0],
this.frontEvent.area[1],
this.frontEvent.area[2],
this.frontEvent.area[3],
0,
0,
this.width,
this.height
);

// Restore clip
ctx.restore();

// Draw lens edge
ctx.beginPath();
ctx.lineWidth = this.lineWidth;
ctx.strokeStyle = this.lensColor;
ctx.arc(
this.lensCenterX,
this.lensCenterY,
this.lensRadius,
0,
2 * Math.PI
);
ctx.closePath();
ctx.stroke();

// Trigger image ready event
const readyImage = {
canvas: this.bgCanvas.el,
area: [0, 0, this.width, this.height],
outputSize: [this.width, this.height],
builder: this,
arguments: this.frontEvent.arguments,
};

// Let everyone know the image is ready
this.emit(IMAGE_READY_TOPIC, readyImage);
}

// ------------------------------------------------------------------------

update() {
this.frontImageBuilder.update();
this.backImageBuilder.update();
}

// ------------------------------------------------------------------------

render() {
this.frontImageBuilder.render();
this.backImageBuilder.render();
}

// ------------------------------------------------------------------------

onImageReady(callback) {
return this.on(IMAGE_READY_TOPIC, callback);
}

// ------------------------------------------------------------------------

onModelChange(callback) {
return this.on(MODEL_CHANGE_TOPIC, callback);
}

// ------------------------------------------------------------------------

getListeners() {
return this.listener;
}

// ------------------------------------------------------------------------

destroy() {
this.off();
this.listener = null;

this.frontSubscription.unsubscribe();
this.frontSubscription = null;

this.backSubscription.unsubscribe();
this.backSubscription = null;

this.frontImageBuilder.destroy();
this.backImageBuilder.destroy();
}

// ------------------------------------------------------------------------

getActiveImageBuilder() {
return this.frontActive ? this.frontImageBuilder : this.backImageBuilder;
}

// ------------------------------------------------------------------------

isFront() {
return this.frontActive;
}

// ------------------------------------------------------------------------

toggleLens() {
this.frontActive = !this.frontActive;
this.emit(MODEL_CHANGE_TOPIC);
}
}

// Add Observer pattern using Monologue.js
Monologue.mixInto(MagicLensImageBuilder);