feat: corner click in edge-dock starts single-axis resize (exit dock); corners/center not dock

This commit is contained in:
Hermes Agent committed 2026-09-10 15:41:04 +02:00
1 parent 2dcb572b12
commit ccc38a0561
2 files changed
+99 -15

No files matched your search

+32 -8
View File
@@ -160,7 +160,7 @@ export default function () {
// y = startY + (top? dy/zoom : 0)
// Это устраняет "скачки": позиция не зависит от текущего кадра, а размер и позиция
// переведены в одни единицы (px для размера, canvas/zoom для позиции).
let resizeAnchor: { x: number; y: number; w: number; h: number; dir: string } | null = null
let resizeAnchor: { x: number; y: number; w: number; h: number; dir: string; fixedAxis?: 'none' | 'x' | 'y' } | null = null
function getPos () {
try {
const p = (figma.ui as any).getPosition()
@@ -169,25 +169,35 @@ export default function () {
return { x: 0, y: 0 }
}
}
on('RESIZE_START', function (payload: { dir: string; startW: number; startH: number }) {
on('RESIZE_START', function (payload: { dir: string; startW: number; startH: number; fixedAxis?: 'none' | 'x' | 'y' }) {
const p = getPos()
resizeAnchor = {
x: p.x,
y: p.y,
w: payload.startW,
h: payload.startH,
dir: payload.dir
dir: payload.dir,
fixedAxis: payload.fixedAxis || 'none'
}
})
on('RESIZE_WINDOW_DRAG', function (payload: { dx: number; dy: number; dir: string }) {
on('RESIZE_WINDOW_DRAG', function (payload: { dx: number; dy: number; dir: string; fixedAxis?: 'none' | 'x' | 'y' }) {
if (resizeAnchor == null) return
const { dx, dy, dir } = payload
const fixed = payload.fixedAxis || resizeAnchor.fixedAxis || 'none'
let w = resizeAnchor.w
let h = resizeAnchor.h
if (dir.indexOf('right') !== -1) w = resizeAnchor.w + dx
if (dir.indexOf('left') !== -1) w = resizeAnchor.w - dx
if (dir.indexOf('bottom') !== -1) h = resizeAnchor.h + dy
if (dir.indexOf('top') !== -1) h = resizeAnchor.h - dy
// fixedAxis: меняем только ПЕРПЕНДИКУЛЯРНУЮ оси (в доке растянута противоположная
// сжатой). fixedAxis='y' (док сжал высоту) → двигаем ширину; 'x' → высоту.
const useX = fixed === 'none' || fixed === 'y'
const useY = fixed === 'none' || fixed === 'x'
if (useX) {
if (dir.indexOf('right') !== -1) w = resizeAnchor.w + dx
if (dir.indexOf('left') !== -1) w = resizeAnchor.w - dx
}
if (useY) {
if (dir.indexOf('bottom') !== -1) h = resizeAnchor.h + dy
if (dir.indexOf('top') !== -1) h = resizeAnchor.h - dy
}
const size = clampWindowSize({ width: w, height: h })
figma.ui.resize(size.width, size.height)
lastSize = { width: size.width, height: size.height }
@@ -198,12 +208,18 @@ export default function () {
// Ресайз окна плагина (кастомные ручки по периметру -> RESIZE_WINDOW по pointerup)
on('RESIZE_WINDOW', function (windowSize: { width: number; height: number }) {
const wasFixedAxis = resizeAnchor != null ? (resizeAnchor.fixedAxis || 'none') : 'none'
resizeAnchor = null
const { width, height } = clampWindowSize(windowSize)
lastSize = { width, height }
figma.ui.resize(width, height)
// Если окно задокено/в центре — переприжимаем ПОЗИЦИЮ к краю/центру, но размер
// НЕ трогаем (canonical=false), чтобы ручной ресайз не сбрасывался на отпускании.
// ИСКЛЮЧЕНИЕ: single-axis ресайз из дока (fixedAxis!='none') — окно "выезжает" из
// дока, не ре-докаем (иначе вернётся к канону и сбросит ручной размер).
if (wasFixedAxis !== 'none') {
return
}
if (dockedPosition !== null) {
applyDock(dockedPosition, width, height, false)
} else if (centerLocked) {
@@ -212,6 +228,14 @@ export default function () {
}
})
// Выход из дока (ручной single-axis ресайз угла через UI): сбрасываем док-состояние,
// чтобы поллинг не переприжимал и RESIZE_WINDOW не ре-докал.
on('UNDOCK', function () {
dockedPosition = null
centerLocked = false
lastBoundsKey = ''
})
// Эмуляция докинга: растянуть окно на зону и прижать к её краю (9 позиций).
// Двухэтапно, чтобы позиция считалась от РЕАЛЬНОГО размера, который Figma
// фактически отрисовала (она клампит resize до своего лимита при видимом UI).
+67 -7
View File
@@ -50,7 +50,14 @@ function Section (props: { title: string; children: preact.ComponentChildren })
function Plugin () {
const [currentWindowSize, setCurrentWindowSize] = useState(MIN_WINDOW_SIZE)
const resizeRef = useRef({ active: false, dir: '', startX: 0, startY: 0, startW: 0, startH: 0 })
const resizeRef = useRef({ active: false, dir: '', startX: 0, startY: 0, startW: 0, startH: 0, fixedAxis: 'none' as 'none' | 'x' | 'y' })
// Текущая док-позиция (для детекта "окно в доке у края" в UI). Центр и углы НЕ пишем —
// они не считаются доком (окно там просто лежит, не растянуто).
const dockedPosRef = useRef<DockPosition | null>(null)
// Флаг: ресайз выводит окно из дока (на отпускании не ре-докать, очистить dock-состояние).
const exitingDockRef = useRef(false)
// Та же величина, что DOCK_EDGE_SIZE в main.ts — признак "сжатая ось дока".
const DOCK_EDGE = 320
const [windowInfo, setWindowInfo] = useState<{
x: number; y: number; width: number; height: number
zoom: number
@@ -65,18 +72,19 @@ function Plugin () {
// Кастомный ресайз по ВСЕМУ периметру (8 невидимых ручек: 4 края + 4 угла).
// Левый/верхний края двигают окно (main двигает через reposition), правый/нижний — только resize.
// Live-обновление: RESIZE_WINDOW_DRAG во время drag'а, финальный RESIZE_WINDOW по pointerup.
function startResize (e: PointerEvent, dir: string) {
function startResize (e: PointerEvent, dir: string, fixedAxis: 'none' | 'x' | 'y' = 'none') {
e.preventDefault()
e.stopPropagation()
resizeRef.current.active = true
resizeRef.current.dir = dir
resizeRef.current.fixedAxis = fixedAxis
resizeRef.current.startX = e.clientX
resizeRef.current.startY = e.clientY
resizeRef.current.startW = window.innerWidth
resizeRef.current.startH = window.innerHeight
// Сообщаем main зафиксировать якорь (старт-позиция + старт-размер) ДО первого движения.
// Размер берём из window (UI-поток), т.к. в main-потоке window нет.
emit('RESIZE_START', { dir: dir, startW: window.innerWidth, startH: window.innerHeight })
emit('RESIZE_START', { dir: dir, startW: window.innerWidth, startH: window.innerHeight, fixedAxis: fixedAxis })
}
function onPointerMove (e: PointerEvent) {
@@ -84,7 +92,7 @@ function Plugin () {
const dir = resizeRef.current.dir
const dx = e.clientX - resizeRef.current.startX
const dy = e.clientY - resizeRef.current.startY
emit('RESIZE_WINDOW_DRAG', { dx: dx, dy: dy, dir: dir })
emit('RESIZE_WINDOW_DRAG', { dx: dx, dy: dy, dir: dir, fixedAxis: resizeRef.current.fixedAxis })
}
function onPointerUpResize () {
@@ -94,14 +102,36 @@ function Plugin () {
const h = window.innerHeight
setCurrentWindowSize({ width: w, height: h })
emit('RESIZE_WINDOW', { width: w, height: h })
// Если ресайз выводил окно из дока — больше не держим док-состояние (чтобы
// поллинг не переприжимал и RESIZE_WINDOW не ре-докал). Обычный (не из дока) ресайз
// сам держит centerLocked/dock через applyDock в main.
if (exitingDockRef.current) {
exitingDockRef.current = false
dockedPosRef.current = null
emit('UNDOCK', {})
}
refreshInfo()
}
// Клик-или-драг на периметральной ручке:
// - смещение курсора > CLICK_THRESHOLD px → ресайз (startResize + live drag через глобальный onPointerMove)
// - иначе (чистый клик) → док в соответствующую позицию (dockWindow)
// - иначе (чистый клик) → зависит от позиции:
// * угол (top-left/right, bottom-left/right):
// - окно НЕ в доке у края → телепорт в угол (dockWindow)
// - окно В ДОКЕ у края (одна ось сжата до DOCK_EDGE) → старт single-axis live-ресайза
// сжатой оси (окно "выезжает" в угол, выход из дока), dir = угол, fixedAxis = сжатая ось
// * край (top/bottom-center, center-left/right) → телепорт в край (dockWindow)
// Центр НЕ входит в периметр — только кнопкой.
const CLICK_THRESHOLD = 4
const isCornerPos = (p: DockPosition) =>
p === 'top-left' || p === 'top-right' || p === 'bottom-left' || p === 'bottom-right'
// Окно "в доке у края" = одна ось сжата до DOCK_EDGE, другая существенно больше.
function isEdgeDocked () {
const w = window.innerWidth
const h = window.innerHeight
return (w <= DOCK_EDGE + 4 && h > DOCK_EDGE + 4) || (h <= DOCK_EDGE + 4 && w > DOCK_EDGE + 4)
}
function onHandleDown (e: PointerEvent, dir: string, dockPos: DockPosition) {
e.preventDefault()
e.stopPropagation()
@@ -114,7 +144,7 @@ function Plugin () {
const dy = ev.clientY - startY
if (Math.sqrt(dx * dx + dy * dy) < CLICK_THRESHOLD) return
started = true
startResize(ev, dir)
startResize(ev, dir, 'none')
// Считаем ресайз от исходной точки (до порога), а не от текущей (>порога).
resizeRef.current.startX = startX
resizeRef.current.startY = startY
@@ -125,7 +155,31 @@ function Plugin () {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
if (started) onPointerUpResize()
else dockWindow(dockPos)
else {
// Чистый клик — поведение зависит от позиции ручки и состояния дока.
if (isCornerPos(dockPos)) {
if (isEdgeDocked()) {
// Окно в доке у края: клик по смежному углу = single-axis ресайз сжатой оси.
// Сжатая ось: верх/низ-док (h сжата) → фикс Y (меняем ширину);
// лево/право-док (w сжата) → фикс X (меняем высоту).
const fixedAxis = window.innerHeight <= DOCK_EDGE + 4 ? 'y' : 'x'
exitingDockRef.current = true
startResize(e, dir, fixedAxis) // active=true + emit RESIZE_START (старт от точки клика)
// Финализатор на СЛЕДУЮЩИЙ pointerup (live-ресайз ведёт глобальный onPointerMove).
const finish = function () {
window.removeEventListener('pointerup', finish)
onPointerUpResize()
}
window.addEventListener('pointerup', finish)
return
}
// Не в доке: телепорт в угол.
dockWindow(dockPos)
} else {
// Краевая ручка: телепорт в край.
dockWindow(dockPos)
}
}
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
@@ -277,6 +331,12 @@ function Plugin () {
| 'bottom-left' | 'bottom-center' | 'bottom-right'
function dockWindow (position: DockPosition) {
// Запоминаем док-позицию только для КРАЁВ (растянуто у края). Углы и центр — не док
// (окно там просто лежит), поэтому dockedPosRef оставляем как есть / не трогаем.
const isEdge = position === 'top-center' || position === 'bottom-center' ||
position === 'center-left' || position === 'center-right'
if (isEdge) dockedPosRef.current = position
else if (position !== 'center') dockedPosRef.current = null
// Этап 1: просим main растянуть (попытка до канона) и запомнить режим.
// main НЕ позиционирует сразу — позиция считается на этапе 2 от РЕАЛЬНОГО
// размера, который Figma отрисует после клампа (видимый UI режет запрос).