Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 120 additions & 4 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1293,7 +1293,7 @@
const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
const MIN_CONNECTOR_CHORD_PX = 8;
for (const svg of Array.from(root.querySelectorAll("svg"))) {
if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
if (!isVisibleElement(svg)) continue;
for (const path of Array.from(svg.querySelectorAll("path"))) {
if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
if (!isConnectorPath(svg, path)) continue;
Expand Down Expand Up @@ -1326,7 +1326,16 @@
// Paste-into-`d` bug: both raw endpoints land on distinct anchors as screen pixels.
const userStartKey = attachmentKey(user.start);
const userEndKey = attachmentKey(user.end);
if (!userStartKey || !userEndKey || userStartKey === userEndKey) continue;
const pasteBug = Boolean(userStartKey && userEndKey && userStartKey !== userEndKey);
// Guessed marked shaft: both frames miss. Same-anchor grazes attach in user-space
// and must stay skipped. Name-only decorative flow/arrow paths stay skipped.
// 80px keeps short marker glyphs (chevrons, tips) out.
const markedMiss =
renderedChord >= 80 &&
!userStartKey &&
!userEndKey &&
(path.hasAttribute("marker-start") || path.hasAttribute("marker-end"));
if (!pasteBug && !markedMiss) continue;
const gap = Math.round(
Math.min(
Math.min(...anchors.compact.map((a) => distanceToRect(rendered.start, a.rect))),
Expand All @@ -1339,7 +1348,113 @@
time,
selector: selectorFor(path),
containerSelector: selectorFor(svg),
message: `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.`,
message: pasteBug
? `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.`
: `Connector path endpoints render ${gap}px from the nearest anchorable element — a marked shaft that meets no node.`,
rect: toRect({
left: Math.min(rendered.start.x, rendered.end.x),
top: Math.min(rendered.start.y, rendered.end.y),
right: Math.max(rendered.start.x, rendered.end.x),
bottom: Math.max(rendered.start.y, rendered.end.y),
width: Math.abs(rendered.end.x - rendered.start.x),
height: Math.abs(rendered.end.y - rendered.start.y),
}),
fixHint: pasteBug
? "Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage."
: "Measure the settled node boxes and write `d` in the SVG's user space (invert getScreenCTM), or grow a layout-owned shaft from the source node.",
});
}
}
return issues;
}

function shaftIsPainted(path) {
if (IGNORE_TAGS.has(path.tagName) || hasIgnoreFlag(path)) return false;
const style = getComputedStyle(path);
if (
style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse"
) {
return false;
}
return opacityChain(path) >= 0.2;
}

function shaftDashHidden(path) {
if (typeof path.getTotalLength !== "function") return false;
let total;
try {
total = path.getTotalLength();
} catch {
return false;
}
if (!Number.isFinite(total) || total <= 0) return false;
const style = getComputedStyle(path);
const offset = Number.parseFloat(style.strokeDashoffset || "0");
const dash = Number.parseFloat(String(style.strokeDasharray || "").split(/[\s,]+/)[0] || "0");
return offset >= total * 0.9 && dash >= total * 0.9;
}

function connectorEndpointCandidates(root, rootRect) {
const candidates = [];
const rootArea = rectArea(rootRect);
for (const element of Array.from(root.querySelectorAll("*"))) {
if (element.closest("svg") || IGNORE_TAGS.has(element.tagName) || hasIgnoreFlag(element))
continue;
const style = getComputedStyle(element);
const opaque = RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(style);
if (!opaque && !textContentFor(element)) continue;
const rect = toRect(element.getBoundingClientRect());
const area = rectArea(rect);
if (area < 400 || area > rootArea * 0.15) continue;
candidates.push({ rect, element });
}
return candidates;
}

function connectorOrphanIssues(root, rootRect, time) {
const issues = [];
let candidates = null;
const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
for (const svg of Array.from(root.querySelectorAll("svg"))) {
if (!isVisibleElement(svg)) continue;
for (const path of Array.from(svg.querySelectorAll("path"))) {
if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
if (!isConnectorPath(svg, path)) continue;
if (!shaftIsPainted(path) || shaftDashHidden(path)) continue;
const user = pathUserEndpoints(path);
const rendered = pathScreenEndpoints(svg, path, user);
if (!user || !rendered) continue;
const renderedChord = Math.hypot(
rendered.end.x - rendered.start.x,
rendered.end.y - rendered.start.y,
);
if (renderedChord < 80) continue;
if (candidates === null) candidates = connectorEndpointCandidates(root, rootRect);
const dark = [];
for (const point of [rendered.start, rendered.end]) {
let best = null;
let attached = false;
for (const candidate of candidates) {
const gap = distanceToRect(point, candidate.rect);
if (gap > threshold) continue;
if (isVisibleElement(candidate.element)) {
attached = true;
break;
}
if (best === null || gap < best.gap) best = { gap, candidate };
}
if (!attached && best !== null) dark.push(best.candidate);
}
if (dark.length === 0) continue;
issues.push({
code: "connector_orphan",
severity: "warning",
time,
selector: selectorFor(path),
containerSelector: selectorFor(svg),
message: `Connector shaft is visible while ${dark.length === 2 ? "both endpoints are" : `its endpoint ${selectorFor(dark[0].element)} is`} not on stage.`,
rect: toRect({
left: Math.min(rendered.start.x, rendered.end.x),
top: Math.min(rendered.start.y, rendered.end.y),
Expand All @@ -1349,7 +1464,7 @@
height: Math.abs(rendered.end.y - rendered.start.y),
}),
fixHint:
"Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage.",
"Show the shaft only after both ends are on, and hide it with the earlier exit. Do not give the line its own clock.",
});
}
}
Expand Down Expand Up @@ -1460,6 +1575,7 @@
issues.push(...escaped.issues);
issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged));
issues.push(...connectorDetachmentIssues(root, rootRect, time));
issues.push(...connectorOrphanIssues(root, rootRect, time));
return issues;
};

Expand Down
Loading
Loading