From ccc38a056126feed43572629e7441804f161f139 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 10 Sep 2026 15:41:04 +0200 Subject: [PATCH] feat: corner click in edge-dock starts single-axis resize (exit dock); corners/center not dock --- src/main.ts | 40 +++++++++++++++++++++++------ src/ui.tsx | 74 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/main.ts b/src/main.ts index 766b8d4..8f485e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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). diff --git a/src/ui.tsx b/src/ui.tsx index c2fc33c..475061b 100644 --- a/src/ui.tsx +++ b/src/ui.tsx @@ -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(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 режет запрос).