Рецепты
Рабочие паттерны для того, что обычно строят на оконном менеджере. Все примеры не зависят от фреймворка, если не сказано обратное.
- Таскбар
- Анимация сворачивания в таскбар
- Модальные диалоги
- Персист и миграции
- Полоса вкладок для сгруппированных окон
- Рабочие столы
- Окна с фиксированными пропорциями
- Свои раскладки снэпа
- Жест одним шагом отмены
- Контекстное меню тайтлбара
- Реакция только на одно окно
- Вынос React-окна на другой монитор
- Серверный рендеринг
- Тестирование
- Производительность
- Окно с нуля
- Тач-жесты
Таскбар
minimized() возвращает свёрнутые окна активного рабочего стола в порядке открытия — ровно в том порядке, в каком они стоят в таскбаре.
function renderTaskbar() {
taskbar.replaceChildren(
...wm.minimized().map((win) => {
const button = document.createElement('button')
button.type = 'button'
button.dataset.task = win.id
button.textContent = win.title
button.addEventListener('click', () => wm.focus(win.id))
return button
}),
)
}
wm.subscribe(renderTaskbar)
renderTaskbar()wm.focus(id) на свёрнутом окне возвращает его в ту стадию, в которой оно было до сворачивания, — развёрнутые окна возвращаются развёрнутыми. А если окно живёт на другом рабочем столе, focus ещё и переключит стол, так что общий список «все окна» работает без лишнего кода.
Анимация сворачивания в таскбар
Направьте minimizeTarget на элемент, в который окно должно улетать. Верните null, чтобы пропустить анимацию для конкретного окна.
attachDesktop(wm, desktopEl, {
minimizeTarget: (win) => taskbar.querySelector(`[data-task="${win.id}"]`) ?? taskbar,
})Контроллер запускает flipToTarget при сворачивании и flipFromTarget при восстановлении. Анимируется одноразовый призрак, так что ваш контент не переносится и не перерендеривается. При reduced motion обе функции ничего не делают.
Модальные диалоги
Открывайте окно с layer: 'modal'. Менеджер не даёт фокус окнам ниже и испускает modalblocked; DOM-слой отклоняет ввод указателем (перетаскивание, ресайз, клики, action-атрибуты) в нижних окнах, мигает модалкой и держит Tab внутри неё.
const confirm = wm.open({
id: 'confirm',
title: 'Delete file?',
layer: 'modal',
width: 360,
height: 180,
closable: false,
})
wm.on('modalblocked', ({ window: modal }) => {
console.log('user tried to reach past', modal.id)
})Заметки:
floating— вариант помягче: всегда поверх, но ничего не блокирует.- Ловушке фокуса нужно что-то фокусируемое внутри окна; дайте диалогу кнопку.
- Программные вызовы не блокируются никогда —
wm.close('confirm')срабатывает всегда, так диалог и закрывают. maximize,snap,restoreи остальные вызовы смены стадии, которые фокусируют окно, переключают на его рабочий стол, но молча пропускают фокус, пока окно блокирует модалка;modalblockedозначает только заблокированный вызовfocus().- Окно, ставшее верхней модалкой через
update(…, { layer })илиmoveToWorkspace, забирает фокус; если модалку в фокусе понизить до обычного слоя, фокус перейдёт к следующей видимой модалке.
Персист и миграции
import { persist } from '@surdeddd/wmkit/persist'
const store = persist(wm, {
key: 'my-app-desktop',
version: 2,
autoRestore: false,
migrate: (state, from) => (from === 1 ? upgradeV1(state) : null),
})По умолчанию плагин сам восстанавливает состояние при создании (autoRestore: false выше оставляет это вам — почему, сказано ниже), а затем сохраняет его с задержкой после каждого изменения; при непрерывном потоке изменений сохранение всё равно происходит не реже чем раз в maxWait мс (по умолчанию 2000 или debounce, если он больше). Отложенное сохранение выполняется на pagehide и при destroy(), а clear() ещё и отменяет его. Данные заворачиваются в { version, state }; данные другой версии проходят через migrate, и если он вернёт null, они отбрасываются, а не восстанавливаются сломанными. Успешная миграция сразу записывается обратно.
Вторая половина работы — восстановить то, что вы рендерили: hydrate испускает open для каждого появившегося окна, так что монтированием можно управлять целиком через события. Подпишитесь до восстановления: при autoRestore по умолчанию persist восстанавливает состояние при создании, когда вашего слушателя ещё нет, а повторный restore() тех же данных ничего не испускает:
wm.on('open', ({ window: win }) => {
if (!mounted.has(win.id)) mount(win)
})
store.restore()Во время hydrate геометрия пересчитывается под текущий вьюпорт, так что рабочий стол, сохранённый на 27-дюймовом мониторе и восстановленный на ноутбуке, возвращается в пределы экрана: развёрнутые окна снова заполняют его, прилепленные заново прилепляются к своим зонам, а обычные прижимаются так, чтобы их тайтлбары оставались досягаемыми. Окно, повреждённое в хранилище, чинится, а не стоит вам всей раскладки (что именно чинится).
Плагин ничего не бросает: о хранилище, которое не смогло прочитать или записать данные, о данных, которые не являются JSON, и о migrate, бросившем исключение, он сообщает. Направьте эти сообщения в своё логирование через onError; без него первая ошибка каждого вида (загрузка, миграция, сохранение) уходит в reportError, который показывает её в консоли как неперехваченную, а дальше этот вид молчит, пока снова не пройдёт успешно:
persist(wm, {
key: 'my-app-desktop',
onError: (error, phase) => logger.warn(`desktop ${phase} failed`, error),
})Полоса вкладок для сгруппированных окон
Группировка headless: менеджер решает, какое окно видно, а полосу вкладок рендерите вы. Всё нужное лежит в state.groups.
function renderTabs(windowEl, id) {
const state = wm.getState()
const win = state.windows[id]
const group = win?.groupId ? state.groups[win.groupId] : undefined
const strip = windowEl.querySelector('.tabs')
if (!group) {
strip.replaceChildren()
return
}
strip.replaceChildren(
...group.members.map((memberId) => {
const tab = document.createElement('button')
tab.type = 'button'
tab.textContent = state.windows[memberId]?.title ?? memberId
tab.setAttribute('aria-selected', String(group.activeId === memberId))
tab.addEventListener('click', () => wm.activateTab(memberId))
return tab
}),
)
}
wm.subscribe(() => {
for (const [id, el] of mounted) renderTabs(el, id)
})Не вешайте жест «оторвать вкладку» на dblclick. Активация вкладки меняет участника, которого показывает DOM-слой, поэтому два клика попадают в два разных элемента, и браузер отправляет dblclick их общему предку — обычно тайтлбару, чей собственный обработчик двойного клика вместо этого разворачивает окно. Отрывайте вкладку перетаскиванием или отдельной кнопкой на вкладке.
Порядок в group.members — это порядок вкладок, и меняет его только moveTab: фокус на участнике, подъём рамки или отправка её вниз его не трогают. Поэтому полосу можно рендерить прямо из members и строить ключи DOM по этому порядку:
tab.addEventListener('keydown', (event) => {
const members = wm.getState().groups[groupId].members
const at = members.indexOf(memberId)
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const step = event.key === 'ArrowRight' ? 1 : -1
if (event.ctrlKey || event.metaKey) wm.moveTab(memberId, at + step)
else wm.cycleTab(step)
})Дайте полосе role="tablist", каждой кнопке — role="tab" и ведите перемещаемый tabindex (roving tabindex): 0 на показанной вкладке, -1 на остальных, — чтобы вся группа занимала одну остановку в порядке Tab на странице. Рабочий стол и сам переключает вкладки с клавиатуры: Ctrl/⌘ + Alt + PageDown / PageUp вызывают cycleTab, а keyboard: { tabShortcuts: false } это отключает. Веб-компонент <wm-window> рисует такую полосу сам (Веб-компоненты).
DOM-слой сам скрывает неактивных участников и размечает элементы: data-wm-group несёт id группы, а data-wm-tab равен active или inactive, так что тема может оформить рамку без всякого JavaScript.
Если ненадолго задержать один тайтлбар над другим, два окна сгруппируются. Пауза важна: без неё любое перетаскивание, которое случайно закончилось над тайтлбаром соседа, поглощало бы окно. Задержка по умолчанию — 420 мс, она настраивается через grouping: { dwell: 250 }. Модальные окна не группируются никогда — ни как перетаскиваемое окно, ни как цель. Пока жест готов сработать, на цели стоит data-wm-tab-target:
[data-wm-tab-target] {
outline: 2px dashed var(--wm-accent);
outline-offset: -2px;
}Выключите жест через attachDesktop(wm, el, { grouping: false }) и вызывайте group() и ungroup() из своего интерфейса.
Рабочие столы
wm.open({ id: 'mail', title: 'Mail' }) // активный рабочий стол
wm.open({ id: 'build', title: 'Build log', workspace: 1 })
wm.setWorkspace(1) // 'mail' теперь скрыто, 'build' в фокусе
wm.moveToWorkspace('mail', 1)
wm.focus('build') // вернёт на стол 1, если вы с него ушлиОкна вне активного рабочего стола получают hidden, не получают фокус и исключаются из cycleFocus, minimized(), arrange(), minimizeAll() и магнетизма при перетаскивании. Всё остальное — геометрия, история, сериализация — обращается с ними как обычно. Рендерите переключатель из wm.getState().workspace и отмечайте столы, на которых есть окна:
const populated = new Set(Object.values(wm.getState().windows).map((win) => win.workspace))Окна с фиксированными пропорциями
wm.open({ id: 'player', title: 'Player', width: 640, aspectRatio: 16 / 9 })
wm.update('player', { aspectRatio: null }) // снять фиксациюПропорция соблюдается при открытии, при resize и при update. При ресайзе указателем ведущая ось — тот край, за который вы взялись: северная и южная ручки ведут высоту, все остальные — ширину, поэтому окно следует за курсором, а не сопротивляется ему. minSize и maxSize всё равно главнее: если в эти рамки пропорция не помещается, вы получите ближайший допустимый размер.
Свои раскладки снэпа
zoneBounds и detectSnapZone экспортируются, так что выбор раскладки можно собрать, не трогая код перетаскивания.
import { zoneBounds } from '@surdeddd/wmkit'
const ZONES = ['left-third', 'center-third', 'right-third']
ZONES.forEach((zone) => {
const button = document.createElement('button')
button.textContent = zone
button.addEventListener('click', () => {
const id = wm.getState().focusedId
if (id) wm.snap(id, zone)
})
pad.append(button)
})Чтобы изменить, что происходит при перетаскивании к краю, используйте опции snap: threshold и cornerSize сдвигают горячие зоны, topEdge: 'top' | 'none' меняет поведение у верхнего края, preview: false убирает призрак, а snap: false выключает снэп целиком. Для фиксированной сетки выключите снэп и сами вызывайте restoreTo из события move.
Жест одним шагом отмены
Перетаскивание и ресайз уже собираются в одну запись истории. Сделайте то же для своих жестов:
const start = selection.map((id) => [id, wm.get(id).bounds])
wm.beginInteraction()
for (const id of selection) wm.moveBy(id, dx, dy)
function commit() {
wm.endInteraction() // один шаг отмены на всю группу
}
function cancel() { // по Escape
for (const [id, { x, y }] of start) wm.move(id, x, y)
wm.abortInteraction() // и ни одного шага
}Если пользователь отменяет жест, верните окна на место и вызовите abortInteraction(): он завершает взаимодействие и выбрасывает созданную им запись, так что Escape не оставляет следа в истории. Сам он ничего не откатывает: изменения, оставленные на месте, отменились бы вместе с предыдущим шагом. Взаимодействие, которое закончилось там же, где началось, вообще не оставляет записи, а во вложенном взаимодействии abortInteraction() только выходит из внутреннего уровня. clearHistory() внутри batch начинает историю заново с этого вызова, и отмена последующих изменений того же batch возвращает к нему; внутри жеста остаток жеста не записывается, а abortInteraction() возвращает очищенное. batch(fn) — то же самое без жеста: одно уведомление change и одна запись истории на серию вызовов.
Контекстное меню тайтлбара
attachDesktop(wm, desktopEl, {
onTitlebarContextMenu: (win, event) => {
showMyMenu(event.clientX, event.clientY, [
{ label: 'Send to back', run: () => wm.sendToBack(win.id) },
{ label: 'Centre', run: () => wm.center(win.id) },
{ label: 'Move to desktop 2', run: () => wm.moveToWorkspace(win.id, 1) },
{ label: 'Close', run: () => wm.close(win.id) },
])
},
})Хук срабатывает на правый клик и на долгое нажатие, а меню браузера подавляется за вас.
Реакция только на одно окно
subscribe срабатывает на каждое изменение рабочего стола. Если вас интересует только одно окно, сравнивайте ссылки — у неизменившихся окон остаётся тот же объект:
let last = wm.get(id)
const stop = wm.subscribe(() => {
const next = wm.get(id)
if (next === last) return
last = next
render(next)
})Именно так устроены useWmWindow и wmWindowStore, и именно поэтому рабочий стол на пятьдесят окон перерендеривает один компонент, когда двигается одно окно.
Вынос React-окна на другой монитор
Узел, который отрендерил React, теряет обработчики при переносе в другой документ, потому что React слушает события на собственном корне. Оставьте контент на месте и отрендерите его копию в popout через портал:
import { createPortal } from 'react-dom'
import { popout, type PopoutHandle } from '@surdeddd/wmkit/popout'
function PopoutButton({ wm, id, children }: { wm: WindowManager; id: string; children: ReactNode }) {
const [handle, setHandle] = useState<PopoutHandle | null>(null)
const open = async (event: MouseEvent<HTMLButtonElement>) => {
const root = event.currentTarget.closest<HTMLElement>('[data-wm-content]') as HTMLElement
const next = await popout(wm, id, root, { mode: 'container', target: 'window', screen: 'other' })
setHandle(next)
void next.closed.then(() => setHandle(null))
}
return handle ? createPortal(children, handle.container) : <button onClick={open}>pop out</button>
}mode: 'container' оставляет исходный узел на месте и отдаёт пустой container в popout; портал рендерит туда, и собственная система событий React работает как обычно. closed выполняется, когда пользователь закрывает popout или окно закрывается в менеджере. Второй клик, пока окно вынесено, ничего не ломает: popout фокусирует уже открытый popout и возвращает тот же хэндл.
Серверный рендеринг
src/core никогда не обращается к window или document. Создайте менеджер на сервере, восстановите сохранённую раскладку через hydrate, сериализуйте её в данные страницы и подключайте DOM-слой только после монтирования.
// сервер
const wm = createWindowManager({ viewport: { width: 0, height: 0 } })
wm.hydrate(savedLayout)
const payload = wm.serialize()
// клиент, после монтирования
const wm = createWindowManager()
wm.hydrate(payload)
attachDesktop(wm, desktopEl) // ResizeObserver выставит настоящий вьюпорт, геометрия пересчитаетсяПока вьюпорт на сервере равен 0 × 0, ограничение по вьюпорту выключено, поэтому ничего не втискивается во вьюпорт, который ещё неизвестен. persist читает хранилище внутри try/catch и ничего не делает, когда хранилища нет.
Тестирование
Ядро — простой объект без DOM, поэтому юнит-тестам не нужно окружение:
import { createWindowManager } from '@surdeddd/wmkit'
const wm = createWindowManager({ viewport: { width: 1000, height: 800 } })
const win = wm.open({ title: 'Test' })
wm.snap(win.id, 'left')
expect(wm.get(win.id).bounds).toEqual({ x: 0, y: 0, width: 500, height: 800 })jsdom считает clientWidth и clientHeight равными 0, поэтому при включённом autoViewport вьюпорт, выставленный при монтировании, игнорируется и остаётся тот, что передан в конструктор: в тестах под jsdom передавайте viewport в createWindowManager. ResizeObserver в чистом jsdom тоже нет; тогда контроллер измеряет рабочий стол один раз и работает дальше.
Для проверки DOM-поведения посылайте настоящие события указателя — библиотека использует захват указателя, так что синтетические события мыши не подойдут. В Playwright:
const box = await handle.boundingBox()
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(target.x, target.y, { steps: 16 })
await page.mouse.up()Дожидайтесь окончания анимаций, прежде чем проверять геометрию, и помните, что магнетизм может сдвинуть конечную позицию на величину до порога, — проверяйте с допуском или передайте magnetism: false в фикстуре.
Производительность
Настройки по умолчанию уже дают быстрый путь: позиционирование только через transform, ввод указателя собирается в один requestAnimationFrame, состояние использует structural sharing, а прямоугольник рабочего стола кэшируется на время жеста.
Что всё ещё зависит от вас:
- Объединяйте массовые операции.
wm.batch(() => { for (…) wm.open(…) })превращает пятьдесят открытий в одно уведомление, один перерендер и один проход layout. - Подписывайтесь точечно. Подписки на отдельные окна выгоднее перерендера всего списка.
- Держите контент лёгким во время перетаскивания. На элементе стоит
[data-wm-dragging]— по нему можно ставить дорогие эффекты на паузу. - Не анимируйте
transformв своей теме во время жеста или изменения размера рабочего стола. Встроенные темы уже отключают переход приdata-wm-dragging,data-wm-resizingиdata-wm-live; своя тема должна делать то же самое. - Пусть нижние окна обходятся без размытия. Размытие под каждым окном дорого обходится на большом рабочем столе, поэтому полупрозрачные темы убирают его у окон с
data-wm-deep— тех, что лежат больше чем наstacking.deepмест ниже верхнего (по умолчанию 12). Уменьшите это значение, если окон на рабочем столе много, или передайтеfalse, чтобы сохранить размытие везде, — см. Темизация.
Окно с нуля
@surdeddd/wmkit/chrome превращает строку разметки в скин. Подстановки: {{title}}, {{id}}, {{stage}}, {{layer}}, {{workspace}} и {{variant}}; каждая подстановка экранируется как HTML, так что заголовок окна никогда не протащит разметку в обвязку вокруг себя. Подстановка может стоять только в тексте или в значении в кавычках у aria-*, data-*, title, alt, class, id, role, lang, dir, for, value, placeholder или label; в любом другом месте skin() бросает ошибку, как и для разметки SVG или MathML, которую браузер может разобрать иначе (все правила). За дальнейшими изменениями состояния следует только {{title}} внутри [data-wm-title], остальные подстановки вычисляются при монтировании или пересборке, поэтому aria-label="Close {{title}}" ниже сохраняет тот заголовок, который был у окна при монтировании.
import { attachDesktop } from '@surdeddd/wmkit'
import { skin } from '@surdeddd/wmkit/chrome'
const brutal = skin({
name: 'brutal',
styles: '[data-wm-skin="brutal"] { border: 3px solid #111 }',
template: `
<section>
<header data-wm-drag>
<span data-wm-title>{{title}}</span>
<button type="button" data-wm-to-workspace="1" aria-label="Send to workspace 2">→</button>
<button type="button" data-wm-close aria-label="Close {{title}}">×</button>
</header>
<div data-wm-content></div>
</section>
`,
})
const desktop = attachDesktop(wm, root, { skins: { brutal } })
wm.open({ id: 'notes', title: 'Notes' })
const notes = desktop.mountWindow('notes', 'brutal')
notes.content.append(myApp)Кнопкам не нужны обработчики: data-wm-close и соседние атрибуты читает контроллер. Каждый skin() владеет одним <style data-wm-skin-styles="brutal"> в каждом документе; он удаляется, когда уходит последнее окно в этом скине. Окно несёт data-wm-skin="brutal", так что вашим правилам есть за что зацепиться.
Шаблон должен содержать ровно один атрибут [data-wm-content] и один корневой элемент; иначе ошибка возникает ещё при написании скина (или при монтировании, если корней несколько), а не теряет ваше приложение молча.
Изоляция
shadow: true собирает обвязку внутри открытого теневого корня. Ваш контент — это собственный элемент [data-wm-content] шаблона: он переносится в light DOM и проецируется через <slot>, поставленный на его место, так что теневой CSS достаёт до него как ::slotted([data-wm-content]). CSS страницы не проникает внутрь, собственный CSS окна не вытекает наружу, а стили становятся одной constructable stylesheet, общей для всех окон этого скина.
import { themeStyle } from '@surdeddd/wmkit/themes'
const sealed = skin({
name: 'sealed',
shadow: true,
styles: themeStyle('carbon', { shadow: true }),
template: TEMPLATE,
})Передайте { shadow: true }, иначе тема придёт с токенами, но без единого правила, — почему, см. в разделе Темизация. Внутри теневого корня пишите :host там, где написали бы селектор окна. Корневой элемент шаблона разворачивается, так что :host — это бокс, в котором лежат тайтлбар и контент. Группировка перетаскиванием, ловушка фокуса в модалке и ручки ресайза продолжают работать через границу; доступное имя переключается с aria-labelledby на aria-label, потому что ссылка по id через неё не проходит.
Тач-жесты
Жесты подключаются по желанию: в ядре их нет, а @surdeddd/wmkit/gestures стоит 1.66 кБ, только если вы его импортируете. Оба жеста реагируют только на pointerType === 'touch', поэтому поведение мыши и пера не меняется, и оба работают через один слушатель на элементе рабочего стола.
- Пинч меняет размер окна. Два пальца на одном окне масштабируют его вокруг точки между ними.
minSize,maxSizeиaspectRatioокна по-прежнему действуют, а опорная точка вычисляется из уже ограниченного размера, так что точка под пальцами остаётся на месте даже на пределе. Весь пинч — один шаг отмены;Escapeили отменённый указатель возвращают начальную геометрию. Пока пинч идёт, на элементе стоитdata-wm-pinching. - Свайп двумя пальцами вбок переключает рабочий стол. Он работает и над окном, и над пустым рабочим столом — это важно на телефоне, где окна закрывают всё. Жест засчитывается, когда пальцы проходят
thresholdпикселей по горизонтали и этот путь более чем вдвое больше вертикального смещения.
Они никогда не конфликтуют: побеждает тот, кто первым перешагнёт свой порог, и ни один не начинается, пока указателем уже владеет перетаскивание или ресайз.
import { touchGestures } from '@surdeddd/wmkit/gestures'
attachDesktop(wm, root, {
gestures: [touchGestures({ pinch: { threshold: 16 }, swipe: { workspaces: 4 } })],
})Импортируйте pinch() или swipe() по отдельности, если нужен только один из них.
Одна оговорка, которую стоит знать. Браузер отдаёт события указателя от двух пальцев, только если элемент не отдаёт этот жест странице раньше, поэтому любой жест двумя пальцами (пинч или свайп) ставит на рабочий стол touch-action: pan-x pan-y, а при уничтожении возвращает исходное значение: контент по-прежнему прокручивается, но собственное масштабирование браузера больше не перехватывает жест. Если ваши окна никогда не прокручиваются, lockTouchAction: true усиливает это до touch-action: none.