blob: cba0632b651e331d6ef07fbc0db77550b1369274 [file] [log] [blame]
// Copyright 2020 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.
// Copyright (C) 2012 Google Inc. All rights reserved.
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions
// are met:
// 1. Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// 2. Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
// 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
// its contributors may be used to endorse or promote products derived
// from this software without specific prior written permission.
// THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
// THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import {createChild, Overlay, type Bounds, type ResetData} from './common.js';
import {buildPath, emptyBounds, type PathBounds} from './highlight_common.js';
interface Path {
path: Array<string|number>;
outlineColor: string;
name: string;
}
interface SourceOrderHighlight {
sourceOrder: number;
paths: Path[];
}
export class SourceOrderOverlay extends Overlay {
private sourceOrderContainer!: HTMLElement;
override reset(resetData: ResetData) {
super.reset(resetData);
this.sourceOrderContainer.textContent = '';
}
override install() {
this.document.body.classList.add('fill');
const canvas = this.document.createElement('canvas');
canvas.id = 'canvas';
canvas.classList.add('fill');
this.document.body.append(canvas);
const sourceOrderContainer = this.document.createElement('div');
sourceOrderContainer.id = 'source-order-container';
this.document.body.append(sourceOrderContainer);
this.sourceOrderContainer = sourceOrderContainer;
this.setCanvas(canvas);
super.install();
}
override uninstall() {
this.document.body.classList.remove('fill');
this.document.body.innerHTML = '';
super.uninstall();
}
drawSourceOrder(highlight: SourceOrderHighlight) {
const sourceOrder = highlight.sourceOrder || 0;
const path = highlight.paths.slice().pop();
if (!path) {
throw new Error('No path provided');
}
this.context.save();
const bounds = emptyBounds();
const outlineColor = path.outlineColor;
this.context.save();
drawPath(this.context, path.path, outlineColor, Boolean(sourceOrder), bounds, this.emulationScaleFactor);
this.context.restore();
this.context.save();
if (Boolean(sourceOrder)) {
this.drawSourceOrderLabel(sourceOrder, outlineColor, bounds);
}
this.context.restore();
return {bounds: bounds};
}
private drawSourceOrderLabel(sourceOrder: number, color: string, bounds: PathBounds) {
const sourceOrderContainer = this.sourceOrderContainer;
const otherLabels = sourceOrderContainer.children;
const labelContainer = createChild(sourceOrderContainer, 'div', 'source-order-label-container');
labelContainer.style.color = color;
labelContainer.textContent = String(sourceOrder);
const labelHeight = labelContainer.offsetHeight;
const labelWidth = labelContainer.offsetWidth;
const labelType =
getLabelType(bounds, labelHeight, labelWidth, otherLabels as HTMLCollectionOf<HTMLElement>, this.canvasHeight);
const labelPosition = getPositionFromLabelType(labelType, bounds, labelHeight);
labelContainer.classList.add(labelType);
labelContainer.style.top = labelPosition.contentTop + 'px';
labelContainer.style.left = labelPosition.contentLeft + 'px';
}
}
// If there is a large number of child elements, labels will be placed in the top
// corner in order to keep the overlay rendering quick
const MAX_CHILD_ELEMENTS_THRESHOLD = 300;
/**
* There are 8 types of labels.
*
* There are 4 positions a label can have on the y axis, relative to the element:
* - topCorner, bottomCorner: placed inside of the element, in its left corners
* - aboveElement, belowElement: placed outside of the element, aligned on the
* left edge of the element
*
* The label position is determined as follows:
* 1. Top corner if the element is wider and taller than the element
* 2. Above element if the label is wider or taller than the element
* 3. Below element if the label would be placed above the element, but this would
* cause it to overlap with another label or intersect the top of the window
* 4. Bottom corner if the label would be placed below the element, but this would
* cause it to intersect the bottom of the window
* On the x axis, the label is always aligned with its element's leftmost edge.
*
* The label may need additional styles if it is taller or wider than the element,
* to make sure all borders that don't touch the element's outline are rounded
* - Wider: right corners are rounded
* - Taller: top corners are rounded
*
* Examples: (E = element, L = label)
* ______
* |_L_| | (the bottom right corner of the label will be rounded)
* topCorner: | |
* |___E__|
* ___
* |_L_| (the bottom right corner of the label will be rounded)
* aboveElementWider: | |
* |E|
* |_|
* ______
* bottomCornerTaller: | L |_____
* |______|__E__|
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const LabelTypes = {
topCorner: 'top-corner',
aboveElement: 'above-element',
belowElement: 'below-element',
aboveElementWider: 'above-element-wider',
belowElementWider: 'below-element-wider',
bottomCornerWider: 'bottom-corner-wider',
bottomCornerTaller: 'bottom-corner-taller',
bottomCornerWiderTaller: 'bottom-corner-wider-taller',
};
/**
* Calculates the coordinates to place the label based on position type
*/
export function getPositionFromLabelType(positionType: string, bounds: Omit<Bounds, 'allPoints'>, labelHeight: number):
{contentTop: number, contentLeft: number} {
let contentTop = 0;
switch (positionType) {
case LabelTypes.topCorner:
contentTop = bounds.minY;
break;
case LabelTypes.aboveElement:
case LabelTypes.aboveElementWider:
contentTop = bounds.minY - labelHeight;
break;
case LabelTypes.belowElement:
case LabelTypes.belowElementWider:
contentTop = bounds.maxY;
break;
case LabelTypes.bottomCornerWider:
case LabelTypes.bottomCornerTaller:
case LabelTypes.bottomCornerWiderTaller:
contentTop = bounds.maxY - labelHeight;
break;
}
return {
contentTop,
contentLeft: bounds.minX,
};
}
/**
* Determines the position type of the label based on the element it's associated
* with, avoiding overlaps between other labels
*/
export function getLabelType(
bounds: Omit<Bounds, 'allPoints'>, labelHeight: number, labelWidth: number,
otherLabels: HTMLCollectionOf<HTMLElement>, canvasHeight: number): string {
let labelType;
// Label goes in the top left corner if the element is bigger than the label
// or if there are too many child nodes
const widerThanElement = bounds.minX + labelWidth > bounds.maxX;
const tallerThanElement = bounds.minY + labelHeight > bounds.maxY;
if ((!widerThanElement && !tallerThanElement) || otherLabels.length >= MAX_CHILD_ELEMENTS_THRESHOLD) {
return LabelTypes.topCorner;
}
// Check if the new label would overlap with an existing label if placed above
// its element
let overlaps = false;
for (let i = 0; i < otherLabels.length; i++) {
const currentLabel = otherLabels[i];
const rect = currentLabel.getBoundingClientRect();
// Skip the newly created/appended element that is currently being placed
if (currentLabel.style.top === '' && currentLabel.style.left === '') {
continue;
}
const topOverlaps = bounds.minY - labelHeight <= rect.top + rect.height && bounds.minY - labelHeight >= rect.top;
const bottomOverlaps = bounds.minY <= rect.top + rect.height && bounds.minY >= rect.top;
const leftOverlaps = bounds.minX >= rect.left && bounds.minX <= rect.left + rect.width;
const rightOverlaps = bounds.minX + labelWidth >= rect.left && bounds.minX + labelWidth <= rect.left + rect.width;
const sideOverlaps = leftOverlaps || rightOverlaps;
if (sideOverlaps && (topOverlaps || bottomOverlaps)) {
overlaps = true;
break;
}
}
// Label goes on top of the element if the element is too small
if (bounds.minY - labelHeight > 0 && !overlaps) {
labelType = LabelTypes.aboveElement;
if (widerThanElement) {
labelType = LabelTypes.aboveElementWider;
}
// Label goes below the element if would go off the screen/overlap with another label
} else if (bounds.maxY + labelHeight < canvasHeight) {
labelType = LabelTypes.belowElement;
if (widerThanElement) {
labelType = LabelTypes.belowElementWider;
}
// Label goes in the bottom left corner of the element if putting it below the element would make it go off the screen
} else {
if (widerThanElement && tallerThanElement) {
labelType = LabelTypes.bottomCornerWiderTaller;
} else if (widerThanElement) {
labelType = LabelTypes.bottomCornerWider;
} else {
labelType = LabelTypes.bottomCornerTaller;
}
}
return labelType;
}
function drawPath(
context: CanvasRenderingContext2D, commands: Array<string|number>, outlineColor: string|undefined, isChild: boolean,
bounds: PathBounds, emulationScaleFactor: number): Path2D {
context.save();
const path = buildPath(commands, bounds, emulationScaleFactor);
if (outlineColor) {
context.strokeStyle = outlineColor;
context.lineWidth = 2;
if (!isChild) {
context.setLineDash([3, 3]);
}
context.stroke(path);
}
context.restore();
return path;
}