一些检查失败了
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
138 行
5.3 KiB
TypeScript
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;
|