文件
mynode/packages/lib/services/whiteboard/findEmptySpot.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

138 行
5.3 KiB
TypeScript

export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export interface Size {
width: number;
height: number;
}
export interface FindEmptySpotOptions {
// Existing node rectangles the new node must not overlap.
existing: Rect[];
// Visible viewport in canvas coordinates. If null, the function returns
// the preferred-size rect centred on `centre` (used when the viewport
// hasn't been measured yet).
viewport: Rect | null;
// Point around which the search spirals — typically the viewport centre.
centre: { x: number; y: number };
preferred: Size;
// Minimum acceptable size. The search shrinks from preferred toward min
// (preserving aspect ratio) and only falls back to offscreen placement
// if even the min size doesn't fit anywhere in view.
min: Size;
// Spiral step in canvas coordinates.
step?: number;
// Shrink increment (applied to width; height shrinks proportionally).
shrinkStep?: number;
// Gap between the new node and the AABB of existing nodes when placed
// adjacent to it in the offscreen-fallback path.
gap?: number;
}
const rectsOverlap = (a: Rect, b: Rect): boolean => {
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
};
const overlapsAny = (candidate: Rect, existing: Rect[]): boolean => {
for (const e of existing) {
if (rectsOverlap(candidate, e)) return true;
}
return false;
};
export const buildSizes = (preferred: Size, min: Size, shrinkStep: number): Size[] => {
const sizes: Size[] = [];
const ratio = preferred.height / preferred.width;
// Keep shrinking while EITHER dimension is still above its minimum, so the
// other one can be clamped instead of terminating the search early. This
// matters for non-square preferred/min pairs where one axis reaches its
// minimum well before the other.
let w = preferred.width;
let h = preferred.height;
while (w > min.width || h > min.height) {
sizes.push({ width: Math.max(w, min.width), height: Math.max(h, min.height) });
w -= shrinkStep;
h -= Math.round(shrinkStep * ratio);
}
const last = sizes[sizes.length - 1];
if (!last || last.width !== min.width || last.height !== min.height) sizes.push({ width: min.width, height: min.height });
return sizes;
};
// Finds an empty spot for a new rectangular node. Tries the preferred size
// first, spiralling out from `centre` inside `viewport`; if nothing fits, it
// shrinks toward `min` and tries again. If even the min size doesn't fit
// anywhere in view, falls back to placing a preferred-size rect adjacent to
// the AABB of existing nodes (with a gap) on whichever side is closest to the
// viewport, so the new node stays partially visible.
const findEmptySpot = (options: FindEmptySpotOptions): Rect => {
const { existing, viewport, centre, preferred, min } = options;
const step = options.step ?? 40;
const shrinkStep = options.shrinkStep ?? 40;
const gap = options.gap ?? 40;
if (!viewport) {
return { x: centre.x - preferred.width / 2, y: centre.y - preferred.height / 2, ...preferred };
}
const sizes = buildSizes(preferred, min, shrinkStep);
for (const size of sizes) {
const cornerX = centre.x - size.width / 2;
const cornerY = centre.y - size.height / 2;
const maxX = viewport.x + viewport.width - size.width;
const maxY = viewport.y + viewport.height - size.height;
if (maxX < viewport.x || maxY < viewport.y) continue;
const centred: Rect = { x: cornerX, y: cornerY, ...size };
if (viewport.x <= cornerX && cornerX <= maxX && viewport.y <= cornerY && cornerY <= maxY && !overlapsAny(centred, existing)) {
return centred;
}
const maxRings = Math.ceil(Math.max(viewport.width, viewport.height) / step);
for (let ring = 1; ring <= maxRings; ring++) {
for (let dy = -ring; dy <= ring; dy++) {
for (let dx = -ring; dx <= ring; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue;
const x = cornerX + dx * step;
const y = cornerY + dy * step;
if (x < viewport.x || x > maxX || y < viewport.y || y > maxY) continue;
const candidate: Rect = { x, y, ...size };
if (!overlapsAny(candidate, existing)) return candidate;
}
}
}
}
if (!existing.length) {
return { x: centre.x - preferred.width / 2, y: centre.y - preferred.height / 2, ...preferred };
}
let minCX = Infinity, minCY = Infinity, maxCX = -Infinity, maxCY = -Infinity;
for (const e of existing) {
minCX = Math.min(minCX, e.x);
minCY = Math.min(minCY, e.y);
maxCX = Math.max(maxCX, e.x + e.width);
maxCY = Math.max(maxCY, e.y + e.height);
}
const viewR = viewport.x + viewport.width;
const viewB = viewport.y + viewport.height;
const cyMid = Math.max(viewport.y, Math.min(viewB - preferred.height, (minCY + maxCY) / 2 - preferred.height / 2));
const cxMid = Math.max(viewport.x, Math.min(viewR - preferred.width, (minCX + maxCX) / 2 - preferred.width / 2));
const candidates = [
{ x: maxCX + gap, y: cyMid, dist: Math.max(0, maxCX + gap - viewR) },
{ x: cxMid, y: maxCY + gap, dist: Math.max(0, maxCY + gap - viewB) },
{ x: minCX - gap - preferred.width, y: cyMid, dist: Math.max(0, viewport.x - (minCX - gap - preferred.width)) },
{ x: cxMid, y: minCY - gap - preferred.height, dist: Math.max(0, viewport.y - (minCY - gap - preferred.height)) },
];
candidates.sort((a, b) => a.dist - b.dist);
return { x: candidates[0].x, y: candidates[0].y, ...preferred };
};
export default findEmptySpot;