Canonicalize shared toolbar mechanics

This commit is contained in:
DCCONSTRUCTIONS
2026-07-11 11:35:09 +03:00
parent 888fdbeb69
commit 6b42d913ea
10 changed files with 707 additions and 53 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./accent.js";
export * from "./floating.js";
export * from "./theme.js";
export * from "./toolbar.js";
+106
View File
@@ -0,0 +1,106 @@
export type ToolbarPlacement = "left" | "right" | "bottom";
export interface ToolbarSettings {
minSize: number;
maxSize: number;
lensCount: number;
gap: number;
}
export interface ToolbarTarget {
size: number;
shift: number;
scale: number;
}
export const NODEDC_TOOLBAR_DEFAULTS = Object.freeze({
background: "#111115",
border: "#111117",
outline: "#1c1c1c",
minSize: 25,
maxSize: 87,
lensCount: 5,
gap: 10,
padding: 6,
hideDelay: 5000,
});
export const NODEDC_TOOLBAR_SPRING = Object.freeze({
mass: 0.1,
stiffness: 170,
damping: 12,
maxStep: 1 / 240,
});
const BASE_SCALE = 2.25;
const BASE_DISTANCE = 110;
const BASE_NUDGE = 40;
const NUDGE_TIGHTNESS = 0.82;
export function normalizeToolbarSettings({
minSize = NODEDC_TOOLBAR_DEFAULTS.minSize,
maxSize = NODEDC_TOOLBAR_DEFAULTS.maxSize,
lensCount = NODEDC_TOOLBAR_DEFAULTS.lensCount,
gap = NODEDC_TOOLBAR_DEFAULTS.gap,
}: Partial<ToolbarSettings> = {}): ToolbarSettings {
const safeMin = Math.max(18, Math.min(42, Number.isFinite(minSize) ? minSize : NODEDC_TOOLBAR_DEFAULTS.minSize));
const safeMax = Math.max(safeMin, Math.min(88, Number.isFinite(maxSize) ? maxSize : NODEDC_TOOLBAR_DEFAULTS.maxSize));
const roundedLens = Math.max(1, Math.min(13, Math.round(Number.isFinite(lensCount) ? lensCount : NODEDC_TOOLBAR_DEFAULTS.lensCount)));
return {
minSize: safeMin,
maxSize: safeMax,
lensCount: roundedLens % 2 === 0 ? Math.min(13, roundedLens + 1) : roundedLens,
gap: Math.max(0, Number.isFinite(gap) ? gap : NODEDC_TOOLBAR_DEFAULTS.gap),
};
}
export function stepToolbarSpring(value: number, velocity: number, target: number, dt: number) {
let nextValue = value;
let nextVelocity = velocity;
const duration = Math.min(0.032, Math.max(0.001, dt));
const steps = Math.max(1, Math.ceil(duration / NODEDC_TOOLBAR_SPRING.maxStep));
const step = duration / steps;
for (let index = 0; index < steps; index += 1) {
const displacement = nextValue - target;
const springForce = -NODEDC_TOOLBAR_SPRING.stiffness * displacement;
const dampingForce = -NODEDC_TOOLBAR_SPRING.damping * nextVelocity;
const acceleration = (springForce + dampingForce) / NODEDC_TOOLBAR_SPRING.mass;
nextVelocity += acceleration * step;
nextValue += nextVelocity * step;
}
return [nextValue, nextVelocity] as const;
}
export function calculateToolbarTargets(
centers: readonly number[],
pointerCoordinate: number,
settings: ToolbarSettings,
): ToolbarTarget[] {
const step = settings.minSize + settings.gap;
const lensRadius = Math.max(
settings.minSize,
Math.floor((settings.lensCount - 1) / 2) * step + settings.gap / 2,
);
const maxScale = settings.minSize > 0 ? settings.maxSize / settings.minSize : 1;
const scaledNudge = BASE_NUDGE
* (lensRadius / BASE_DISTANCE)
* (Math.max(0, maxScale - 1) / (BASE_SCALE - 1))
* NUDGE_TIGHTNESS;
return centers.map((center) => {
const distance = pointerCoordinate - center;
const absoluteDistance = Math.abs(distance);
const scale = absoluteDistance >= lensRadius
? 1
: 1 + (maxScale - 1) * (1 - absoluteDistance / lensRadius);
return {
scale,
size: settings.minSize * scale,
shift: absoluteDistance > lensRadius
? Math.sign(distance || 1) * -scaledNudge
: (-distance / lensRadius) * scaledNudge * scale,
};
});
}
+69 -14
View File
@@ -2104,20 +2104,39 @@ textarea.nodedc-field__control {
.nodedc-toolbar {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem;
border: 1px solid var(--nodedc-toolbar-outline);
border-radius: calc(var(--nodedc-toolbar-radius) + 0.42rem);
background: color-mix(in srgb, var(--nodedc-toolbar-bg) 88%, transparent);
box-shadow: 0 10px 28px color-mix(in srgb, var(--nodedc-toolbar-outline) 36%, transparent);
backdrop-filter: blur(24px) saturate(1.12);
-webkit-backdrop-filter: blur(24px) saturate(1.12);
gap: var(--nodedc-toolbar-gap, 10px);
background: transparent;
transition: opacity 180ms ease, transform 220ms ease;
will-change: transform, opacity;
}
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar,
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar {
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar {
width: var(--nodedc-toolbar-min-size);
flex-direction: column;
align-items: flex-start;
padding: var(--nodedc-toolbar-padding, 6px) 0;
}
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar {
width: var(--nodedc-toolbar-min-size);
flex-direction: column;
align-items: flex-end;
padding: var(--nodedc-toolbar-padding, 6px) 0;
}
.nodedc-toolbar-wrap[data-placement="bottom"] .nodedc-toolbar {
height: var(--nodedc-toolbar-min-size);
align-items: flex-end;
padding: 0 var(--nodedc-toolbar-padding, 6px);
}
.nodedc-toolbar-wrap[data-placement="left"][data-active="true"] .nodedc-toolbar,
.nodedc-toolbar-wrap[data-placement="right"][data-active="true"] .nodedc-toolbar {
width: var(--nodedc-toolbar-max-size);
}
.nodedc-toolbar-wrap[data-placement="bottom"][data-active="true"] .nodedc-toolbar {
height: var(--nodedc-toolbar-max-size);
}
.nodedc-toolbar-wrap[data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
@@ -2125,6 +2144,10 @@ textarea.nodedc-field__control {
pointer-events: none;
}
.nodedc-toolbar-wrap[data-autohide="true"]:not([data-visible="true"]) {
pointer-events: none;
}
.nodedc-toolbar-wrap[data-placement="left"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar {
transform: translateX(calc(-100% - 1rem));
}
@@ -2139,6 +2162,7 @@ textarea.nodedc-field__control {
.nodedc-toolbar__hotzone {
position: fixed;
pointer-events: auto;
}
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__hotzone {
@@ -2160,9 +2184,9 @@ textarea.nodedc-field__control {
.nodedc-toolbar__button {
display: grid;
width: var(--nodedc-toolbar-item-size);
height: var(--nodedc-toolbar-item-size);
flex: 0 0 var(--nodedc-toolbar-item-size);
width: var(--nodedc-toolbar-min-size);
height: var(--nodedc-toolbar-min-size);
flex: 0 0 var(--nodedc-toolbar-min-size);
place-items: center;
border: 1px solid var(--nodedc-toolbar-border);
border-radius: var(--nodedc-toolbar-radius);
@@ -2170,7 +2194,32 @@ textarea.nodedc-field__control {
background: var(--nodedc-toolbar-bg);
color: var(--nodedc-toolbar-accent);
padding: 0;
transition: width 120ms ease, height 120ms ease, flex-basis 120ms ease, background 120ms ease, color 120ms ease;
line-height: 1;
cursor: pointer;
user-select: none;
will-change: transform;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease, color 120ms ease;
}
.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__button {
transform: translateY(var(--nodedc-toolbar-shift, 0px)) scale(var(--nodedc-toolbar-scale, 1));
transform-origin: left center;
}
.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar__button {
transform: translateY(var(--nodedc-toolbar-shift, 0px)) scale(var(--nodedc-toolbar-scale, 1));
transform-origin: right center;
}
.nodedc-toolbar-wrap[data-placement="bottom"] .nodedc-toolbar__button {
transform: translateX(var(--nodedc-toolbar-shift, 0px)) scale(var(--nodedc-toolbar-scale, 1));
transform-origin: center bottom;
}
.nodedc-toolbar__button:hover,
.nodedc-toolbar__button:focus-visible {
border-color: var(--nodedc-toolbar-outline);
box-shadow: 0 10px 20px color-mix(in srgb, var(--nodedc-toolbar-outline) 30%, transparent);
}
.nodedc-toolbar__button[data-active="true"] {
@@ -2179,6 +2228,12 @@ textarea.nodedc-field__control {
color: rgb(var(--nodedc-on-accent-rgb));
}
.nodedc-toolbar__button:disabled {
cursor: default;
opacity: 0.45;
box-shadow: none;
}
.nodedc-window.nodedc-share-access-window {
width: min(35rem, calc(100vw - 2rem));
}