/**
* Canvas Overlay
*
* High-performance overlay renderer for visual feedback (hover, selection, guides).
*
* Features:
* - DPR-aware rendering for crisp visuals on HiDPI displays
* - rAF-coalesced rendering via markDirty() pattern
* - ResizeObserver-backed automatic sizing
* - Separate layers for hover, selection, and future guides
*
* Performance considerations:
* - Uses `desynchronized: true` for lower latency
* - Batches all drawing to single rAF
* - Only redraws when dirty flag is set
* - Pixel-aligned strokes for crisp lines
*/
import {
WEB_EDITOR_V2_COLORS,
WEB_EDITOR_V2_DISTANCE_LABEL_FONT,
WEB_EDITOR_V2_DISTANCE_LABEL_OFFSET,
WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_X,
WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_Y,
WEB_EDITOR_V2_DISTANCE_LABEL_RADIUS,
WEB_EDITOR_V2_DISTANCE_LINE_WIDTH,
WEB_EDITOR_V2_DISTANCE_TICK_SIZE,
WEB_EDITOR_V2_GUIDE_LINE_WIDTH,
WEB_EDITOR_V2_INSERTION_LINE_WIDTH,
WEB_EDITOR_V2_LOG_PREFIX,
} from '../constants';
import { Disposer } from '../utils/disposables';
// =============================================================================
// Types
// =============================================================================
/** Rectangle in viewport coordinates */
export type ViewportRect = Pick<DOMRectReadOnly, 'left' | 'top' | 'width' | 'height'>;
/** Line segment in viewport coordinates */
export interface ViewportLine {
x1: number;
y1: number;
x2: number;
y2: number;
}
/** Distance label kind (Phase 4.3) */
export type DistanceLabelKind = 'sibling' | 'viewport';
/** Axis of the measured distance (Phase 4.3) */
export type DistanceLabelAxis = 'x' | 'y';
/** Distance label rendered on overlay (Phase 4.3) */
export interface DistanceLabel {
/** Source of this measurement */
readonly kind: DistanceLabelKind;
/** 'x' => horizontal distance, 'y' => vertical distance */
readonly axis: DistanceLabelAxis;
/** Rounded px value */
readonly value: number;
/** Display text (e.g. "12px") */
readonly text: string;
/** Measurement line segment */
readonly line: ViewportLine;
}
/** Box style configuration */
export interface BoxStyle {
/** Stroke color */
strokeColor: string;
/** Fill color (with alpha for transparency) */
fillColor: string;
/** Line width in CSS pixels */
lineWidth: number;
/** Dash pattern (empty array for solid line) */
dashPattern: number[];
}
/** Canvas overlay interface */
export interface CanvasOverlay {
/** The underlying canvas element */
canvas: HTMLCanvasElement;
/** Mark state as dirty and schedule a render on next animation frame */
markDirty(): void;
/** Render immediately if dirty (called by RAF engine) */
render(): void;
/** Clear all visual elements */
clear(): void;
/** Update hover highlight (with optional transition animation) */
setHoverRect(rect: ViewportRect | null, options?: { animate?: boolean }): void;
/** Update selection highlight */
setSelectionRect(rect: ViewportRect | null): void;
/** Update drag ghost highlight (Phase 2.4) */
setDragGhostRect(rect: ViewportRect | null): void;
/** Update insertion indicator line (Phase 2.4) */
setInsertionLine(line: ViewportLine | null): void;
/** Update alignment guide lines (Phase 4.2) */
setGuideLines(lines: readonly ViewportLine[] | null): void;
/** Update distance labels (Phase 4.3) */
setDistanceLabels(labels: readonly DistanceLabel[] | null): void;
/** Dispose and cleanup */
dispose(): void;
}
/** Options for creating canvas overlay */
export interface CanvasOverlayOptions {
/** Container element (should be overlayRoot from ShadowHost) */
container: HTMLElement;
}
// =============================================================================
// Constants
// =============================================================================
const CANVAS_ATTR = 'data-mcp-canvas';
const CANVAS_ATTR_VALUE = 'overlay';
/** Duration of hover rect transition animation in milliseconds */
const HOVER_ANIMATION_DURATION_MS = 100;
/** Default styles for different box types */
const BOX_STYLES = {
hover: {
strokeColor: WEB_EDITOR_V2_COLORS.hover,
fillColor: `${WEB_EDITOR_V2_COLORS.hover}15`, // 15 = ~8% opacity
lineWidth: 2,
dashPattern: [6, 4],
},
selection: {
strokeColor: WEB_EDITOR_V2_COLORS.selected,
fillColor: `${WEB_EDITOR_V2_COLORS.selected}20`, // 20 = ~12% opacity
lineWidth: 2,
dashPattern: [],
},
dragGhost: {
strokeColor: WEB_EDITOR_V2_COLORS.selectionBorder,
fillColor: WEB_EDITOR_V2_COLORS.dragGhost,
lineWidth: 2,
dashPattern: [8, 6],
},
} satisfies Record<string, BoxStyle>;
// =============================================================================
// Helpers
// =============================================================================
function isFinitePositive(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
function isValidRect(rect: ViewportRect | null): rect is ViewportRect {
if (!rect) return false;
return (
Number.isFinite(rect.left) &&
Number.isFinite(rect.top) &&
isFinitePositive(rect.width) &&
isFinitePositive(rect.height)
);
}
function isValidLine(line: ViewportLine | null): line is ViewportLine {
if (!line) return false;
return (
Number.isFinite(line.x1) &&
Number.isFinite(line.y1) &&
Number.isFinite(line.x2) &&
Number.isFinite(line.y2)
);
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
// =============================================================================
// Animation Helpers
// =============================================================================
/** Cubic ease-out curve: fast start, slow end (matches CSS ease-out approximately) */
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
/** Linear interpolation between two values */
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/** Interpolate between two rectangles */
function lerpRect(from: ViewportRect, to: ViewportRect, t: number): ViewportRect {
return {
left: lerp(from.left, to.left, t),
top: lerp(from.top, to.top, t),
width: lerp(from.width, to.width, t),
height: lerp(from.height, to.height, t),
};
}
/**
* Build a rounded rectangle path (without beginning a new path)
*/
function buildRoundedRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
// =============================================================================
// Implementation
// =============================================================================
/**
* Create a canvas overlay for rendering visual feedback.
*/
export function createCanvasOverlay(options: CanvasOverlayOptions): CanvasOverlay {
const { container } = options;
const disposer = new Disposer();
// Cleanup any existing canvas from previous instance
const existing = container.querySelector<HTMLCanvasElement>(
`canvas[${CANVAS_ATTR}="${CANVAS_ATTR_VALUE}"]`,
);
if (existing) {
existing.remove();
}
// Create canvas element
const canvas = document.createElement('canvas');
canvas.setAttribute(CANVAS_ATTR, CANVAS_ATTR_VALUE);
canvas.setAttribute('aria-hidden', 'true');
// Style for fullscreen coverage
Object.assign(canvas.style, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
display: 'block',
});
container.append(canvas);
disposer.add(() => canvas.remove());
// Get 2D context with performance options
const ctxOrNull = canvas.getContext('2d', {
alpha: true,
desynchronized: true, // Lower latency on supported browsers
});
if (!ctxOrNull) {
disposer.dispose();
throw new Error(`${WEB_EDITOR_V2_LOG_PREFIX} Failed to get canvas 2D context`);
}
// Capture as non-null after guard (TypeScript needs explicit assignment)
const ctx: CanvasRenderingContext2D = ctxOrNull;
// ==========================================================================
// State
// ==========================================================================
let hoverRect: ViewportRect | null = null;
// Hover animation state: tracks in-progress transition between two rect positions
interface HoverAnimation {
/** Starting rectangle (from position) */
start: ViewportRect;
/** Ending rectangle (to position) */
end: ViewportRect;
/** Animation start timestamp (performance.now()) */
startTime: number;
/** Animation duration in milliseconds */
durationMs: number;
}
let hoverAnimation: HoverAnimation | null = null;
let selectionRect: ViewportRect | null = null;
let dragGhostRect: ViewportRect | null = null;
let insertionLine: ViewportLine | null = null;
let guideLines: readonly ViewportLine[] | null = null;
let distanceLabels: readonly DistanceLabel[] | null = null;
let viewportWidth = 1;
let viewportHeight = 1;
let devicePixelRatio = 1;
let dirty = true;
let rafId: number | null = null;
// ==========================================================================
// RAF Management
// ==========================================================================
function cancelRaf(): void {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
disposer.add(cancelRaf);
function scheduleRaf(): void {
if (rafId !== null || disposer.isDisposed) return;
rafId = requestAnimationFrame(() => {
rafId = null;
render();
});
}
// ==========================================================================
// Canvas Sizing (DPR-aware)
// ==========================================================================
function updateCanvasSize(): boolean {
const nextDpr = Math.max(1, window.devicePixelRatio || 1);
const cssWidth = Math.max(1, viewportWidth);
const cssHeight = Math.max(1, viewportHeight);
const pixelWidth = Math.round(cssWidth * nextDpr);
const pixelHeight = Math.round(cssHeight * nextDpr);
const needsResize =
canvas.width !== pixelWidth ||
canvas.height !== pixelHeight ||
Math.abs(devicePixelRatio - nextDpr) > 0.001;
if (!needsResize) return false;
devicePixelRatio = nextDpr;
canvas.width = pixelWidth;
canvas.height = pixelHeight;
// Reset transform after resize (canvas state is cleared)
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
return true;
}
// ==========================================================================
// Drawing Functions
// ==========================================================================
function clearCanvas(): void {
updateCanvasSize();
ctx.clearRect(0, 0, viewportWidth, viewportHeight);
}
function drawBox(rect: ViewportRect | null, style: BoxStyle): void {
if (!isValidRect(rect)) return;
const w = Math.round(rect.width);
const h = Math.round(rect.height);
if (w <= 0 || h <= 0) return;
// Pixel-align for crisp strokes (add 0.5 for even line widths)
const x = Math.round(rect.left) + 0.5;
const y = Math.round(rect.top) + 0.5;
ctx.save();
// Configure stroke
ctx.lineWidth = style.lineWidth;
ctx.strokeStyle = style.strokeColor;
ctx.fillStyle = style.fillColor;
ctx.setLineDash(style.dashPattern);
// Draw rectangle
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.fill();
ctx.stroke();
ctx.restore();
}
/**
* Draw an insertion indicator line (horizontal)
*/
function drawInsertionLine(line: ViewportLine | null): void {
if (!isValidLine(line)) return;
ctx.save();
ctx.lineWidth = WEB_EDITOR_V2_INSERTION_LINE_WIDTH;
ctx.strokeStyle = WEB_EDITOR_V2_COLORS.insertionLine;
ctx.setLineDash([]);
ctx.lineCap = 'round';
// Pixel-align for crisp strokes
const x1 = Math.round(line.x1) + 0.5;
const y1 = Math.round(line.y1) + 0.5;
const x2 = Math.round(line.x2) + 0.5;
const y2 = Math.round(line.y2) + 0.5;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
}
/**
* Draw alignment guide lines (Phase 4.2)
*
* Guide lines indicate snap alignments during resize operations.
* Multiple lines may be drawn simultaneously (one per axis).
*/
function drawGuideLines(lines: readonly ViewportLine[] | null): void {
if (!lines || lines.length === 0) return;
ctx.save();
ctx.lineWidth = WEB_EDITOR_V2_GUIDE_LINE_WIDTH;
ctx.strokeStyle = WEB_EDITOR_V2_COLORS.guideLine;
ctx.setLineDash([]);
ctx.lineCap = 'round';
// Batch all lines into single path for performance
ctx.beginPath();
for (const line of lines) {
if (!isValidLine(line)) continue;
// Pixel-align for crisp strokes
const x1 = Math.round(line.x1) + 0.5;
const y1 = Math.round(line.y1) + 0.5;
const x2 = Math.round(line.x2) + 0.5;
const y2 = Math.round(line.y2) + 0.5;
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
ctx.restore();
}
/**
* Draw distance labels (Phase 4.3)
*
* Renders:
* - Measurement line (pink, matches guide line color)
* - End ticks (perpendicular marks at each end)
* - Text pill (dark translucent background with white text)
*/
function drawDistanceLabels(labels: readonly DistanceLabel[] | null): void {
if (!labels || labels.length === 0) return;
ctx.save();
// Draw measurement lines and ticks first (batched for performance)
ctx.lineWidth = WEB_EDITOR_V2_DISTANCE_LINE_WIDTH;
ctx.strokeStyle = WEB_EDITOR_V2_COLORS.guideLine;
ctx.setLineDash([]);
ctx.lineCap = 'round';
const tick = WEB_EDITOR_V2_DISTANCE_TICK_SIZE;
ctx.beginPath();
for (const label of labels) {
const line = label.line;
if (!isValidLine(line)) continue;
// Pixel-align for crisp 1px strokes
const x1 = Math.round(line.x1) + 0.5;
const y1 = Math.round(line.y1) + 0.5;
const x2 = Math.round(line.x2) + 0.5;
const y2 = Math.round(line.y2) + 0.5;
// Draw measurement line
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
// Draw end ticks (perpendicular to measurement direction)
if (label.axis === 'x') {
// Horizontal distance: vertical ticks at each end
ctx.moveTo(x1, y1 - tick);
ctx.lineTo(x1, y1 + tick);
ctx.moveTo(x2, y2 - tick);
ctx.lineTo(x2, y2 + tick);
} else {
// Vertical distance: horizontal ticks at each end
ctx.moveTo(x1 - tick, y1);
ctx.lineTo(x1 + tick, y1);
ctx.moveTo(x2 - tick, y2);
ctx.lineTo(x2 + tick, y2);
}
}
ctx.stroke();
// Draw text pills (each label gets its own pill)
ctx.font = WEB_EDITOR_V2_DISTANCE_LABEL_FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const label of labels) {
const line = label.line;
if (!isValidLine(line)) continue;
// Measure text dimensions
const metrics = ctx.measureText(label.text);
const textWidth = metrics.width;
// Use actualBoundingBox if available, fallback to estimated values
const ascent = Number.isFinite(metrics.actualBoundingBoxAscent)
? metrics.actualBoundingBoxAscent
: 8;
const descent = Number.isFinite(metrics.actualBoundingBoxDescent)
? metrics.actualBoundingBoxDescent
: 3;
const textHeight = ascent + descent;
// Calculate pill dimensions
const pillWidth = Math.ceil(textWidth + WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_X * 2);
const pillHeight = Math.ceil(textHeight + WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_Y * 2);
// Position pill at midpoint of measurement line with offset
const midX = (line.x1 + line.x2) / 2;
const midY = (line.y1 + line.y2) / 2;
const offset = WEB_EDITOR_V2_DISTANCE_LABEL_OFFSET;
let pillX = midX - pillWidth / 2;
let pillY = midY - pillHeight / 2;
// Position based on axis with auto-flip if out of viewport
if (label.axis === 'x') {
// Horizontal distance: prefer above the line
pillY = midY - pillHeight / 2 - offset;
if (pillY < 0) {
pillY = midY + offset - pillHeight / 2;
}
} else {
// Vertical distance: prefer right of the line
pillX = midX + offset - pillWidth / 2;
if (pillX + pillWidth > viewportWidth) {
pillX = midX - offset - pillWidth / 2;
}
}
// Clamp within viewport bounds (handle edge case where pill > viewport)
const maxPillX = Math.max(2, viewportWidth - pillWidth - 2);
const maxPillY = Math.max(2, viewportHeight - pillHeight - 2);
pillX = clamp(pillX, 2, maxPillX);
pillY = clamp(pillY, 2, maxPillY);
// Draw pill background
ctx.save();
ctx.fillStyle = WEB_EDITOR_V2_COLORS.distanceLabelBg;
ctx.strokeStyle = WEB_EDITOR_V2_COLORS.distanceLabelBorder;
ctx.lineWidth = 1;
ctx.beginPath();
buildRoundedRectPath(
ctx,
pillX,
pillY,
pillWidth,
pillHeight,
WEB_EDITOR_V2_DISTANCE_LABEL_RADIUS,
);
ctx.fill();
ctx.stroke();
// Draw text
ctx.fillStyle = WEB_EDITOR_V2_COLORS.distanceLabelText;
ctx.fillText(label.text, pillX + pillWidth / 2, pillY + pillHeight / 2);
ctx.restore();
}
ctx.restore();
}
// ==========================================================================
// Public API
// ==========================================================================
function markDirty(): void {
if (disposer.isDisposed) return;
dirty = true;
scheduleRaf();
}
function render(): void {
if (disposer.isDisposed || !dirty) return;
// Cancel any pending RAF (in case render() is called manually)
cancelRaf();
// Reset dirty flag before drawing
dirty = false;
// Calculate hover rect to render (may be animated)
const now = performance.now();
let hoverRectToRender = hoverRect;
if (hoverAnimation) {
const elapsed = now - hoverAnimation.startTime;
const progress = clamp(elapsed / hoverAnimation.durationMs, 0, 1);
const easedProgress = easeOutCubic(progress);
hoverRectToRender = lerpRect(hoverAnimation.start, hoverAnimation.end, easedProgress);
if (progress >= 1) {
// Animation complete, clear state
hoverAnimation = null;
} else {
// Animation in progress, schedule next frame
dirty = true;
}
}
// Clear and redraw
clearCanvas();
drawBox(hoverRectToRender, BOX_STYLES.hover);
drawBox(selectionRect, BOX_STYLES.selection);
drawBox(dragGhostRect, BOX_STYLES.dragGhost);
drawInsertionLine(insertionLine);
drawGuideLines(guideLines);
drawDistanceLabels(distanceLabels);
// If something marked dirty during render, schedule another frame
if (dirty) {
scheduleRaf();
}
}
function setHoverRect(rect: ViewportRect | null, options?: { animate?: boolean }): void {
const shouldAnimate = options?.animate === true;
// Fast path: no animation requested (snap immediately)
if (!shouldAnimate) {
hoverAnimation = null;
hoverRect = rect;
markDirty();
return;
}
// Animation requested: calculate starting position
const now = performance.now();
let fromRect: ViewportRect | null = hoverRect;
// If animation is in progress, start from current interpolated position
// This ensures smooth transition when target changes mid-animation
if (hoverAnimation) {
const elapsed = now - hoverAnimation.startTime;
const progress = clamp(elapsed / hoverAnimation.durationMs, 0, 1);
const easedProgress = easeOutCubic(progress);
fromRect = lerpRect(hoverAnimation.start, hoverAnimation.end, easedProgress);
}
// Cannot animate if source or target rect is invalid
if (!isValidRect(fromRect) || !isValidRect(rect)) {
hoverAnimation = null;
hoverRect = rect;
markDirty();
return;
}
// Start animation from current position to target
hoverAnimation = {
start: { ...fromRect },
end: { ...rect },
startTime: now,
durationMs: HOVER_ANIMATION_DURATION_MS,
};
hoverRect = rect;
markDirty();
}
function setSelectionRect(rect: ViewportRect | null): void {
selectionRect = rect;
markDirty();
}
function setDragGhostRect(rect: ViewportRect | null): void {
dragGhostRect = rect;
markDirty();
}
function setInsertionLine(line: ViewportLine | null): void {
insertionLine = line;
markDirty();
}
function setGuideLines(lines: readonly ViewportLine[] | null): void {
guideLines = lines && lines.length > 0 ? lines : null;
markDirty();
}
function setDistanceLabels(labels: readonly DistanceLabel[] | null): void {
distanceLabels = labels && labels.length > 0 ? labels : null;
markDirty();
}
function clear(): void {
hoverRect = null;
hoverAnimation = null;
selectionRect = null;
dragGhostRect = null;
insertionLine = null;
guideLines = null;
distanceLabels = null;
markDirty();
}
// ==========================================================================
// Initialization
// ==========================================================================
// Initial size measurement
try {
const rect = container.getBoundingClientRect();
viewportWidth = Math.max(1, rect.width);
viewportHeight = Math.max(1, rect.height);
} catch (error) {
console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} Initial size measurement failed:`, error);
}
// Setup ResizeObserver for automatic sizing
disposer.observeResize(container, (entries) => {
const entry = entries[0];
const rect = entry?.contentRect;
if (!rect) return;
const nextWidth = Math.max(1, rect.width);
const nextHeight = Math.max(1, rect.height);
// Skip if size hasn't changed significantly
if (Math.abs(nextWidth - viewportWidth) < 0.5 && Math.abs(nextHeight - viewportHeight) < 0.5) {
return;
}
viewportWidth = nextWidth;
viewportHeight = nextHeight;
markDirty();
});
// Initial render
markDirty();
return {
canvas,
markDirty,
render,
clear,
setHoverRect,
setSelectionRect,
setDragGhostRect,
setInsertionLine,
setGuideLines,
setDistanceLabels,
dispose: () => disposer.dispose(),
};
}