| // Copyright 2021 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {assert, assertInstanceof, assertString} from '../../chrome_util.js'; |
| import * as dom from '../../dom.js'; |
| import { |
| Point, |
| PolarVector, |
| Vector, // eslint-disable-line no-unused-vars |
| vectorFromPoints, |
| } from '../../geometry.js'; |
| import {DeviceOperator} from '../../mojo/device_operator.js'; |
| import { |
| closeEndpoint, |
| MojoEndpoint, // eslint-disable-line no-unused-vars |
| } from '../../mojo/util.js'; |
| import * as util from '../../util.js'; |
| |
| /** |
| * Controller for placing line-like element. |
| */ |
| class Line { |
| /** |
| * @param {!HTMLDivElement} el |
| */ |
| constructor(el) { |
| /** |
| * @const {!HTMLDivElement} |
| * @private |
| */ |
| this.el_ = el; |
| } |
| |
| /** |
| * @param {{position: (!Point|undefined), angle: (number|undefined), scale: |
| * (number|undefined)}} params 'position' is the x, y coordinates of start |
| * endpoint in px. 'angle' is the rotate angle in rad. 'scale' is the |
| * scaling ratio of line length. |
| */ |
| place({position, angle, scale}) { |
| const transforms = []; |
| if (position !== undefined) { |
| transforms.push(new CSSTranslate(CSS.px(position.x), CSS.px(position.y))); |
| } |
| if (angle !== undefined) { |
| const prevAngle = this.angle_(); |
| if (prevAngle !== null) { |
| // Derive new angle from prevAngle + smallest rotation angle between new |
| // and prev to ensure the rotation transition like -pi to pi won't jump |
| // too much. |
| angle = prevAngle - |
| new PolarVector(angle, 1).rotation(new PolarVector(prevAngle, 1)); |
| } |
| transforms.push(new CSSRotate(CSS.rad(angle))); |
| } |
| if (scale !== undefined) { |
| transforms.push(new CSSScale(CSS.number(scale), CSS.number(1))); |
| } |
| this.el_.attributeStyleMap.set( |
| 'transform', new CSSTransformValue(transforms)); |
| } |
| |
| /** |
| * @return {?CSSTransformValue} |
| */ |
| getTransform_() { |
| const trans = this.el_.attributeStyleMap.get('transform'); |
| return trans && assertInstanceof(trans, CSSTransformValue); |
| } |
| |
| /** |
| * @return {?number} |
| */ |
| angle_() { |
| const transforms = this.getTransform_(); |
| if (transforms === null) { |
| return null; |
| } |
| for (const transform of transforms) { |
| if (transform instanceof CSSRotate) { |
| return transform.angle.to('rad').value; |
| } |
| } |
| return null; |
| } |
| } |
| |
| /** |
| * Controller for placing corner indicator on preview overlay. |
| */ |
| class Corner { |
| /** |
| * @param {!HTMLDivElement} overlay |
| */ |
| constructor(overlay) { |
| const tpl = util.instantiateTemplate('#document-corner-template'); |
| |
| /** |
| * @const {!HTMLDivElement} |
| * @private |
| */ |
| this.corner_ = dom.getFrom(tpl, `div.corner`, HTMLDivElement); |
| |
| /** |
| * @const {!Line} |
| * @private |
| */ |
| this.prevLine_ = |
| new Line(dom.getAllFrom(tpl, `div.line`, HTMLDivElement)[0]); |
| |
| /** |
| * @const {!Line} |
| * @private |
| */ |
| this.nextLine_ = |
| new Line(dom.getAllFrom(tpl, `div.line`, HTMLDivElement)[1]); |
| |
| overlay.appendChild(tpl); |
| } |
| |
| /** |
| * @param {!Point} pt |
| * @param {!Point} prevPt |
| * @param {!Point} nextPt |
| */ |
| place(pt, prevPt, nextPt) { |
| this.corner_.attributeStyleMap.set('left', CSS.px(pt.x)); |
| this.corner_.attributeStyleMap.set('top', CSS.px(pt.y)); |
| this.prevLine_.place( |
| {angle: vectorFromPoints(prevPt, pt).cssRotateAngle()}); |
| this.nextLine_.place( |
| {angle: vectorFromPoints(nextPt, pt).cssRotateAngle()}); |
| } |
| } |
| |
| /** |
| * Timeout to show toast message when no document is detected within the time. |
| */ |
| const SHOW_NO_DOCUMENT_TOAST_TIMEOUT_MS = 4000; |
| |
| /** |
| * An overlay to show document corner rectangles over preview. |
| */ |
| export class DocumentCornerOverlay { |
| /** |
| * @public |
| */ |
| constructor() { |
| /** |
| * @const {!HTMLDivElement} |
| * @private |
| */ |
| this.overlay_ = dom.get('#preview-document-corner-overlay', HTMLDivElement); |
| |
| /** |
| * @const {!HTMLDivElement} |
| * @private |
| */ |
| this.noDocumentToast_ = |
| dom.getFrom(this.overlay_, '.no-document-toast', HTMLDivElement); |
| |
| /** |
| * @type {?string} |
| * @private |
| */ |
| this.deviceId_ = null; |
| |
| /** |
| * @type {?MojoEndpoint} |
| * @private |
| */ |
| this.observer_ = null; |
| |
| /** |
| * @type {!Array<!Line>} |
| * @private |
| */ |
| this.sides_ = (() => { |
| const lines = []; |
| for (let i = 0; i < 4; i++) { |
| const tpl = util.instantiateTemplate('#document-side-template'); |
| const el = dom.getFrom(tpl, `div`, HTMLDivElement); |
| lines.push(new Line(el)); |
| this.overlay_.appendChild(tpl); |
| } |
| return lines; |
| })(); |
| |
| /** |
| * @type {!Array<!Corner>} |
| * @private |
| */ |
| this.corners_ = (() => { |
| const corners = []; |
| for (let i = 0; i < 4; i++) { |
| corners.push(new Corner(this.overlay_)); |
| } |
| return corners; |
| })(); |
| |
| /** |
| * @type {?number} |
| * @private |
| */ |
| this.noDocumentTimerId_ = null; |
| |
| this.hide_(); |
| } |
| |
| /** |
| * @return {string} |
| */ |
| getDeviceId() { |
| return assertString(this.deviceId_); |
| } |
| |
| /** |
| * Attaches to camera with specified device id. |
| * @param {string} deviceId |
| */ |
| attach(deviceId) { |
| assert(this.deviceId_ === null); |
| this.deviceId_ = deviceId; |
| } |
| |
| /** |
| * Detaches from previous attached camera. |
| * @return {!Promise} |
| */ |
| async detach() { |
| await this.stop(); |
| this.deviceId_ = null; |
| } |
| |
| /** |
| * @return {!Promise} |
| */ |
| async start() { |
| if (this.observer_ !== null) { |
| return; |
| } |
| const deviceOperator = await DeviceOperator.getInstance(); |
| if (deviceOperator === null) { |
| // Skip showing indicator on fake camera. |
| return; |
| } |
| this.observer_ = await deviceOperator.registerDocumentCornersObserver( |
| assertString(this.deviceId_), (corners) => { |
| if (corners.length === 0) { |
| this.onNoCornerDetected_(); |
| return; |
| } |
| const rect = this.overlay_.getBoundingClientRect(); |
| const toOverlaySpace = (pt) => |
| new Point(rect.width * pt.x, rect.height * pt.y); |
| this.onCornerDetected_(corners.map(toOverlaySpace)); |
| }); |
| this.hide_(); |
| this.clearNoDocumentTimer_(); |
| this.setNoDocumentTimer_(); |
| } |
| |
| /** |
| * @return {!Promise} |
| */ |
| async stop() { |
| if (this.observer_ === null) { |
| return; |
| } |
| closeEndpoint(this.observer_); |
| this.observer_ = null; |
| this.hide_(); |
| this.clearNoDocumentTimer_(); |
| } |
| |
| /** |
| * @return {boolean} |
| */ |
| isEnabled() { |
| return this.observer_ !== null; |
| } |
| |
| /** |
| * @private |
| */ |
| onNoCornerDetected_() { |
| this.hideIndicators_(); |
| if (this.isNoDocumentToastShown_()) { |
| return; |
| } |
| if (this.noDocumentTimerId_ === null) { |
| this.setNoDocumentTimer_(); |
| } |
| } |
| |
| /** |
| * @param {!Array<!Point>} corners |
| */ |
| onCornerDetected_(corners) { |
| this.hideNoDocumentToast_(); |
| this.clearNoDocumentTimer_(); |
| if (this.isIndicatorsShown_()) { |
| this.updateCorners_(corners); |
| } else { |
| this.showIndicators_(); |
| this.settleCorners_(corners); |
| } |
| } |
| |
| /** |
| * Place first 4 corners on the overlay and play settle animation. |
| * @param {!Array<!Point>} corners |
| * @private |
| */ |
| settleCorners_(corners) { |
| /** |
| * Start point(corner coordinates + outer shift) of settle animation. |
| * @param {!Point} corn |
| * @param {!Point} corn2 |
| * @param {!Point} corn3 |
| * @param {number} d |
| * @return {!Point} |
| */ |
| const calSettleStart = (corn, corn2, corn3, d) => { |
| const side = vectorFromPoints(corn2, corn); |
| const norm = side.normal().multiply(d); |
| |
| const side2 = vectorFromPoints(corn2, corn3); |
| const angle = side.rotation(side2); |
| const dir = side.direction().multiply(d / Math.tan(angle / 2)); |
| |
| return vectorFromPoints(corn2).add(norm).add(dir).point(); |
| }; |
| const starts = corners.map((_, idx) => { |
| const prevIdx = (idx + 3) % 4; |
| const nextIdx = (idx + 1) % 4; |
| return calSettleStart( |
| corners[prevIdx], corners[idx], corners[nextIdx], 50); |
| }); |
| |
| // Set start of dot transition. |
| starts.forEach((corn, idx) => { |
| const prevIdx = (idx + 3) % 4; |
| const nextIdx = (idx + 1) % 4; |
| this.corners_[idx].place(corn, starts[prevIdx], starts[nextIdx]); |
| }); |
| |
| // Set start of line transition. |
| this.sides_.forEach((line, i) => { |
| const startCorn = starts[i]; |
| const startCorn2 = starts[(i + 1) % 4]; |
| const startSide = vectorFromPoints(startCorn2, startCorn); |
| line.place({ |
| position: startCorn, |
| angle: startSide.cssRotateAngle(), |
| scale: startSide.length(), |
| }); |
| }); |
| |
| /** @suppress {suspiciousCode} */ |
| this.overlay_.offsetParent; // Force start state of transition. |
| |
| // Set end of dot transition. |
| corners.forEach((corn, i) => { |
| const prevIdx = (i + 3) % 4; |
| const nextIdx = (i + 1) % 4; |
| this.corners_[i].place(corn, corners[prevIdx], corners[nextIdx]); |
| }); |
| |
| this.sides_.forEach((line, i) => { |
| const endCorn = corners[i]; |
| const endCorn2 = corners[(i + 1) % 4]; |
| const endSide = vectorFromPoints(endCorn2, endCorn); |
| line.place({ |
| position: endCorn, |
| angle: endSide.cssRotateAngle(), |
| scale: endSide.length(), |
| }); |
| }); |
| } |
| |
| /** |
| * Place first 4 corners on the overlay and play settle animation. |
| * @param {!Array<!Point>} corners |
| * @private |
| */ |
| updateCorners_(corners) { |
| corners.forEach((corn, i) => { |
| const prevIdx = (i + 3) % 4; |
| const nextIdx = (i + 1) % 4; |
| this.corners_[i].place(corn, corners[prevIdx], corners[nextIdx]); |
| }); |
| this.sides_.forEach((line, i) => { |
| const corn = corners[i]; |
| const corn2 = corners[(i + 1) % 4]; |
| const side = vectorFromPoints(corn2, corn); |
| line.place( |
| {position: corn, angle: side.cssRotateAngle(), scale: side.length()}); |
| }); |
| } |
| |
| /** |
| * Hides overlay related UIs. |
| * @private |
| */ |
| hide_() { |
| this.hideIndicators_(); |
| this.hideNoDocumentToast_(); |
| } |
| |
| /** |
| * @private |
| * @return {boolean} |
| */ |
| isIndicatorsShown_() { |
| return this.overlay_.classList.contains('show-corner-indicator'); |
| } |
| |
| /** |
| * @private |
| */ |
| showIndicators_() { |
| this.overlay_.classList.add('show-corner-indicator'); |
| } |
| |
| /** |
| * @private |
| */ |
| hideIndicators_() { |
| this.overlay_.classList.remove('show-corner-indicator'); |
| } |
| |
| /** |
| * @private |
| */ |
| showNoDocumentToast_() { |
| this.noDocumentToast_.attributeStyleMap.delete('visibility'); |
| } |
| |
| /** |
| * @private |
| */ |
| hideNoDocumentToast_() { |
| this.noDocumentToast_.attributeStyleMap.set('visibility', 'hidden'); |
| } |
| |
| /** |
| * @private |
| * @return {boolean} |
| */ |
| isNoDocumentToastShown_() { |
| return !this.noDocumentToast_.attributeStyleMap.has('visibility'); |
| } |
| |
| /** |
| * @private |
| */ |
| setNoDocumentTimer_() { |
| if (this.noDocumentTimerId_ !== null) { |
| clearTimeout(this.noDocumentTimerId_); |
| } |
| this.noDocumentTimerId_ = setTimeout(() => { |
| this.showNoDocumentToast_(); |
| this.clearNoDocumentTimer_(); |
| }, SHOW_NO_DOCUMENT_TOAST_TIMEOUT_MS); |
| } |
| |
| /** |
| * @private |
| */ |
| clearNoDocumentTimer_() { |
| if (this.noDocumentTimerId_ !== null) { |
| clearTimeout(this.noDocumentTimerId_); |
| this.noDocumentTimerId_ = null; |
| } |
| } |
| } |