Release BV NodePack 0.18.1
This commit is contained in:
@@ -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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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"}
|
||||
|
||||
@@ -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:/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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"');
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bv_nodepack",
|
||||
"private": true,
|
||||
"version": "0.18.0",
|
||||
"version": "0.18.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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(); }}
|
||||
/>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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] })));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user