1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 | import {contrastRatio, contrastRatioAPCA, getAPCAThreshold, getContrastThreshold} from '../front_end/core/common/ColorUtils.js';
|
32 |
|
33 | import type {Bounds, PathCommands, ResetData} from './common.js';
|
34 | import {constrainNumber, createChild, createElement, createTextChild, ellipsify, Overlay} from './common.js';
|
35 | import type {PathBounds} from './highlight_common.js';
|
36 | import {drawPath, emptyBounds, formatColor, formatRgba, parseHexa} from './highlight_common.js';
|
37 | import type {FlexContainerHighlight, FlexItemHighlight} from './highlight_flex_common.js';
|
38 | import {drawLayoutFlexContainerHighlight, drawLayoutFlexItemHighlight} from './highlight_flex_common.js';
|
39 | import type {GridHighlight} from './highlight_grid_common.js';
|
40 | import {drawLayoutGridHighlight} from './highlight_grid_common.js';
|
41 | import type {ScrollSnapHighlight} from './highlight_scroll_snap.js';
|
42 | import type {ContainerQueryHighlight} from './highlight_container_query.js';
|
43 | import {drawContainerQueryHighlight} from './highlight_container_query.js';
|
44 | import type {IsolatedElementHighlight} from './highlight_isolated_element.js';
|
45 | import {PersistentOverlay} from './tool_persistent.js';
|
46 |
|
47 | interface Path {
|
48 | path: PathCommands;
|
49 | outlineColor: string;
|
50 | fillColor: string;
|
51 | name: string;
|
52 | }
|
53 |
|
54 | interface ContrastInfo {
|
55 | backgroundColor: string;
|
56 | fontSize: string;
|
57 | fontWeight: string;
|
58 | contrastAlgorithm: 'apca'|'aa'|'aaa';
|
59 | textOpacity: number;
|
60 | }
|
61 |
|
62 | interface 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 |
|
79 | interface 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 |
|
92 | export 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 |
|
209 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
389 | const lightGridColor = 'rgba(0,0,0,0.2)';
|
390 | const darkGridColor = 'rgba(0,0,0,0.7)';
|
391 | const gridBackgroundColor = 'rgba(255, 255, 255, 0.8)';
|
392 |
|
393 |
|
394 |
|
395 |
|
396 |
|
397 |
|
398 | function 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 |
|
412 |
|
413 | function 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 |
|
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 |
|
569 |
|
570 |
|
571 |
|
572 |
|
573 |
|
574 | function drawElementTitle(
|
575 | elementInfo: ElementInfo, colorFormat: string, bounds: Bounds, canvasWidth: number, canvasHeight: number) {
|
576 |
|
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 |
|
585 | const wrapper = createChild(tooltipContainer, 'div');
|
586 | const tooltipContent = createChild(wrapper, 'div', 'tooltip-content');
|
587 |
|
588 |
|
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 |
|
603 |
|
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 |
|
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 |
|
633 |
|
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 |
|
653 | tooltipContent.style.setProperty('--arrow-top', (onTop ? titleHeight - 1 : -arrowHalfWidth) + 'px');
|
654 | tooltipContent.style.setProperty('--arrow-left', (arrowX - boxX) + 'px');
|
655 | }
|
656 |
|
657 | const DEFAULT_RULER_COLOR = 'rgba(128, 128, 128, 0.3)';
|
658 |
|
659 | function 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 | }
|