Release BV NodePack 0.18.1

This commit is contained in:
BlackVortexAI
2026-08-23 12:42:33 +02:00
parent c8964f0077
commit c59c1acc16
16 changed files with 179 additions and 48 deletions
+9
View File
@@ -1208,6 +1208,15 @@ Technical references:
## Changelog
### 2026-08-23 — v0.18.1
- Restore pointer interaction for every Regional Editor drawing tool after the
shared BV toolbar migration.
- Close polygon drawings by clicking the initial point once at least three
vertices have been placed.
- Fix autocomplete styling and caret-relative placement in long, scrolled or
transformed text areas, and refresh suggestions when the cursor moves.
### 2026-08-22 — v0.18.0
- Move Smart Pipe and Smart Pipe Merge configuration into the shared BV window
+24 -24
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "bv_nodepack"
version = "0.18.0"
version = "0.18.1"
description = "Visual regional prompting with sequential regional detailer plans, detector-aware Impact integration, regional LoRA hooks, native composition modes and model-specific attention backends, plus Smart Pipes and ComfyUI workflow tools."
authors = [
{name = "BlackVortex", email = "blackvortexx@proton.me"}
+11
View File
@@ -15,5 +15,16 @@ test("global completion targets native multiline widgets and provides an explici
test("global completion reevaluates suggestions when the caret moves", () => {
assert.match(source, /addEventListener\("select", search\)/);
assert.match(source, /removeEventListener\("select", search\)/);
assert.match(source, /document\.addEventListener\("selectionchange", selectionChanged\)/);
assert.match(source, /document\.removeEventListener\("selectionchange", selectionChanged\)/);
assert.match(promptTextareaSource, /onSelect=\{event => \{ search\(/);
assert.match(promptTextareaSource, /document\.addEventListener\("selectionchange", selectionChanged\)/);
assert.match(promptTextareaSource, /document\.removeEventListener\("selectionchange", selectionChanged\)/);
});
test("BV prompt completion follows scrolling fields and corrects for measured popup height", () => {
assert.match(promptTextareaSource, /addEventListener\("scroll",\s*position,\s*true\)/);
assert.match(promptTextareaSource, /addEventListener\("resize",\s*position\)/);
assert.match(promptTextareaSource, /onHeight=/);
assert.match(source, /onHeight:/);
});
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import { textareaCaretRect } from "../ui/src/completion/position.ts";
test("caret positioning preserves textarea layout before mapping through canvas zoom", () => {
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
let mirror;
const marker = {
style: {},
getBoundingClientRect: () => mirror.style.width === "600px"
? { left: 200, top: 80 }
: { left: 250, top: 180 },
};
mirror = {
style: {},
setAttribute: () => {},
append: () => {},
remove: () => {},
getBoundingClientRect: () => ({ left: 0, top: 0 }),
scrollTop: 0,
scrollLeft: 0,
textContent: "",
};
globalThis.document = { createElement: tag => tag === "span" ? marker : mirror, body: { append: () => {} } };
globalThis.window = {
getComputedStyle: () => ({ boxSizing: "border-box", lineHeight: "20px", fontSize: "16px" }),
};
const textarea = {
value: "long transformed prompt",
selectionStart: 12,
scrollTop: 0,
scrollLeft: 0,
offsetWidth: 600,
offsetHeight: 200,
getBoundingClientRect: () => ({ left: 100, top: 50, width: 300, height: 100 }),
};
try {
assert.deepEqual(textareaCaretRect(textarea), { left: 200, top: 90, bottom: 100 });
} finally {
globalThis.document = originalDocument;
globalThis.window = originalWindow;
}
});
+8 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { addGeometryLayerTarget, regionsInPaintOrder, regionsInPriorityOrder, shouldAppendFinalBrushPoint, shouldStartSelectionMove, synchronizeRegionPriorities } from "../ui/src/regional/interaction.ts";
import { addGeometryLayerTarget, regionsInPaintOrder, regionsInPriorityOrder, shouldAppendFinalBrushPoint, shouldClosePolygon, shouldStartSelectionMove, synchronizeRegionPriorities } from "../ui/src/regional/interaction.ts";
import { mergeSelectedLayers, selectLayers, splitCompoundLayer } from "../ui/src/regional/layerOperations.ts";
import { connectedAreas } from "../ui/src/regional/connectedComponents.ts";
@@ -31,6 +31,13 @@ test("only drawing appends the pointer-up point to a brush stroke", () => {
assert.equal(shouldAppendFinalBrushPoint("resize"), false);
});
test("polygon closes when a later click returns to its first point", () => {
const first = { x: 120, y: 80 };
assert.equal(shouldClosePolygon(3, first, { x: 126, y: 86 }), true);
assert.equal(shouldClosePolygon(3, first, { x: 140, y: 80 }), false);
assert.equal(shouldClosePolygon(2, first, first), false);
});
test("add geometry extends the selected editable layer regardless of its existing geometry type", () => {
const layer = { id: "brush-layer", enabled: true, authoring: { locked: false }, geometries: [{ type: "brush_stroke" }] };
assert.equal(addGeometryLayerTarget(layer), "brush-layer");
+15
View File
@@ -327,6 +327,21 @@ test("regional toolbar uses compact framework buttons and theme-colored outline
assert.match(styles, /\.bv-toolbar \.bv-button>svg\s*\{[^}]*fill:none[^}]*stroke:currentColor/s);
});
test("regional drawing toolbar remains pointer-interactive inside the passive artboard overlay", () => {
assert.match(styles, /\.artboard-tools\s*\{[^}]*pointer-events:\s*none/s);
assert.match(styles, /\.artboard-tools\s+\.bv-toolbar\s*\{[^}]*pointer-events:\s*auto/s);
});
test("active polygon input closes from a single click near its first screen point", () => {
assert.match(regionalEditor, /shouldClosePolygon\([^)]*event\.clientX[^)]*event\.clientY[^)]*\)\)\s*\{\s*finalizePolygon\(\)/s);
});
test("completion popup overrides generic button layout with compact left-aligned BV styling", () => {
assert.match(styles, /\.bv-completion-popup \.bv-button\s*\{[^}]*justify-content:stretch[^}]*justify-items:start/s);
assert.match(styles, /\.bv-completion-popup button\s*\{[^}]*color:\s*var\(--bv-ui-text\)/s);
assert.match(styles, /\.bv-completion-popup small\s*\{[^}]*color:\s*var\(--bv-ui-muted\)/s);
});
test("regional default layout separates geometric tools left from prompting right", () => {
const selectionStart = options.indexOf('activeTab !== "selection"');
const regionStart = options.indexOf('activeTab !== "region"');
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bv_nodepack",
"version": "0.17.0",
"version": "0.18.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "bv_nodepack",
"version": "0.17.0",
"version": "0.18.1",
"dependencies": {
"flexlayout-react": "0.10.5",
"prismjs": "^1.30.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "bv_nodepack",
"private": true,
"version": "0.18.0",
"version": "0.18.1",
"type": "module",
"scripts": {
"build": "vite build",
+6 -4
View File
@@ -1,9 +1,11 @@
import type { CSSProperties } from "react";
import { useLayoutEffect, useRef, type CSSProperties } from "react";
import type { CompletionSuggestion } from "./engine";
import { Button } from "../ui";
export function CompletionPopup({ suggestions, selected, position, onAccept }: { suggestions: CompletionSuggestion[]; selected: number; position: { left: number; top: number; width: number }; onAccept: (index: number) => void }) {
return <div className="bv-completion-popup bv-global-completion-popup" style={position as CSSProperties} role="listbox">
{suggestions.map((item, index) => <Button intent="ghost" key={item.id} className={index === selected ? "active" : ""} onPointerDown={event => { event.preventDefault(); onAccept(index); }}><span>{item.label}</span><small>{[item.category, item.detail, item.source].filter(Boolean).join(" · ")}</small></Button>)}
export function CompletionPopup({ suggestions, selected, position, onAccept, onHeight }: { suggestions: CompletionSuggestion[]; selected: number; position: { left: number; top: number; width: number }; onAccept: (index: number) => void; onHeight?: (height: number) => void }) {
const popup = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { if (popup.current) onHeight?.(popup.current.offsetHeight); });
return <div ref={popup} className="bv-completion-popup bv-global-completion-popup" style={position as CSSProperties} role="listbox">
{suggestions.map((item, index) => <Button density="compact" intent="ghost" key={item.id} className={index === selected ? "active" : ""} onPointerDown={event => { event.preventDefault(); onAccept(index); }}><span>{item.label}</span><small>{[item.category, item.detail, item.source].filter(Boolean).join(" · ")}</small></Button>)}
</div>;
}
+23 -2
View File
@@ -18,8 +18,11 @@ export default function PromptTextarea({ value, onValue, completionContext, onKe
const requestRef = useRef<ReturnType<typeof completionRequest>>(null);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<number | null>(null);
const popupHeightRef = useRef(210);
const searchRef = useRef<(text: string, caret: number, selectionEnd?: number) => void>(() => {});
const close = () => { abortRef.current?.abort(); setSuggestions([]); setPopup(null); };
const position = () => { const element = textarea.current; if (element) setPopup(completionPopupPosition(element, placement, popupHeightRef.current)); };
const search = (text: string, caret: number, selectionEnd = caret) => {
if (!enabled) return close();
if (caret !== selectionEnd) return close();
@@ -37,6 +40,7 @@ export default function PromptTextarea({ value, onValue, completionContext, onKe
setPopup(items.length && element ? completionPopupPosition(element, placement) : null);
}, 120);
};
searchRef.current = search;
const accept = (index = selected) => {
const request = requestRef.current, suggestion = suggestions[index];
if (!request || !suggestion) return;
@@ -46,7 +50,18 @@ export default function PromptTextarea({ value, onValue, completionContext, onKe
};
useEffect(() => { if (!enabled) close(); }, [enabled]);
useEffect(() => { if (popup && textarea.current) setPopup(completionPopupPosition(textarea.current, placement)); }, [placement]);
useEffect(() => {
const selectionChanged = () => { const element = textarea.current; if (element && document.activeElement === element) searchRef.current(element.value, element.selectionStart, element.selectionEnd); };
document.addEventListener("selectionchange", selectionChanged);
return () => document.removeEventListener("selectionchange", selectionChanged);
}, []);
useEffect(() => { if (popup) position(); }, [placement]);
useEffect(() => {
if (!popup) return;
window.addEventListener("scroll", position, true);
window.addEventListener("resize", position);
return () => { window.removeEventListener("scroll", position, true); window.removeEventListener("resize", position); };
}, [Boolean(popup), placement]);
useEffect(() => () => { abortRef.current?.abort(); if (timerRef.current != null) window.clearTimeout(timerRef.current); }, []);
return <>
<span className="bv-textarea-shell resize-vertical"><TextareaControl {...props} ref={textarea} value={value} onChange={event => { onValue(event.target.value); search(event.target.value, event.target.selectionStart, event.target.selectionEnd); }} onSelect={event => { search(event.currentTarget.value, event.currentTarget.selectionStart, event.currentTarget.selectionEnd); onSelect?.(event); }} onKeyDown={event => {
@@ -57,6 +72,12 @@ export default function PromptTextarea({ value, onValue, completionContext, onKe
}
onKeyDown?.(event);
}} onBlur={event => { window.setTimeout(close, 120); onBlur?.(event); }}/></span>
{enabled && popup && <CompletionPopup suggestions={suggestions} selected={selected} position={popup} onAccept={accept}/>}
{enabled && popup && <CompletionPopup
suggestions={suggestions}
selected={selected}
position={popup}
onAccept={accept}
onHeight={height => { if (Math.abs(height - popupHeightRef.current) < 1) return; popupHeightRef.current = height; position(); }}
/>}
</>;
}
+7 -3
View File
@@ -25,6 +25,7 @@ function attach(textarea: HTMLTextAreaElement) {
let timer: number | null = null;
let popup: HTMLDivElement | null = null;
let popupRoot: Root | null = null;
let popupHeight = 210;
const close = () => {
abort?.abort();
@@ -38,12 +39,12 @@ function attach(textarea: HTMLTextAreaElement) {
};
const position = () => {
if (!popup) return;
const next = completionPopupPosition(textarea, completionPlacement(), popup.offsetHeight || 210);
const next = completionPopupPosition(textarea, completionPlacement(), popupHeight);
renderPopup(next);
};
const renderPopup = (coordinates = completionPopupPosition(textarea, completionPlacement(), popup?.offsetHeight || 210)) => {
const renderPopup = (coordinates = completionPopupPosition(textarea, completionPlacement(), popupHeight)) => {
if (!popup) { popup = document.createElement("div"); popup.className = "bv-ui bv-ui-portal"; document.body.append(popup); popupRoot = createRoot(popup); }
popupRoot!.render(createElement(CompletionPopup, { suggestions, selected, position: coordinates, onAccept: accept }));
popupRoot!.render(createElement(CompletionPopup, { suggestions, selected, position: coordinates, onAccept: accept, onHeight: (height: number) => { if (Math.abs(height - popupHeight) < 1) return; popupHeight = height; position(); } }));
};
const render = () => renderPopup();
const accept = (index = selected) => {
@@ -87,8 +88,10 @@ function attach(textarea: HTMLTextAreaElement) {
}
};
const blur = () => window.setTimeout(() => { if (!popup?.contains(document.activeElement)) close(); }, 120);
const selectionChanged = () => { if (document.activeElement === textarea) search(); };
textarea.addEventListener("input", search);
textarea.addEventListener("select", search);
document.addEventListener("selectionchange", selectionChanged);
textarea.addEventListener("keydown", keydown, true);
textarea.addEventListener("blur", blur);
window.addEventListener("resize", position);
@@ -97,6 +100,7 @@ function attach(textarea: HTMLTextAreaElement) {
close();
textarea.removeEventListener("input", search);
textarea.removeEventListener("select", search);
document.removeEventListener("selectionchange", selectionChanged);
textarea.removeEventListener("keydown", keydown, true);
textarea.removeEventListener("blur", blur);
window.removeEventListener("resize", position);
+10 -3
View File
@@ -11,12 +11,16 @@ const COPIED_STYLES = [
export function textareaCaretRect(textarea: HTMLTextAreaElement, caret = textarea.selectionStart ?? textarea.value.length) {
const bounds = textarea.getBoundingClientRect();
const style = window.getComputedStyle(textarea);
const layoutWidth = textarea.offsetWidth || bounds.width;
const layoutHeight = textarea.offsetHeight || bounds.height;
const scaleX = bounds.width / layoutWidth;
const scaleY = bounds.height / layoutHeight;
const mirror = document.createElement("div");
mirror.setAttribute("aria-hidden", "true");
Object.assign(mirror.style, {
position: "fixed", visibility: "hidden", pointerEvents: "none",
left: `${bounds.left}px`, top: `${bounds.top}px`, width: `${bounds.width}px`,
height: `${bounds.height}px`, boxSizing: style.boxSizing,
left: "0", top: "0", width: `${layoutWidth}px`,
height: `${layoutHeight}px`, boxSizing: style.boxSizing,
whiteSpace: "pre-wrap", overflowWrap: "break-word", overflow: "hidden",
});
COPIED_STYLES.forEach(property => { mirror.style[property] = style[property]; });
@@ -27,10 +31,13 @@ export function textareaCaretRect(textarea: HTMLTextAreaElement, caret = textare
document.body.append(mirror);
mirror.scrollTop = textarea.scrollTop;
mirror.scrollLeft = textarea.scrollLeft;
const mirrorRect = mirror.getBoundingClientRect();
const markerRect = marker.getBoundingClientRect();
const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.2 || 16;
mirror.remove();
return { left: markerRect.left, top: markerRect.top, bottom: markerRect.top + lineHeight };
const left = bounds.left + (markerRect.left - mirrorRect.left) * scaleX;
const top = bounds.top + (markerRect.top - mirrorRect.top) * scaleY;
return { left, top, bottom: top + lineHeight * scaleY };
}
export function completionPopupPosition(textarea: HTMLTextAreaElement, placement: CompletionPlacement, popupHeight = 210): CompletionPopupPosition {
+5 -3
View File
@@ -221,11 +221,12 @@
@media(max-width:760px){.bv-toolbar-window-launcher{width:min(320px,calc(100vw - 24px))}.bv-toolbar-window-launcher__body{grid-template-columns:minmax(190px,1fr)}.bv-toolbar-window-launcher section+section{border-top:1px solid var(--bv-ui-border);border-left:0}}
.bv-toolbar-window-launcher { z-index:var(--bv-layer-flyout); }
.bv-completion-popup { position:fixed; z-index:var(--bv-layer-flyout); max-height:calc(240px * var(--bv-ui-scale)); overflow:auto; padding:4px; background:var(--bv-ui-input); border:1px solid var(--bv-ui-border-hover); border-radius:var(--bv-ui-radius); box-shadow:0 10px 32px #000c; font:calc(12px * var(--bv-ui-scale))/1.4 system-ui,-apple-system,"Segoe UI",sans-serif; }
.bv-completion-popup button { display: grid; width: 100%; gap: 1px; padding: 6px 8px; color: var(--bv-text-color); text-align: left; background: transparent; border: 0; border-radius: var(--bv-ui-radius-sm); cursor: pointer; }
.bv-completion-popup { position:fixed; z-index:var(--bv-layer-flyout); max-height:calc(240px * var(--bv-ui-scale)); overflow:auto; padding:4px; color:var(--bv-ui-text); background:var(--bv-ui-input); border:1px solid var(--bv-ui-border-hover); border-radius:var(--bv-ui-radius); box-shadow:0 10px 32px #000c; font:calc(12px * var(--bv-ui-scale))/1.4 system-ui,-apple-system,"Segoe UI",sans-serif; }
.bv-completion-popup button { display:grid; width:100%; gap:0; padding:5px 8px; color:var(--bv-ui-text); text-align:left; background:transparent; border:0; border-radius:var(--bv-ui-radius-sm); cursor:pointer; }
.bv-completion-popup .bv-button { min-height:0; justify-content:stretch; justify-items:start; }
.bv-completion-popup button:hover, .bv-completion-popup button.active { background:rgba(141,112,255,.24); color:var(--bv-ui-text); }
.bv-completion-popup button:hover small, .bv-completion-popup button.active small { color: #dcefff; }
.bv-completion-popup small { overflow: hidden; color: var(--bv-muted-text-color); text-overflow: ellipsis; white-space: nowrap; }
.bv-completion-popup small { overflow:hidden; color:var(--bv-ui-muted); font-size:calc(10px * var(--bv-ui-scale)); text-overflow:ellipsis; white-space:nowrap; }
.completion-menu { min-width: 320px; }
.completion-dataset-picker { display: grid; gap: 6px; padding: 5px 8px 8px; }
.completion-dataset-picker > label { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 7px; }
@@ -394,6 +395,7 @@
.bv-ui-window-shelf-icon { display:grid; place-items:center; width:calc(24px * var(--bv-ui-scale)); height:calc(24px * var(--bv-ui-scale)); color:#d8d0ff; background:rgba(141,112,255,.14); border-radius:var(--bv-ui-radius-sm); }
.artboard-tools { position: absolute; z-index: 12; top: 12px; left: 12px; display: flex; align-items: flex-start; gap: 7px; pointer-events: none; }
.artboard-tools .bv-toolbar { pointer-events: auto; }
.zoom-controls { position: absolute; z-index: 12; bottom: 12px; left: 50%; display: flex; gap: 3px; padding: 4px; border: 1px solid #ffffff2b; border-radius: var(--bv-ui-radius); background: color-mix(in srgb, var(--comfy-menu-bg, #20242a) 92%, transparent); box-shadow: 0 8px 25px #0009; backdrop-filter: blur(8px); transform: translateX(-50%); }
.zoom-controls button { min-width: 32px; height: 28px; padding: 3px 8px; border-color: transparent; background: transparent; }
.zoom-controls button:nth-child(2) { min-width: 66px; }
+9 -4
View File
@@ -4,7 +4,7 @@ import EditorMenus from "./EditorMenus";
import OptionsPanel from "./OptionsPanel";
import type { BrushSettings, Tool } from "../ui/components";
import { Bounds, boundsOfLayer, Handle, hitTest, moveLayer, resizeLayer, setLayerBounds, simplifyPoints } from "./geometry";
import { addGeometryLayerTarget, regionsInPriorityOrder, shouldAppendFinalBrushPoint, shouldStartSelectionMove, synchronizeRegionPriorities } from "./interaction";
import { addGeometryLayerTarget, regionsInPriorityOrder, shouldAppendFinalBrushPoint, shouldClosePolygon, shouldStartSelectionMove, synchronizeRegionPriorities } from "./interaction";
import { automaticRegionColor, clone, Geometry, geometryAuthoring, geometryLayerId, geometryLayers, geometryMaskGroupId, newRegion, parseDocument, Point, preserveDocumentIdentity, RegionalDocument, Region, uuid } from "./model";
import { mergeSelectedLayers, remapMaskGroups, selectLayers } from "./layerOperations";
import { activeWindowGeometry, clampWindowGeometry, defaultEditorState, EditorViewState, loadEditorState, saveEditorState } from "./editorState";
@@ -25,7 +25,8 @@ type NodeRef = { id: number | string; title?: string; widgets?: Array<{ name: st
const keyFor=(node:NodeRef|null|undefined)=>scopedNodeKey((getApp() as any).rootGraph??(getApp() as any).graph,node);
type Props = { open: boolean; activationToken?:number; nodes: NodeRef[]; initialNode: NodeRef | null; backgrounds: Record<string, string>; loraStacks: NamedLoraStack[]; onClose: () => void };
type Gesture =
| { mode: "draw-box" | "draw-brush" | "draw-polygon"; start: Point; commit: "add" | "subtract" }
| { mode: "draw-box" | "draw-brush"; start: Point; commit: "add" | "subtract" }
| { mode: "draw-polygon"; start: Point; startScreen: { x: number; y: number }; commit: "add" | "subtract" }
| { mode: "move"; start: Point; startScreen: { x: number; y: number }; original: Geometry[]; started: boolean }
| { mode: "resize"; start: Point; original: Geometry[]; handle: Handle };
@@ -246,7 +247,11 @@ export default function RegionalEditor({ open, activationToken=0, nodes, initial
}
const subtract = tool.endsWith("subtract");
if (subtract && (!selectedLayerId || selectedLayer?.authoring.locked)) return;
if (tool.startsWith("polygon") && gesture.current?.mode === "draw-polygon") { setDraft(current => current?.map(item => item.type === "polygon" ? { ...item, points: [...item.points, point] } : item) ?? null); return; }
if (tool.startsWith("polygon") && gesture.current?.mode === "draw-polygon") {
const pointCount = draft?.find(item => item.type === "polygon")?.points.length ?? 0;
if (shouldClosePolygon(pointCount, gesture.current.startScreen, { x: event.clientX, y: event.clientY })) { finalizePolygon(); return; }
setDraft(current => current?.map(item => item.type === "polygon" ? { ...item, points: [...item.points, point] } : item) ?? null); return;
}
const id = uuid(), reusableLayerId = !subtract ? addGeometryLayerTarget(selectedLayer) : null;
const layerId = subtract ? selectedLayerId! : reusableLayerId ?? id;
const shapeName = tool.startsWith("rect") ? "Rectangle" : tool.startsWith("ellipse") ? "Ellipse" : tool.startsWith("polygon") ? "Polygon" : "Brush";
@@ -259,7 +264,7 @@ export default function RegionalEditor({ open, activationToken=0, nodes, initial
gesture.current = { mode: "draw-box", start: point, commit: subtract ? "subtract" : "add" }; if (!subtract) replaceLayerSelection(layerId);
} else if (tool.startsWith("polygon")) {
setDraft(maskGroups.map((maskGroupId, index) => ({ id: index ? uuid() : id, layer_id: layerId, mask_group_id: maskGroupId, type: "polygon", operation: subtract ? "subtract" : "add", enabled: true, authoring, points: [point] })));
gesture.current = { mode: "draw-polygon", start: point, commit: subtract ? "subtract" : "add" }; if (!subtract) replaceLayerSelection(layerId);
gesture.current = { mode: "draw-polygon", start: point, startScreen: { x: event.clientX, y: event.clientY }, commit: subtract ? "subtract" : "add" }; if (!subtract) replaceLayerSelection(layerId);
} else {
event.currentTarget.setPointerCapture(event.pointerId);
setDraft(maskGroups.map((maskGroupId, index) => ({ id: index ? uuid() : id, layer_id: layerId, mask_group_id: maskGroupId, type: "brush_stroke", operation: tool === "brush-add" ? "add" : "subtract", enabled: true, authoring, size: brush.size, hardness: brush.hardness, opacity: brush.opacity, shape: brush.shape, pressure_mode: brush.pressureMode, points: [point] })));
+4
View File
@@ -15,6 +15,10 @@ export function shouldStartSelectionMove(start: ScreenPoint, current: ScreenPoin
return Math.hypot(current.x - start.x, current.y - start.y) >= threshold;
}
export function shouldClosePolygon(pointCount: number, start: ScreenPoint, current: ScreenPoint, threshold = 12) {
return pointCount >= 3 && Math.hypot(current.x - start.x, current.y - start.y) <= threshold;
}
export function regionsInPaintOrder<T extends { priority: number }>(regions: T[]) {
return [...regions].sort((left, right) => right.priority - left.priority);
}