UNPKG

26.2 kBPlain TextView Raw
1// Copyright 2020 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// Copyright (C) 2012 Google Inc. All rights reserved.
6
7// Redistribution and use in source and binary forms, with or without
8// modification, are permitted provided that the following conditions
9// are met:
10
11// 1. Redistributions of source code must retain the above copyright
12// notice, this list of conditions and the following disclaimer.
13// 2. Redistributions in binary form must reproduce the above copyright
14// notice, this list of conditions and the following disclaimer in the
15// documentation and/or other materials provided with the distribution.
16// 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
17// its contributors may be used to endorse or promote products derived
18// from this software without specific prior written permission.
19
20// THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
21// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23// DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
24// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
25// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
26// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
27// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
29// THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30
31import {contrastRatio, contrastRatioAPCA, getAPCAThreshold, getContrastThreshold} from '../front_end/core/common/ColorUtils.js'; // eslint-disable-line rulesdir/es_modules_import
32
33import type {Bounds, PathCommands, ResetData} from './common.js';
34import {constrainNumber, createChild, createElement, createTextChild, ellipsify, Overlay} from './common.js';
35import type {PathBounds} from './highlight_common.js';
36import {drawPath, emptyBounds, formatColor, formatRgba, parseHexa} from './highlight_common.js';
37import type {FlexContainerHighlight, FlexItemHighlight} from './highlight_flex_common.js';
38import {drawLayoutFlexContainerHighlight, drawLayoutFlexItemHighlight} from './highlight_flex_common.js';
39import type {GridHighlight} from './highlight_grid_common.js';
40import {drawLayoutGridHighlight} from './highlight_grid_common.js';
41import type {ScrollSnapHighlight} from './highlight_scroll_snap.js';
42import type {ContainerQueryHighlight} from './highlight_container_query.js';
43import {drawContainerQueryHighlight} from './highlight_container_query.js';
44import type {IsolatedElementHighlight} from './highlight_isolated_element.js';
45import {PersistentOverlay} from './tool_persistent.js';
46
47interface Path {
48 path: PathCommands;
49 outlineColor: string;
50 fillColor: string;
51 name: string;
52}
53
54interface ContrastInfo {
55 backgroundColor: string;
56 fontSize: string;
57 fontWeight: string;
58 contrastAlgorithm: 'apca'|'aa'|'aaa';
59 textOpacity: number;
60}
61
62interface ElementInfo {
63 contrast?: ContrastInfo;
64 tagName: string;
65 idValue: string;
66 className?: string;
67 nodeWidth: number;
68 nodeHeight: number;
69 isLocked: boolean;
70 isLockedAncestor: boolean;
71 style: {[key: string]: string|undefined};
72 showAccessibilityInfo: boolean;
73 isKeyboardFocusable: boolean;
74 accessibleName: string;
75 accessibleRole: string;
76 layoutObjectName?: string;
77}
78
79interface Highlight {
80 paths: Path[];
81 showRulers: boolean;
82 showExtensionLines: boolean;
83 elementInfo: ElementInfo;
84 colorFormat: string;
85 gridInfo: GridHighlight[];
86 flexInfo: FlexContainerHighlight[];
87 flexItemInfo: FlexItemHighlight[];
88 containerQueryInfo: ContainerQueryHighlight[];
89 isolatedElementInfo: IsolatedElementHighlight[];
90}
91
92export class HighlightOverlay extends Overlay {
93 private tooltip!: HTMLElement;
94 private persistentOverlay?: PersistentOverlay;
95 private gridLabelState = {gridLayerCounter: 0};
96
97 reset(resetData: ResetData) {
98 super.reset(resetData);
99 this.tooltip.innerHTML = '';
100 this.gridLabelState.gridLayerCounter = 0;
101 if (this.persistentOverlay) {
102 this.persistentOverlay.reset(resetData);
103 }
104 }
105
106 install() {
107 this.document.body.classList.add('fill');
108
109 const canvas = this.document.createElement('canvas');
110 canvas.id = 'canvas';
111 canvas.classList.add('fill');
112 this.document.body.append(canvas);
113
114 const tooltip = this.document.createElement('div');
115 tooltip.id = 'tooltip-container';
116 this.document.body.append(tooltip);
117 this.tooltip = tooltip;
118
119 this.persistentOverlay = new PersistentOverlay(this.window);
120 this.persistentOverlay.renderGridMarkup();
121 this.persistentOverlay.setCanvas(canvas);
122
123 this.setCanvas(canvas);
124
125 super.install();
126 }
127
128 uninstall() {
129 this.document.body.classList.remove('fill');
130 this.document.body.innerHTML = '';
131
132 super.uninstall();
133 }
134
135 drawHighlight(highlight: Highlight) {
136 this.context.save();
137
138 const bounds = emptyBounds();
139 let contentPath: PathCommands|null = null;
140 let borderPath: PathCommands|null = null;
141
142 for (let paths = highlight.paths.slice(); paths.length;) {
143 const path = paths.pop();
144 if (!path) {
145 continue;
146 }
147 this.context.save();
148 drawPath(
149 this.context, path.path, path.fillColor, path.outlineColor, undefined, bounds, this.emulationScaleFactor);
150 if (paths.length) {
151 this.context.globalCompositeOperation = 'destination-out';
152 drawPath(
153 this.context, paths[paths.length - 1].path, 'red', undefined, undefined, bounds, this.emulationScaleFactor);
154 }
155 this.context.restore();
156
157 if (path.name === 'content') {
158 contentPath = path.path;
159 }
160 if (path.name === 'border') {
161 borderPath = path.path;
162 }
163 }
164 this.context.restore();
165
166 this.context.save();
167
168 const rulerAtRight = Boolean(
169 highlight.paths.length && highlight.showRulers && bounds.minX < 20 && bounds.maxX + 20 < this.canvasWidth);
170 const rulerAtBottom = Boolean(
171 highlight.paths.length && highlight.showRulers && bounds.minY < 20 && bounds.maxY + 20 < this.canvasHeight);
172
173 if (highlight.showRulers) {
174 this.drawAxis(this.context, rulerAtRight, rulerAtBottom);
175 }
176
177 if (highlight.paths.length) {
178 if (highlight.showExtensionLines) {
179 drawRulers(
180 this.context, bounds, rulerAtRight, rulerAtBottom, undefined, false, this.canvasWidth, this.canvasHeight);
181 }
182
183 if (highlight.elementInfo) {
184 drawElementTitle(highlight.elementInfo, highlight.colorFormat, bounds, this.canvasWidth, this.canvasHeight);
185 }
186 }
187 if (highlight.gridInfo) {
188 for (const grid of highlight.gridInfo) {
189 drawLayoutGridHighlight(
190 grid, this.context, this.deviceScaleFactor, this.canvasWidth, this.canvasHeight, this.emulationScaleFactor,
191 this.gridLabelState);
192 }
193 }
194
195 if (highlight.flexInfo) {
196 for (const flex of highlight.flexInfo) {
197 drawLayoutFlexContainerHighlight(
198 flex, this.context, this.deviceScaleFactor, this.canvasWidth, this.canvasHeight, this.emulationScaleFactor);
199 }
200 }
201
202 if (highlight.containerQueryInfo) {
203 for (const containerQuery of highlight.containerQueryInfo) {
204 drawContainerQueryHighlight(containerQuery, this.context, this.emulationScaleFactor);
205 }
206 }
207
208 // Draw the highlight for flex item only if the element isn't also a flex container that already has some highlight
209 // config.
210 const isVisibleFlexContainer = highlight.flexInfo?.length && highlight.flexInfo.some(config => {
211 return Object.keys(config.flexContainerHighlightConfig).length > 0;
212 });
213
214 if (highlight.flexItemInfo && !isVisibleFlexContainer) {
215 for (const flexItem of highlight.flexItemInfo) {
216 const path = flexItem.boxSizing === 'content' ? contentPath : borderPath;
217 if (!path) {
218 continue;
219 }
220 drawLayoutFlexItemHighlight(
221 flexItem, path, this.context, this.deviceScaleFactor, this.canvasWidth, this.canvasHeight,
222 this.emulationScaleFactor);
223 }
224 }
225 this.context.restore();
226
227 return {bounds: bounds};
228 }
229
230 drawGridHighlight(highlight: GridHighlight) {
231 if (this.persistentOverlay) {
232 this.persistentOverlay.drawGridHighlight(highlight);
233 }
234 }
235
236 drawFlexContainerHighlight(highlight: FlexContainerHighlight) {
237 if (this.persistentOverlay) {
238 this.persistentOverlay.drawFlexContainerHighlight(highlight);
239 }
240 }
241
242 drawScrollSnapHighlight(highlight: ScrollSnapHighlight) {
243 this.persistentOverlay?.drawScrollSnapHighlight(highlight);
244 }
245
246 drawContainerQueryHighlight(highlight: ContainerQueryHighlight) {
247 this.persistentOverlay?.drawContainerQueryHighlight(highlight);
248 }
249
250 drawIsolatedElementHighlight(highlight: IsolatedElementHighlight) {
251 this.persistentOverlay?.drawIsolatedElementHighlight(highlight);
252 }
253
254 private drawAxis(context: CanvasRenderingContext2D, rulerAtRight: boolean, rulerAtBottom: boolean) {
255 context.save();
256
257 const pageFactor = this.pageZoomFactor * this.pageScaleFactor * this.emulationScaleFactor;
258 const scrollX = this.scrollX * this.pageScaleFactor;
259 const scrollY = this.scrollY * this.pageScaleFactor;
260 function zoom(x: number) {
261 return Math.round(x * pageFactor);
262 }
263 function unzoom(x: number) {
264 return Math.round(x / pageFactor);
265 }
266
267 const width = this.canvasWidth / pageFactor;
268 const height = this.canvasHeight / pageFactor;
269
270 const gridSubStep = 5;
271 const gridStep = 50;
272
273 {
274 // Draw X grid background
275 context.save();
276 context.fillStyle = gridBackgroundColor;
277 if (rulerAtBottom) {
278 context.fillRect(0, zoom(height) - 15, zoom(width), zoom(height));
279 } else {
280 context.fillRect(0, 0, zoom(width), 15);
281 }
282
283 // Clip out backgrounds intersection
284 context.globalCompositeOperation = 'destination-out';
285 context.fillStyle = 'red';
286 if (rulerAtRight) {
287 context.fillRect(zoom(width) - 15, 0, zoom(width), zoom(height));
288 } else {
289 context.fillRect(0, 0, 15, zoom(height));
290 }
291 context.restore();
292
293 // Draw Y grid background
294 context.fillStyle = gridBackgroundColor;
295 if (rulerAtRight) {
296 context.fillRect(zoom(width) - 15, 0, zoom(width), zoom(height));
297 } else {
298 context.fillRect(0, 0, 15, zoom(height));
299 }
300 }
301
302 context.lineWidth = 1;
303 context.strokeStyle = darkGridColor;
304 context.fillStyle = darkGridColor;
305 {
306 // Draw labels.
307 context.save();
308 context.translate(-scrollX, 0.5 - scrollY);
309 const maxY = height + unzoom(scrollY);
310 for (let y = 2 * gridStep; y < maxY; y += 2 * gridStep) {
311 context.save();
312 context.translate(scrollX, zoom(y));
313 context.rotate(-Math.PI / 2);
314 context.fillText(String(y), 2, rulerAtRight ? zoom(width) - 7 : 13);
315 context.restore();
316 }
317 context.translate(0.5, -0.5);
318 const maxX = width + unzoom(scrollX);
319 for (let x = 2 * gridStep; x < maxX; x += 2 * gridStep) {
320 context.save();
321 context.fillText(String(x), zoom(x) + 2, rulerAtBottom ? scrollY + zoom(height) - 7 : scrollY + 13);
322 context.restore();
323 }
324 context.restore();
325 }
326
327 {
328 // Draw vertical grid
329 context.save();
330 if (rulerAtRight) {
331 context.translate(zoom(width), 0);
332 context.scale(-1, 1);
333 }
334 context.translate(-scrollX, 0.5 - scrollY);
335 const maxY = height + unzoom(scrollY);
336 for (let y = gridStep; y < maxY; y += gridStep) {
337 context.beginPath();
338 context.moveTo(scrollX, zoom(y));
339 const markLength = (y % (gridStep * 2)) ? 5 : 8;
340 context.lineTo(scrollX + markLength, zoom(y));
341 context.stroke();
342 }
343 context.strokeStyle = lightGridColor;
344 for (let y = gridSubStep; y < maxY; y += gridSubStep) {
345 if (!(y % gridStep)) {
346 continue;
347 }
348 context.beginPath();
349 context.moveTo(scrollX, zoom(y));
350 context.lineTo(scrollX + gridSubStep, zoom(y));
351 context.stroke();
352 }
353 context.restore();
354 }
355
356 {
357 // Draw horizontal grid
358 context.save();
359 if (rulerAtBottom) {
360 context.translate(0, zoom(height));
361 context.scale(1, -1);
362 }
363 context.translate(0.5 - scrollX, -scrollY);
364 const maxX = width + unzoom(scrollX);
365 for (let x = gridStep; x < maxX; x += gridStep) {
366 context.beginPath();
367 context.moveTo(zoom(x), scrollY);
368 const markLength = (x % (gridStep * 2)) ? 5 : 8;
369 context.lineTo(zoom(x), scrollY + markLength);
370 context.stroke();
371 }
372 context.strokeStyle = lightGridColor;
373 for (let x = gridSubStep; x < maxX; x += gridSubStep) {
374 if (!(x % gridStep)) {
375 continue;
376 }
377 context.beginPath();
378 context.moveTo(zoom(x), scrollY);
379 context.lineTo(zoom(x), scrollY + gridSubStep);
380 context.stroke();
381 }
382 context.restore();
383 }
384
385 context.restore();
386 }
387}
388
389const lightGridColor = 'rgba(0,0,0,0.2)';
390const darkGridColor = 'rgba(0,0,0,0.7)';
391const gridBackgroundColor = 'rgba(255, 255, 255, 0.8)';
392
393/**
394 * Determine the layout type of the highlighted element based on the config.
395 * @param {Object} elementInfo The element information, part of the config object passed to drawHighlight
396 * @return {String|null} The layout type of the object, or null if none was found
397 */
398function getElementLayoutType(elementInfo: ElementInfo): string|null {
399 if (elementInfo.layoutObjectName && elementInfo.layoutObjectName.endsWith('Grid')) {
400 return 'grid';
401 }
402
403 if (elementInfo.layoutObjectName && elementInfo.layoutObjectName === 'LayoutNGFlexibleBox') {
404 return 'flex';
405 }
406
407 return null;
408}
409
410/**
411 * Create the DOM node that displays the description of the highlighted element
412 */
413function createElementDescription(elementInfo: ElementInfo, colorFormat: string): Element {
414 const elementInfoElement = createElement('div', 'element-info');
415 const elementInfoHeaderElement = createChild(elementInfoElement, 'div', 'element-info-header');
416
417 const layoutType = getElementLayoutType(elementInfo);
418 if (layoutType) {
419 createChild(elementInfoHeaderElement, 'div', `element-layout-type ${layoutType}`);
420 }
421 const descriptionElement = createChild(elementInfoHeaderElement, 'div', 'element-description monospace');
422 const tagNameElement = createChild(descriptionElement, 'span', 'material-tag-name');
423 tagNameElement.textContent = elementInfo.tagName;
424 const nodeIdElement = createChild(descriptionElement, 'span', 'material-node-id');
425 const maxLength = 80;
426 nodeIdElement.textContent = elementInfo.idValue ? '#' + ellipsify(elementInfo.idValue, maxLength) : '';
427 nodeIdElement.classList.toggle('hidden', !elementInfo.idValue);
428
429 const classNameElement = createChild(descriptionElement, 'span', 'material-class-name');
430 if (nodeIdElement.textContent.length < maxLength) {
431 classNameElement.textContent = ellipsify(elementInfo.className || '', maxLength - nodeIdElement.textContent.length);
432 }
433 classNameElement.classList.toggle('hidden', !elementInfo.className);
434 const dimensionsElement = createChild(elementInfoHeaderElement, 'div', 'dimensions');
435 createChild(dimensionsElement, 'span', 'material-node-width').textContent =
436 String(Math.round(elementInfo.nodeWidth * 100) / 100);
437 createTextChild(dimensionsElement, '\u00d7');
438 createChild(dimensionsElement, 'span', 'material-node-height').textContent =
439 String(Math.round(elementInfo.nodeHeight * 100) / 100);
440
441 const style = elementInfo.style || {};
442 let elementInfoBodyElement: HTMLElement;
443
444 if (elementInfo.isLockedAncestor) {
445 addTextRow('Showing content-visibility ancestor', '');
446 }
447
448 if (elementInfo.isLocked) {
449 addTextRow('Descendants are skipped due to content-visibility', '');
450 }
451
452 const color = style['color'];
453 if (color && color !== '#00000000') {
454 addColorRow('Color', color, colorFormat);
455 }
456
457 const fontFamily = style['font-family'];
458 const fontSize = style['font-size'];
459 if (fontFamily && fontSize !== '0px') {
460 addTextRow('Font', `${fontSize} ${fontFamily}`);
461 }
462
463 const bgcolor = style['background-color'];
464 if (bgcolor && bgcolor !== '#00000000') {
465 addColorRow('Background', bgcolor, colorFormat);
466 }
467
468 const margin = style['margin'];
469 if (margin && margin !== '0px') {
470 addTextRow('Margin', margin);
471 }
472
473 const padding = style['padding'];
474 if (padding && padding !== '0px') {
475 addTextRow('Padding', padding);
476 }
477
478 const cbgColor = elementInfo.contrast ? elementInfo.contrast.backgroundColor : null;
479 const hasContrastInfo = color && color !== '#00000000' && cbgColor && cbgColor !== '#00000000';
480
481 if (elementInfo.showAccessibilityInfo) {
482 addSection('Accessibility');
483
484 if (hasContrastInfo && style['color'] && elementInfo.contrast) {
485 addContrastRow(style['color'], elementInfo.contrast);
486 }
487
488 addTextRow('Name', elementInfo.accessibleName);
489 addTextRow('Role', elementInfo.accessibleRole);
490 addIconRow(
491 'Keyboard-focusable',
492 elementInfo.isKeyboardFocusable ? 'a11y-icon a11y-icon-ok' : 'a11y-icon a11y-icon-not-ok');
493 }
494
495 function ensureElementInfoBody() {
496 if (!elementInfoBodyElement) {
497 elementInfoBodyElement = createChild(elementInfoElement, 'div', 'element-info-body');
498 }
499 }
500
501 function addSection(name: string) {
502 ensureElementInfoBody();
503 const rowElement = createChild(elementInfoBodyElement, 'div', 'element-info-row element-info-section');
504 const nameElement = createChild(rowElement, 'div', 'section-name');
505 nameElement.textContent = name;
506 createChild(createChild(rowElement, 'div', 'separator-container'), 'div', 'separator');
507 }
508
509 function addRow(name: string, rowClassName: string|undefined, valueClassName: string|undefined) {
510 ensureElementInfoBody();
511 const rowElement = createChild(elementInfoBodyElement, 'div', 'element-info-row');
512 if (rowClassName) {
513 rowElement.classList.add(rowClassName);
514 }
515 const nameElement = createChild(rowElement, 'div', 'element-info-name');
516 nameElement.textContent = name;
517 createChild(rowElement, 'div', 'element-info-gap');
518 return createChild(rowElement, 'div', valueClassName || '');
519 }
520
521 function addIconRow(name: string, value: string) {
522 createChild(addRow(name, '', 'element-info-value-icon'), 'div', value);
523 }
524
525 function addTextRow(name: string, value: string) {
526 createTextChild(addRow(name, '', 'element-info-value-text'), value);
527 }
528
529 function addColorRow(name: string, color: string, colorFormat: string) {
530 const valueElement = addRow(name, '', 'element-info-value-color');
531 const swatch = createChild(valueElement, 'div', 'color-swatch');
532 const inner = createChild(swatch, 'div', 'color-swatch-inner');
533 inner.style.backgroundColor = color;
534 createTextChild(valueElement, formatColor(color, colorFormat));
535 }
536
537 function addContrastRow(fgColor: string, contrast: ContrastInfo) {
538 const parsedFgColor = parseHexa(fgColor);
539 const parsedBgColor = parseHexa(contrast.backgroundColor);
540 // Merge text opacity into the alpha channel of the color.
541 parsedFgColor[3] *= contrast.textOpacity;
542 const valueElement = addRow('Contrast', '', 'element-info-value-contrast');
543 const sampleText = createChild(valueElement, 'div', 'contrast-text');
544 sampleText.style.color = formatRgba(parsedFgColor, 'rgb');
545 sampleText.style.backgroundColor = contrast.backgroundColor;
546 sampleText.textContent = 'Aa';
547 const valueSpan = createChild(valueElement, 'span');
548 if (contrast.contrastAlgorithm === 'apca') {
549 const percentage = contrastRatioAPCA(parsedFgColor, parsedBgColor);
550 const threshold = getAPCAThreshold(contrast.fontSize, contrast.fontWeight);
551 valueSpan.textContent = String(Math.floor(percentage * 100) / 100) + '%';
552 createChild(
553 valueElement, 'div',
554 threshold === null || Math.abs(percentage) < threshold ? 'a11y-icon a11y-icon-warning' :
555 'a11y-icon a11y-icon-ok');
556 } else if (contrast.contrastAlgorithm === 'aa' || contrast.contrastAlgorithm === 'aaa') {
557 const ratio = contrastRatio(parsedFgColor, parsedBgColor);
558 const threshold = getContrastThreshold(contrast.fontSize, contrast.fontWeight)[contrast.contrastAlgorithm];
559 valueSpan.textContent = String(Math.floor(ratio * 100) / 100);
560 createChild(valueElement, 'div', ratio < threshold ? 'a11y-icon a11y-icon-warning' : 'a11y-icon a11y-icon-ok');
561 }
562 }
563
564 return elementInfoElement;
565}
566
567/**
568 * @param {Object} elementInfo The highlight config object passed to drawHighlight
569 * @param {String} colorFormat
570 * @param {Object} bounds
571 * @param {number} canvasWidth
572 * @param {number} canvasHeight
573 */
574function drawElementTitle(
575 elementInfo: ElementInfo, colorFormat: string, bounds: Bounds, canvasWidth: number, canvasHeight: number) {
576 // Get the tooltip container and empty it, there can only be one tooltip displayed at the same time.
577 const tooltipContainer = document.getElementById('tooltip-container');
578 if (!tooltipContainer) {
579 throw new Error('#tooltip-container is not found');
580 }
581
582 tooltipContainer.innerHTML = '';
583
584 // Create the necessary wrappers.
585 const wrapper = createChild(tooltipContainer, 'div');
586 const tooltipContent = createChild(wrapper, 'div', 'tooltip-content');
587
588 // Create the tooltip content and append it.
589 const tooltip = createElementDescription(elementInfo, colorFormat);
590 tooltipContent.appendChild(tooltip);
591
592 const titleWidth = tooltipContent.offsetWidth;
593 const titleHeight = tooltipContent.offsetHeight;
594 const arrowHalfWidth = 8;
595 const pageMargin = 2;
596 const arrowWidth = arrowHalfWidth * 2;
597 const arrowInset = arrowHalfWidth + 2;
598
599 const containerMinX = pageMargin + arrowInset;
600 const containerMaxX = canvasWidth - pageMargin - arrowInset - arrowWidth;
601
602 // Left align arrow to the tooltip but ensure it is pointing to the element.
603 // Center align arrow if the inspected element bounds are too narrow.
604 const boundsAreTooNarrow = bounds.maxX - bounds.minX < arrowWidth + 2 * arrowInset;
605 let arrowX: number;
606 if (boundsAreTooNarrow) {
607 arrowX = (bounds.minX + bounds.maxX) * 0.5 - arrowHalfWidth;
608 } else {
609 const xFromLeftBound = bounds.minX + arrowInset;
610 const xFromRightBound = bounds.maxX - arrowInset - arrowWidth;
611 if (xFromLeftBound > containerMinX && xFromLeftBound < containerMaxX) {
612 arrowX = xFromLeftBound;
613 } else {
614 arrowX = constrainNumber(containerMinX, xFromLeftBound, xFromRightBound);
615 }
616 }
617 // Hide arrow if element is completely off the sides of the page.
618 const arrowHidden = arrowX < containerMinX || arrowX > containerMaxX;
619
620 let boxX = arrowX - arrowInset;
621 boxX = constrainNumber(boxX, pageMargin, canvasWidth - titleWidth - pageMargin);
622
623 let boxY = bounds.minY - arrowHalfWidth - titleHeight;
624 let onTop = true;
625 if (boxY < 0) {
626 boxY = Math.min(canvasHeight - titleHeight, bounds.maxY + arrowHalfWidth);
627 onTop = false;
628 } else if (bounds.minY > canvasHeight) {
629 boxY = canvasHeight - arrowHalfWidth - titleHeight;
630 }
631
632 // If tooltip intersects with the bounds, hide it.
633 // Allow bounds to contain the box though for the large elements like <body>.
634 const includes = boxX >= bounds.minX && boxX + titleWidth <= bounds.maxX && boxY >= bounds.minY &&
635 boxY + titleHeight <= bounds.maxY;
636 const overlaps =
637 boxX < bounds.maxX && boxX + titleWidth > bounds.minX && boxY < bounds.maxY && boxY + titleHeight > bounds.minY;
638 if (overlaps && !includes) {
639 tooltipContent.style.display = 'none';
640 return;
641 }
642
643 tooltipContent.style.top = boxY + 'px';
644 tooltipContent.style.left = boxX + 'px';
645 tooltipContent.style.setProperty('--arrow-visibility', (arrowHidden || includes) ? 'hidden' : 'visible');
646 if (arrowHidden) {
647 return;
648 }
649
650 tooltipContent.style.setProperty('--arrow', onTop ? 'var(--arrow-down)' : 'var(--arrow-up)');
651 tooltipContent.style.setProperty('--shadow-direction', onTop ? 'var(--shadow-up)' : 'var(--shadow-down)');
652 // When tooltip is on-top remove 1px from the arrow's top value to get rid of whitespace produced by the tooltip's border.
653 tooltipContent.style.setProperty('--arrow-top', (onTop ? titleHeight - 1 : -arrowHalfWidth) + 'px');
654 tooltipContent.style.setProperty('--arrow-left', (arrowX - boxX) + 'px');
655}
656
657const DEFAULT_RULER_COLOR = 'rgba(128, 128, 128, 0.3)';
658
659function drawRulers(
660 context: CanvasRenderingContext2D, bounds: PathBounds, rulerAtRight: boolean, rulerAtBottom: boolean,
661 color: string|undefined, dash: boolean, canvasWidth: number, canvasHeight: number) {
662 context.save();
663 const width = canvasWidth;
664 const height = canvasHeight;
665 context.strokeStyle = color || DEFAULT_RULER_COLOR;
666 context.lineWidth = 1;
667 context.translate(0.5, 0.5);
668 if (dash) {
669 context.setLineDash([3, 3]);
670 }
671
672 if (rulerAtRight) {
673 for (const y in bounds.rightmostXForY) {
674 context.beginPath();
675 context.moveTo(width, Number(y));
676 context.lineTo(bounds.rightmostXForY[y], Number(y));
677 context.stroke();
678 }
679 } else {
680 for (const y in bounds.leftmostXForY) {
681 context.beginPath();
682 context.moveTo(0, Number(y));
683 context.lineTo(bounds.leftmostXForY[y], Number(y));
684 context.stroke();
685 }
686 }
687
688 if (rulerAtBottom) {
689 for (const x in bounds.bottommostYForX) {
690 context.beginPath();
691 context.moveTo(Number(x), height);
692 context.lineTo(Number(x), bounds.topmostYForX[x]);
693 context.stroke();
694 }
695 } else {
696 for (const x in bounds.topmostYForX) {
697 context.beginPath();
698 context.moveTo(Number(x), 0);
699 context.lineTo(Number(x), bounds.topmostYForX[x]);
700 context.stroke();
701 }
702 }
703
704 context.restore();
705}