diff --git a/dev/features/dark-theme.html b/dev/features/bwin-dark-theme.html similarity index 80% rename from dev/features/dark-theme.html rename to dev/features/bwin-dark-theme.html index 76d9187..d309608 100644 --- a/dev/features/dark-theme.html +++ b/dev/features/bwin-dark-theme.html @@ -3,12 +3,13 @@ - +

Dark Theme

+
diff --git a/dev/features/dark-theme.js b/dev/features/bwin-dark-theme.js similarity index 91% rename from dev/features/dark-theme.js rename to dev/features/bwin-dark-theme.js index 0db4737..2472e13 100644 --- a/dev/features/dark-theme.js +++ b/dev/features/bwin-dark-theme.js @@ -54,3 +54,7 @@ document.querySelector('#set-theme-form').addEventListener('submit', (event) => const theme = document.querySelector('#theme-input').value.trim(); bwin.setTheme(theme); }); + +document.querySelector('#add-windowless').addEventListener('click', () => { + BinaryWindow.addWindowlessGlass({ title: 'Windowless glass', content: inputs }); +}); diff --git a/dev/features/bwin-detached-glass.html b/dev/features/bwin-detached-glass.html index 8874c33..f5172b7 100644 --- a/dev/features/bwin-detached-glass.html +++ b/dev/features/bwin-detached-glass.html @@ -20,6 +20,9 @@

BinaryWindow - detached glass

+ + +
diff --git a/dev/features/bwin-detached-glass.js b/dev/features/bwin-detached-glass.js index 394d3e0..4e61865 100644 --- a/dev/features/bwin-detached-glass.js +++ b/dev/features/bwin-detached-glass.js @@ -132,4 +132,35 @@ document.querySelector('#add-default').addEventListener('click', () => { bwin.addDetachedGlass({ content: createGlassContent('default') }); }); +// Static method: floats on document.body, not inside any bw-window. +document.querySelector('#add-windowless').addEventListener('click', () => { + BinaryWindow.addWindowlessGlass({ + title: 'Windowless glass', + content: createGlassContent('windowless'), + }); +}); + +// Modal: a backdrop is appended behind the glass to block everything underneath. +document.querySelector('#add-modal').addEventListener('click', () => { + BinaryWindow.addWindowlessGlass({ + modal: true, + title: 'Modal windowless glass', + content: createGlassContent('modal'), + }); +}); + +// Windowless glass filling the viewport with a 50px inset on every edge. +document.querySelector('#add-fullscreen').addEventListener('click', () => { + const EDGE = 20; + BinaryWindow.addWindowlessGlass({ + title: 'Fullscreen popup', + draggable: false, + position: 'top-left', + offset: EDGE, + width: document.documentElement.clientWidth - EDGE * 2, + height: document.documentElement.clientHeight - EDGE * 2, + content: createGlassContent('fullscreen'), + }); +}); + // document.querySelector('#add-default').click(); diff --git a/dev/index.js b/dev/index.js index 66b367a..1306fbe 100644 --- a/dev/index.js +++ b/dev/index.js @@ -53,11 +53,14 @@ navEl.querySelector('#_toggle-theme').addEventListener('click', () => { const goDark = windowEls[0].getAttribute('theme') !== 'dark'; - windowEls.forEach((windowEl) => { + // Windowless glasses float on the page body, outside bw-window, so theme them too. + const themedEls = [...windowEls, ...frameDoc.querySelectorAll('bw-glass[windowless]')]; + + themedEls.forEach((el) => { if (goDark) { - windowEl.setAttribute('theme', 'dark'); + el.setAttribute('theme', 'dark'); } else { - windowEl.removeAttribute('theme'); + el.removeAttribute('theme'); } }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 82082a5..22c676d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,15 +10,16 @@ bwin borrows real window-construction vocabulary. Matching it in code, comments, and docs is a hard project convention: -| Term | Meaning | Renders as | -|------|---------|-----------| -| **Window** | The whole layout; the root config node. Carries window-level props (`width`, `height`, `fitContainer`, `theme`). | `` | -| **Sash** | A node in the binary tree that organizes panes. Identified by a **Sash ID** (e.g. `AB-123`). | — (model only) | -| **Pane** | A *leaf* sash. Holds a single glass. | `` | -| **Muntin** | An *internal* (parent) sash: the draggable divider used to resize the two children. | `` | -| **Glass** / **attached glass** | The content inside a pane: header (title/tabs + action buttons) + content. | `` inside a `` | -| **Detached glass** | The same glass component floating free outside any pane (the OS-window-like panel produced by the detach action). | `` appended to `` | -| **Sill** | The dock at the bottom of the window holding minimized glasses. | `` | +| Term | Meaning | Renders as | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| **Window** | The whole layout; the root config node. Carries window-level props (`width`, `height`, `fitContainer`, `theme`). | `` | +| **Sash** | A node in the binary tree that organizes panes. Identified by a **Sash ID** (e.g. `AB-123`). | — (model only) | +| **Pane** | A _leaf_ sash. Holds a single glass. | `` | +| **Muntin** | An _internal_ (parent) sash: the draggable divider used to resize the two children. | `` | +| **Glass** / **attached glass** | The content inside a pane: header (title/tabs + action buttons) + content. | `` inside a `` | +| **Detached glass** | The same glass component floating free outside any pane (the OS-window-like panel produced by the detach action). | `` appended to `` | +| **Windowless glass** | A detached glass with _no owning window_ — it floats on `document.body` instead of inside a ``. Created via `addWindowlessGlass`. | `` appended to `` | +| **Sill** | The dock at the bottom of the window holding minimized glasses. | `` | --- @@ -46,17 +47,20 @@ bwin separates a **model** from **two stacked view layers**. The model is the si **Model — `Sash`** (`src/sash.js`) The single source of truth for geometry; nothing else owns layout state. + - A binary tree of regions. Each node holds `left/top/width/height`, `minWidth/minHeight`, `resizeStrategy`, and a `store` bag. - Setters cascade geometry down to children, so the tree stays consistent after any change. **Frame layer — core tiling** (`src/frame/`) -The view layer that *mirrors the model*. Renders the structure, owns resizing. +The view layer that _mirrors the model_. Renders the structure, owns resizing. + - Renders the window, **muntins**, and **panes**. - Owns **pane resizing**: dragging a muntin mutates the sash tree — the defining feature of a tiling manager. Also handles generic drop infrastructure and fit-to-container. - Renders panes but **not** their contents — a `` is an empty region until the layer above fills it. **BinaryWindow layer — enhanced interaction** (`src/binary-window/`) `extends Frame` and adds the user-facing window experience on top. + - Renders a **glass** into each pane (`onPaneCreate`) — this layer is where "windows" (glasses) live; the Frame layer underneath only knows panes. - Adds glass drag-and-drop rearrangement, the minimize/maximize/close/detach actions, **detached** (floating) glasses, and the **sill**. @@ -66,8 +70,8 @@ Rendering is one-directional in both layers: sash tree → DOM, via `glaze()` (i `Frame.mount(containerEl)` is the normal entry point and runs two phases: -1. **`frame(containerEl)`** — *DOM creation.* Creates ``, calls `glaze()` to render the whole sash tree, appends to the container. `BinaryWindow.frame()` additionally appends ``. -2. **`enableFeatures()`** — *interaction wiring.* Attaches event listeners for resize/drop/fit; `BinaryWindow` adds glass and detached-glass features. +1. **`frame(containerEl)`** — _DOM creation._ Creates ``, calls `glaze()` to render the whole sash tree, appends to the container. `BinaryWindow.frame()` additionally appends ``. +2. **`enableFeatures()`** — _interaction wiring._ Attaches event listeners for resize/drop/fit; `BinaryWindow` adds glass and detached-glass features. This split is deliberate and is itself an integration seam — `react-bwin` skips `mount()`, sets `windowElement`/`containerElement`/`sillElement` from its own React-created DOM, then calls `enableFeatures()` directly (see §9). @@ -79,7 +83,14 @@ bwin avoids deep class hierarchies for features. Instead, `Frame`/`BinaryWindow` ```js // src/frame/frame.js -Frame.assemble(mainModule, muntinModule, paneModule, fitContainerModule, droppableModule, resizableModule); +Frame.assemble( + mainModule, + muntinModule, + paneModule, + fitContainerModule, + droppableModule, + resizableModule +); // src/binary-window/binary-window.js BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule); @@ -87,7 +98,7 @@ BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule); `assemble()` uses `strictAssign` (`src/utils.js`), which **throws if a key already exists** on the target prototype. This makes the method namespace a flat, collision-checked shared space. -**Consequence — namespace your mixin methods.** Because all module methods share one namespace, a new feature must not reuse an existing method name. `frame/resizable.js` already owns `enableResize`, so the detached-glass resize feature is `enableDetachedGlassResize`, *not* `enableResize`. When adding a feature, prefix its methods with the feature name. +**Consequence — namespace your mixin methods.** Because all module methods share one namespace, a new feature must not reuse an existing method name. `frame/resizable.js` already owns `enableResize`, so the detached-glass resize feature is `enableDetachedGlassResize`, _not_ `enableResize`. When adding a feature, prefix its methods with the feature name. **Override via composition.** Modules later in the `assemble()` list can't silently clobber (strictAssign throws), so genuine overrides happen through subclassing: `BinaryWindow.onPaneCreate` overrides `Frame`'s, `binary-window/glass/drag.js`'s `onPaneDrop` overrides the empty stub in `frame/droppable.js`, and `trimModule.onMuntinCreate` wraps muntin creation. The base modules leave `onPaneCreate`/`onPaneDrop`/`onMuntinCreate` as empty "intended to be overridden" hooks. @@ -104,15 +115,17 @@ new BinaryWindow({ width, height, fitContainer, theme, children: [...] }).mount( ### Config node forms Each child config node may be written three ways (`ConfigNode.normConfig`): + - **Object** — `{ position, size, content, children, ... }` (full form). - **Array** — shorthand for `{ children: [...] }` (a split with no other props). - **String/number** — shorthand for `{ size }` (e.g. `"60%"`, `0.4`, `300`). Node props: + - **Structural** (named by the config layer): `position` (`left|right|top|bottom`), `size` (px number, fraction `0.4`, or `"60%"`), `id`, `minWidth`, `minHeight`, `children`, `resizeStrategy`. - **Everything else** → `...rest` → `nonCoreData` (see §6). -A node with **no children** becomes a **pane**; a node **with children** becomes a **split** (rendered as a muntin + its two subtrees). Maximum **two children** per node — an omitted second child has its position/size *inferred* from the first (opposite position, complementary size). +A node with **no children** becomes a **pane**; a node **with children** becomes a **split** (rendered as a muntin + its two subtrees). Maximum **two children** per node — an omitted second child has its position/size _inferred_ from the first (opposite position, complementary size). ### Build flow @@ -132,6 +145,7 @@ ConfigRoot(settings) config-root.js - **`SashConfig`** (`config/sash-config.js`) is an alternate entry: a pre-built `Sash` subtree can be passed directly to `Frame`/`BinaryWindow` instead of a config object — `Frame`'s constructor branches on `settings instanceof SashConfig`. ### `parseSize` (`src/utils.js`) + Normalizes sizes: `"50%"` → `0.5` (fraction < 1), `"100px"` / `100` → `100` (absolute px). Fractions and absolutes are handled distinctly throughout the geometry code. --- @@ -141,6 +155,7 @@ Normalizes sizes: `"50%"` → `0.5` (fraction < 1), `"100px"` / `100` → `100` The heart of the layout engine. A `Sash` is a binary-tree node; it never touches the DOM (except holding a `domNode` reference set by the view). ### Tree shape & queries + - `children` is `[]` (leaf/pane) or exactly two nodes (split). `isLeaf()` / `isSplit()`. - Positional accessors: `leftChild`/`rightChild`/`topChild`/`bottomChild`, plus `getChildren()` → `[top, right, bottom, left]`. - A split is either **left/right** (`isLeftRightSplit`) or **top/bottom** (`isTopBottomSplit`). @@ -149,16 +164,21 @@ The heart of the layout engine. A `Sash` is a binary-tree node; it never touches - `swapIds(id1, id2)` — exchanges two sashes' IDs in place (used by the center-drop swap). ### Geometry & cascading setters + `top`/`left`/`width`/`height` are getter/setter pairs over private `_`-fields. **Setting a dimension cascades to children** so the subtree stays consistent: + - Setting `width` on a left/right split distributes the delta between `leftChild` and `rightChild` (proportionally by default), enforcing each side's `calcMinWidth()`; setting it on a top/bottom split widens both children equally. - `height` is symmetric for top/bottom vs left/right. - `top`/`left` shift both children by the same delta. ### Min-size propagation + `calcMinWidth()` / `calcMinHeight()` recurse: a left/right split's min width is the **sum** of children's min widths (they share the axis), while its min height is the **max**; top/bottom splits are the mirror. This is what stops a resize from crushing a deeply-nested pane below its minimum. Initial leaf minimums come from `VITE_DEFAULT_SASH_MIN_WIDTH/HEIGHT`. ### Resize strategies + `resizeStrategy` is `'classic'` (default) or `'natural'`: + - **classic** — both children share the delta proportionally. - **natural** — only one child changes size; the other holds, depending on which side the divider belongs to. @@ -169,6 +189,7 @@ The heart of the layout engine. A `Sash` is a binary-tree node; it never touches Structural props are named explicitly in the config layer; **everything else is opaque pass-through** carried in `sash.store`. This is how `content`, `title`, `tabs`, `actions`, `draggable`, `droppable`, `resizable`, and event handlers (`onDrop`) travel from declarative config to pane/glass construction. ### The path (declarative config) + 1. `config-node.js` — constructor destructures only structural keys; the `...rest` becomes `this.nonCoreData`. (Threaded through `createPrimary/SecondaryConfigNode` so it survives recursion.) 2. `createSash()` passes `nonCoreData` as the Sash's `store`. 3. `sash.js` — `this.store = store`; the Sash **never inspects it**. @@ -176,27 +197,32 @@ Structural props are named explicitly in the config layer; **everything else is 5. `glass.js` — the `Glass` constructor names them (`title`, `content`, `tabs`, `actions`, `draggable`) with defaults. ### `store` is a one-way seed (by intent) -`store` carries data from the top-level API into construction, then **should stop**. It is *not* a live model — moves like attach/detach/swap exchange data through the **DOM**, not through `store`, because the DOM is visible and debuggable in the browser. (See the `RATIONAL:` block in `sash.js`.) + +`store` carries data from the top-level API into construction, then **should stop**. It is _not_ a live model — moves like attach/detach/swap exchange data through the **DOM**, not through `store`, because the DOM is visible and debuggable in the browser. (See the `RATIONAL:` block in `sash.js`.) **Known tech debt:** `store` is not yet a pure seed. Two keys are still read long after construction — `onDrop` (read at drop time in `droppable.js`) and `resizable` (read when a pane is split in `muntin.js`). Until those move onto DOM/closures at build time, `store` can't be cleared in `onPaneCreate`. ### Store-key consumer map + `store` has no fixed schema. Recognized keys and where they're read: -| Key | Read by | Effect | -|-----|---------|--------| -| `content` | `frame/pane.js` (and re-surfaced to `Glass`) | DOM-node'd and placed in the pane/glass content | -| `droppable === false` | `frame/pane.js` | sets `can-drop="false"` on the pane | -| `onDrop` (fn) | `frame/droppable.js` | called `onDrop(event, sash)` on drop | -| `resizable === false` | `frame/muntin.js` | sets `resizable="false"` on the muntin | -| `actions` / `title` / `tabs` / `draggable` / rest | `binary-window.js onPaneCreate` → `Glass` | spread into the `Glass` constructor | +| Key | Read by | Effect | +| ------------------------------------------------- | -------------------------------------------- | ----------------------------------------------- | +| `content` | `frame/pane.js` (and re-surfaced to `Glass`) | DOM-node'd and placed in the pane/glass content | +| `droppable === false` | `frame/pane.js` | sets `can-drop="false"` on the pane | +| `onDrop` (fn) | `frame/droppable.js` | called `onDrop(event, sash)` on drop | +| `resizable === false` | `frame/muntin.js` | sets `resizable="false"` on the muntin | +| `actions` / `title` / `tabs` / `draggable` / rest | `binary-window.js onPaneCreate` → `Glass` | spread into the `Glass` constructor | ### The `undefined` vs `null` actions contract (load-bearing) + A default parameter fires **only on `undefined`**. This is relied on by docs and react-bwin: + - User **omits** `actions` → absent from store → `undefined` → `Glass` defaults to `DEFAULT_GLASS_ACTIONS`. - User writes `actions: null` (or `[]`) → reaches `Glass` as a non-array → `createActions` renders **no** buttons. ### Two other paths to `Glass` + - **Imperative `addPane(id, props)`** destructures `{position, size, id, ...glassProps}` and passes `glassProps` straight to `new Glass` — bypassing ConfigNode/store entirely. - **`DetachedGlass`** defaults `actions` to `DEFAULT_DETACHED_GLASS_ACTIONS` instead. @@ -205,13 +231,17 @@ A default parameter fires **only on `undefined`**. This is relied on by docs and ## 7. The view: rendering & reconciliation (`src/frame/`) ### `glaze()` — initial render (`frame/main.js`) + `rootSash.walk(...)` (post-order) over the whole tree: + - split sash → `createMuntin` + `onMuntinCreate`, **appended** (muntins end up on top in z-order). - leaf sash → `createPane` + `onPaneCreate`, **prepended**. - each sash's `domNode` is set to its element. ### `update()` — incremental reconcile (`frame/main.js`) + After any tree mutation (resize, add/remove/swap pane), `update()` reconciles DOM to model: + 1. Resize `` to the root sash. 2. Remove DOM elements whose `sash-id` is no longer in `rootSash.getAllIds()`. 3. `walk` the tree: for each sash, **create** its element if new, else **update** position/size in place (`updatePane`/`updateMuntin`) and fire the `on*Update` hooks. @@ -219,9 +249,11 @@ After any tree mutation (resize, add/remove/swap pane), `update()` reconciles DO Because Sash IDs are stable across an operation (e.g. `removePane` promotes a sibling's ID into its parent; splits hand the new muntin a fresh `genId`), `update()` can tell apart "moved" from "new/removed". ### Pane geometry (`frame/pane-utils.js`) + `createPaneElement`/`updatePaneElement` write `top/left/width/height` styles and `sash-id`/`position` attributes. `addPaneSash` (+ the four `addPaneSashTo{Left,Right,Top,Bottom}` helpers) performs the **tree surgery** for a split: it converts the target leaf into a split parent (target gets a fresh muntin ID, `domNode = null`), creates the two child sashes (one inherits the target's old ID + `domNode`), and returns the new sash. Sizing honors `size` as fraction or px. ### Muntins (`frame/muntin.js` + `binary-window/trim.js`) + `createMuntin`/`updateMuntin` position a `muntinSize`-px (4px) divider at the boundary between the two children, marked `vertical` (left/right split) or `horizontal` (top/bottom split). `sash.store.resizable === false` sets `resizable="false"`. `trim.js` (a `BinaryWindow` mixin) shrinks each muntin by half its size at both ends via `onMuntinCreate`/`onMuntinUpdate` so dividers don't overlap at intersections. --- @@ -229,9 +261,11 @@ Because Sash IDs are stable across an operation (e.g. `removePane` promotes a si ## 8. Interaction features ### 8.1 Resize (`frame/resizable.js`) + Drag a muntin to resize its two children. Uses **document-bound `mousedown`/`mousemove`/`mouseup`** (the older pattern — see §11). On `mousedown` over a `` (unless `resizable="false"`), records the active muntin sash; on `mousemove`, applies the delta to the two children (clamped by `calcMinWidth`/`calcMinHeight`) and calls `update()`; on `mouseup`, clears state. A `body--bw-resize-x/y` class sets the cursor during the drag. ### 8.2 Glass drag-and-drop rearrangement (native HTML DnD) + Dragging an attached glass and dropping it on a pane rearranges the layout. Split across three files: - **`frame/droppable.js`** — generic drop infrastructure on `windowElement`: `dragover` (must `preventDefault` to allow drop; finds the `` under cursor, computes the zone via `getCursorPosition`, writes it to the pane's `drop-area` attribute so CSS paints a preview), `dragleave` (with a Chrome child-element guard), and `drop` (resolves the sash, calls the overridable `onPaneDrop(event, sash)` stub, then any `sash.store.onDrop`). @@ -239,15 +273,18 @@ Dragging an attached glass and dropping it on a pane rearranges the layout. Spli - **`src/position.js`** — `getCursorPosition` splits a pane into 5 zones (top/right/bottom/left/center) using the two diagonals plus a center box (`centerRadio = 0.3`). **`onPaneDrop` branches on the drop zone:** + - **center** → `swapPanes` (`frame/pane.js`): swap the two sashes' IDs in the tree, swap their DOM child nodes, swap `sash-id`/`can-drop` attributes. No new pane — the two glasses trade places. - **top/right/bottom/left** → split: `removePane(oldSashId)` (the dragged glass's original pane; its sibling collapses up), then `addPane(targetSash.id, { position: dropArea, id: oldSashId })`, then **move the same glass element** into the new pane via `replaceChildren(activeDragGlassEl)`. > **Gotcha:** `BinaryWindow.addPane` always seeds a new pane with its own empty placeholder `Glass`. The drop must `replaceChildren` (not `append`) — a plain append would leave **two** glasses (the empty placeholder first), and a later detach's `querySelector('bw-glass-content')` would then grab the empty one → a blank detached glass. ### 8.3 Fit container (`frame/fit-container.js`) + When `fitContainer` is set, a `ResizeObserver` on the container calls `fit()` (inside `requestAnimationFrame`), which sets the root sash to the container's client size and `update()`s. `fit()` is also exposed as a public method. ### 8.4 Glass actions (`binary-window/glass/`) + Each built-in action is a small object `{ label, className, onClick(event, binaryWindow) }`. `Glass.createActions()` renders them as buttons in `` and wires `onClick`. `DEFAULT_GLASS_ACTIONS = [minimize, detach, close]`. - **close** (`action.close.js`) — removes the pane. @@ -258,49 +295,66 @@ Each built-in action is a small object `{ label, className, onClick(event, binar Built-in actions are **pane-centric** (`closest('bw-pane')`), so they don't work on detached glasses — which is why detached glass ships its own action set. ### 8.5 Detached glass (`binary-window/detached-glass/`) + Floating `` panels that mimic OS windows, appended directly to `windowElement` (not bound to any pane/sash). The canonical "feature folder," mirrored by `glass/`: -| File | Role | -|------|------| -| `index.js` | re-exports `DetachedGlass` + `DEFAULT_DETACHED_GLASS_ACTIONS`; the assembled mixin (`enableDetachedGlassFeatures` + spread of the behavior modules). | -| `detached-glass.js` | `DetachedGlass extends Glass`; positions/sizes the floating node, defaults `actions = DEFAULT_DETACHED_GLASS_ACTIONS`. | -| `crud.js` | `addDetachedGlass` / `removeDetachedGlass` (public). Cascades placement down-right from the active glass; guards size so the constructor's `222` debug default never fires. | -| `manager.js` | `detachedGlassManager` singleton: tracks glasses, `bringToFront` (rising z-index + sole `[active]` marker), find/remove. | -| `activate.js` | click-to-focus → `bringToFront`. | -| `move.js` | drag the header to reposition (pointer events + `setPointerCapture`). | -| `resize.js` | 8 resize handles created **on demand** (on hover). | -| `drag.js` | *alternative* repositioning via native DnD (docks to panes) — currently **disabled** in favor of `move.js` (free-floating). Enable exactly one. | -| `restore.js` | restore a minimized detached glass from its sill button. | -| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. | -| `utils.js` | `genStylesByPosition`, resize-handle creation. | +| File | Role | +| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.js` | re-exports `DetachedGlass` + `DEFAULT_DETACHED_GLASS_ACTIONS`; the assembled mixin (`enableDetachedGlassFeatures` + spread of the behavior modules). | +| `detached-glass.js` | `DetachedGlass extends Glass`; positions/sizes the floating node, defaults `actions = DEFAULT_DETACHED_GLASS_ACTIONS`. | +| `crud.js` | `addDetachedGlass` / `removeDetachedGlass` (public). Cascades placement down-right from the active glass; guards size so the constructor's `222` debug default never fires. | +| `manager.js` | `detachedGlassManager` singleton: tracks glasses, `bringToFront` (rising z-index + sole `[active]` marker), find/remove. | +| `activate.js` | click-to-focus → `bringToFront`. | +| `move.js` | drag the header to reposition (pointer events + `setPointerCapture`). | +| `resize.js` | 8 resize handles created **on demand** (on hover). | +| `drag.js` | _alternative_ repositioning via native DnD (docks to panes) — currently **disabled** in favor of `move.js` (free-floating). Enable exactly one. | +| `restore.js` | restore a minimized detached glass from its sill button. | +| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. | +| `utils.js` | `genStylesByPosition`, resize-handle creation. | `DEFAULT_DETACHED_GLASS_ACTIONS = [minimize, attach, close]`. -**Position** supports the four corners + `center` (centered via `calc(50% - size/2)`, *not* translate, so left/top stay in sync with drag/resize math; `offset` has no effect on centered). +**Position** supports the four corners + `center` (centered via `calc(50% - size/2)`, _not_ translate, so left/top stay in sync with drag/resize math; `offset` has no effect on centered). **Detach → attach round-trip:** + - **detach** (`glass/action.detach.js`): creates a centered detached glass sized to the window, then **moves the real content/title elements** into it (`replaceWith`), stashes the origin (`bwOriginalSiblingSashId`, `bwOriginalPosition`, `bwOriginalRelativeSize`) on the DOM node, and `removePane`s the source. - **attach** (`detached-glass/action.attach.js`): reads the stashed origin and rebuilds a pane via `addPane`, moving the inner content nodes back in. If the original sibling is gone, it falls back to splitting the **largest leaf** (right if wide, bottom if tall) at 50%, then `removeDetachedGlass`. -> **Key contrast with the attached-glass split:** detach/attach round-trips data through the **DOM** (extract content nodes, build a fresh `Glass`), whereas the attached-glass drop *moves the same element*. +> **Key contrast with the attached-glass split:** detach/attach round-trips data through the **DOM** (extract content nodes, build a fresh `Glass`), whereas the attached-glass drop _moves the same element_. + +### 8.6 Windowless glass (`BinaryWindow.addWindowlessGlass`) + +A **windowless glass** is a detached glass with no owning window: it's appended to `document.body` instead of a `` and isn't bound to any `BinaryWindow` instance. It's still registered with the shared `detachedGlassManager`, so z-index/activation stacking works the same as for an in-window detached glass. + +- **Create** (`binary-window.js` `addWindowlessGlass`) — builds a `DetachedGlass` (`position: 'center'` default), sets the `windowless` attribute, appends it to `document.body`, registers it with the manager, and `bringToFront`s it. **Remove** (`removeWindowlessGlass(id)`) — unregisters from the manager, removes the node, and tears down its modal backdrop (below). +- **Modal backdrop** — passing `modal: true` appends a `` to `document.body` that blocks interaction with everything underneath. Its `z-index` is set inline to `glassZIndex - 1` — the odd slot the `topZIndex += 2` reservation (above) leaves free — so it sits directly below its glass. Both `removeWindowlessGlass` and the detached **close** action remove the matching `bw-glass-backdrop[for="…"]` via the shared `removeGlassBackdrop` helper in `detached-glass/utils.js`. Styled in `detached-glass.css` (`position: fixed; inset: 0`), color from `--bw-glass-backdrop-color`. +- **Actions** — with no `binaryWindow`, the _minimize_ and _attach_ actions (which need a window to minimize into / attach to) don't apply, so the default set is **close only**: `DEFAULT_WINDOWLESS_GLASS_ACTIONS = [closeAction]` (vs. `[minimize, attach, close]` for an in-window detached glass). +- **Containing block** (`detached-glass/utils.js` `getContainingBlockOrigin`) — both kinds are `position: absolute`, but their reference frame differs. An in-window detached glass's `offsetParent` is the positioned `bw-window`, so the origin is that window's rect top-left. A windowless glass on a _static_ `body` has no positioned ancestor → its containing block is the **initial** one (document origin), so the origin is `{ left: -scrollX, top: -scrollY }`. Drag (`move.js`) and resize math both route through this so viewport bounding stays correct regardless of scroll. +- **Modal z-index reservation** (`manager.js`) — `bringToFront` increments `topZIndex` by **2** (not 1) and returns the new value, reserving the odd slot just below for a modal overlay on a windowless glass. +- **Theming caveat** — windowless glasses live outside `bw-window`, so theming code must select `bw-glass[windowless]` separately (see `dev/index.js`). --- ## 9. Public API surface (`src/index.js`) -Exports: `Frame`, `BinaryWindow`, `Sash`, `SashConfig`, `ConfigRoot`, `Position`, `DEFAULT_GLASS_ACTIONS`, `DEFAULT_DETACHED_GLASS_ACTIONS`, and the **deprecated** `BUILTIN_ACTIONS` (backwards-compat alias). CSS is imported here (`vars/body/frame/glass/detached-glass/sill`) and shipped as `bwin.css`. +Exports: `Frame`, `BinaryWindow`, `Sash`, `SashConfig`, `ConfigRoot`, `Position`, `DEFAULT_GLASS_ACTIONS`, `DEFAULT_DETACHED_GLASS_ACTIONS`, `DEFAULT_WINDOWLESS_GLASS_ACTIONS`, and the **deprecated** `BUILTIN_ACTIONS` (backwards-compat alias). CSS is imported here (`vars/body/frame/glass/detached-glass/sill`) and shipped as `bwin.css`. Key methods on `BinaryWindow`: + - `mount(containerEl)` / `frame()` / `enableFeatures()` — lifecycle. - `addPane(targetSashId, { position, size, id, ...glassProps })` — split a pane and attach a glass. - `removePane(sashId)` — remove a pane (or clean up a minimized entry). -- `addDetachedGlass(options)` / `removeDetachedGlass(id)` — floating panels. +- `addDetachedGlass(options)` / `removeDetachedGlass(id)` — floating panels inside the window. +- `addWindowlessGlass(options)` / `removeWindowlessGlass(id)` — _static_ floating panels on `document.body`, with no owning window (§8.6). - `setTheme(theme)` / `fit()`. **Per-window `actions` config** is normalized by `BinaryWindow.normActions` into `[glassActions, detachedGlassActions]`, supporting forms like `[a1, a2]`, `[[glassActions]]`, `[glassActions, detachedGlassActions]`, `[undefined, detachedGlassActions]`, etc. `undefined` → both defaults; `null`/`[]` → none. ### Downstream coupling — `react-bwin` + The React wrapper (`../react-bwin`) must own DOM creation, so it bypasses `mount()` and reaches into internals. These are signals for public API bwin could formalize: + - Skips `mount()`; sets `windowElement`/`containerElement`/`sillElement` then calls `enableFeatures()` → wants a "bring-your-own-DOM"/hydrate mode. - Walks the sash tree (`rootSash.walk`, `children`, `leftChild`/`topChild`) and mutates `sash.domNode` from refs → wants a public traversal/serializable-layout API. - Reads `sash.store.*` (`actions`, `title`, `content`, `droppable`, `draggable`, `resizable`) → **`store` is effectively the integration contract.** @@ -322,7 +376,7 @@ These are enforced project conventions (see `CLAUDE.md`): - **Terminology** — use the window-construction metaphor (§1) precisely; don't pick a name whose well-known meaning differs from what the code does. - **Naming** — suffix DOM-element variables with `El` and keep the noun specific (`activeGlassEl`, not `activeEl`); name accessors `get`. Name constants for their context (`MIN_RESIZE_WIDTH`, not `MIN_WIDTH`). -- **Interaction code (preferred for new features)** — Pointer Events + `setPointerCapture` (one path for mouse/touch/pen; capture keeps move events flowing and self-releases) over document-bound `mouse*`; **delegated listeners on `windowElement`** (constant listener count); **create affordance DOM on demand** (on hover), not eagerly; scope child queries with `:scope > selector`. *Note:* existing `resizable.js` and the attached-glass `drag.js` still use the older `document` + `mouse*` style; detached glass `move.js`/`resize.js` use the modern pattern. +- **Interaction code (preferred for new features)** — Pointer Events + `setPointerCapture` (one path for mouse/touch/pen; capture keeps move events flowing and self-releases) over document-bound `mouse*`; **delegated listeners on `windowElement`** (constant listener count); **create affordance DOM on demand** (on hover), not eagerly; scope child queries with `:scope > selector`. _Note:_ existing `resizable.js` and the attached-glass `drag.js` still use the older `document` + `mouse*` style; detached glass `move.js`/`resize.js` use the modern pattern. - **Comments** — only when they add what the code doesn't say; ≤2 lines / 100 chars; prefix a genuinely longer one with `RATIONAL:`; wrap identifiers in backticks. - **Debug sentinels** — repeating-digit literals (`222`, `333`) in default/fallback paths are intentional tripwires, **not** magic numbers. Don't tidy them. If one surfaces downstream, a guard upstream was bypassed — investigate that, don't rename it. - **`dev/`** — test scaffolding, not shippable source. Interactive controls go in the `.html`; the paired `.js` queries them and wires behavior. Commits touching only `dev/` are plain `chore:`. diff --git a/docs/context/conventions.md b/docs/context/conventions.md index 1ac388b..a124aff 100644 --- a/docs/context/conventions.md +++ b/docs/context/conventions.md @@ -1,6 +1,6 @@ # Coding conventions -The full rationale behind the rules summarized in [`CLAUDE.md`](../../CLAUDE.md). Read this when writing or reviewing bwin source; `CLAUDE.md` is the quick checklist, this is the *why*. +The full rationale behind the rules summarized in [`CLAUDE.md`](../../CLAUDE.md). Read this when writing or reviewing bwin source; `CLAUDE.md` is the quick checklist, this is the _why_. See also [`ARCHITECTURE.md`](../ARCHITECTURE.md) for the system design and [`react-bwin-integration.md`](./react-bwin-integration.md) for the downstream contract. @@ -10,13 +10,13 @@ See also [`ARCHITECTURE.md`](../ARCHITECTURE.md) for the system design and [`rea Use the window-construction metaphor precisely — the full glossary is [`ARCHITECTURE.md` §1](../ARCHITECTURE.md#1-the-window-construction-metaphor). Don't pick a name whose well-known meaning differs from what the code does (e.g. jQuery's `unwrap` removes the wrapper in place, so `extractChildNodes` is clearer for moving children into a fragment). -Use plain "glass" by default; say "attached glass" only when contrasting with "detached glass". +Use plain "glass" by default; say "attached glass" only when contrasting with "detached glass". A **windowless glass** is a detached glass with no owning window (floats on `document.body`); use that exact term — not "free glass" (the old name) or "floating glass". --- ## Naming -- **DOM-element variables get an `El` suffix with a *specific* noun** — `activeGlassEl`, not `activeEl`, and not a vague `glassEl` when more specificity is available. +- **DOM-element variables get an `El` suffix with a _specific_ noun** — `activeGlassEl`, not `activeEl`, and not a vague `glassEl` when more specificity is available. - **Element accessors are named `get`** — e.g. `getActiveGlass` (returns the element that `activeGlassEl` would hold). - **Constants name the context they apply to, not just the quantity** — `MIN_RESIZE_WIDTH`, not `MIN_WIDTH`, so a resize-time minimum isn't confused with an unrelated creation-time size default. - **Prefer established domain/library terms** and match their conventional meaning. diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index ac8b227..ec58495 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -1,8 +1,14 @@ import { Frame } from '../frame/frame'; -import glassModule, { Glass, DEFAULT_GLASS_ACTIONS } from './glass'; +import glassModule, { Glass } from './glass'; import { createDomNode } from '../utils'; import trimModule from './trim'; -import detachedGlassModule, { DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass'; +import detachedGlassModule, { + DetachedGlass, + DEFAULT_WINDOWLESS_GLASS_ACTIONS, +} from './detached-glass'; +import { detachedGlassManager } from './detached-glass/manager'; +import { removeGlassBackdrop } from './detached-glass/utils'; +import { normActions } from './utils'; // debug: ci round 2 export class BinaryWindow extends Frame { @@ -12,7 +18,7 @@ export class BinaryWindow extends Frame { super(settings); this.theme = settings.theme || ''; - this.actions = BinaryWindow.normActions(settings.actions); + this.actions = normActions(settings.actions); } frame() { @@ -95,30 +101,68 @@ export class BinaryWindow extends Frame { } } - // Returns [glassActions, detachedGlassActions] - static normActions(actions) { - if (actions === undefined) return [DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS]; - if (!actions || !Array.isArray(actions) || actions.length === 0) return [[], []]; - - // [glassActions] - if (actions.length === 1 && Array.isArray(actions[0])) return [actions[0], DEFAULT_DETACHED_GLASS_ACTIONS]; - - // [action1, action2, ...] - if (!actions.some(Array.isArray)) return [actions, DEFAULT_DETACHED_GLASS_ACTIONS]; + /** + * Add a windowless glass: a detached glass that floats on `document.body` instead + * of inside a `bw-window`, so it isn't owned by any window instance. Managed by the + * shared glass manager (z-index/activation) like an in-window detached glass. + * + * @param {Object} [options] + * @param {boolean} [options.modal] - When true, append a `` + * behind the glass to block interaction with everything underneath. + * @param {'center'|'top-left'|'top-right'|'bottom-left'|'bottom-right'} [options.position='center'] - Where to anchor the glass. + * @param {number} [options.width] - Glass width in px. + * @param {number} [options.height] - Glass height in px. + * @param {number} [options.offset=0] - Distance in px from the anchored corner/edge (no effect on `center`). + * @param {number} [options.offsetX] - Per-axis override of `offset` on the x-axis. + * @param {number} [options.offsetY] - Per-axis override of `offset` on the y-axis. + * @param {string} [options.id] - Glass id; auto-generated (suffixed `-F`) when omitted. + * @param {Object[]} [options.actions] - Action buttons; defaults to `DEFAULT_WINDOWLESS_GLASS_ACTIONS` (close only). + * @param {string|Node} [options.title] - Header title. + * @param {string|Node} [options.content] - Glass body content. + * @param {Object[]} [options.tabs] - Header tabs (shown instead of `title`). + * @param {boolean} [options.draggable=true] - Whether the header can be dragged to move the glass. + * @returns {DetachedGlass} + */ + static addWindowlessGlass(options = {}) { + const { modal, ...glassOptions } = options; + + const glass = new DetachedGlass({ + actions: DEFAULT_WINDOWLESS_GLASS_ACTIONS, + position: 'center', + ...glassOptions, + }); + + glass.domNode.setAttribute('windowless', ''); + + document.body.append(glass.domNode); + detachedGlassManager.addGlassByElement(glass.domNode); + // bringToFront reserves the z-index slot just below the glass for this backdrop. + const glassZIndex = detachedGlassManager.bringToFront(glass.domNode); + + if (modal) { + const backdropEl = document.createElement('bw-glass-backdrop'); + backdropEl.setAttribute('for', glass.domNode.id); + backdropEl.style.zIndex = glassZIndex - 1; + document.body.append(backdropEl); + } - // [undefined, detachedGlassActions] - if (actions.length >= 2 && !Array.isArray(actions[0]) && Array.isArray(actions[1])) - return [[], actions[1]]; + return glass; + } - // [glassActions, undefined] - if (actions.length >= 2 && Array.isArray(actions[0]) && !Array.isArray(actions[1])) - return [actions[0], []]; + /** + * Remove a windowless glass by id, unregistering it from the shared glass manager + * and detaching it from `document.body`. Also removes its modal backdrop, if any. + * + * @param {string} windowlessGlassId - The id of the `bw-glass[windowless]` to remove + * @returns {Element|null} - The removed element, or null if no glass had that id + */ + static removeWindowlessGlass(windowlessGlassId) { + const removedGlassEl = detachedGlassManager.removeGlassById(windowlessGlassId); + removedGlassEl?.remove(); - // [glassActions, detachedGlassActions] - if (actions.length >= 2 && Array.isArray(actions[0]) && Array.isArray(actions[1])) - return actions; + removeGlassBackdrop(windowlessGlassId); - throw new Error(`[bwin] Invalid actions format`); + return removedGlassEl; } } diff --git a/src/binary-window/binary-window.test.js b/src/binary-window/binary-window.test.js index edb4f8b..88e1fa5 100644 --- a/src/binary-window/binary-window.test.js +++ b/src/binary-window/binary-window.test.js @@ -1,45 +1,45 @@ import { describe, it, expect } from 'vitest'; -import { BinaryWindow } from './binary-window'; +import { normActions } from './utils'; import { DEFAULT_GLASS_ACTIONS } from './glass'; import { DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass'; -describe('BinaryWindow.normActions', () => { +describe('normActions', () => { it('returns the builtin actions when actions is undefined', () => { - expect(BinaryWindow.normActions(undefined)).toEqual([DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS]); + expect(normActions(undefined)).toEqual([DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS]); }); it('returns [[], []] when actions is null, empty, or not an array', () => { - expect(BinaryWindow.normActions(null)).toEqual([[], []]); - expect(BinaryWindow.normActions('a')).toEqual([[], []]); - expect(BinaryWindow.normActions({})).toEqual([[], []]); - expect(BinaryWindow.normActions([])).toEqual([[], []]); + expect(normActions(null)).toEqual([[], []]); + expect(normActions('a')).toEqual([[], []]); + expect(normActions({})).toEqual([[], []]); + expect(normActions([])).toEqual([[], []]); }); it('returns [glassActions, DEFAULT_DETACHED_GLASS_ACTIONS] for a single grouped array', () => { const a = { label: 'A' }; - expect(BinaryWindow.normActions([[a]])).toEqual([[a], DEFAULT_DETACHED_GLASS_ACTIONS]); + expect(normActions([[a]])).toEqual([[a], DEFAULT_DETACHED_GLASS_ACTIONS]); }); it('returns [actions, DEFAULT_DETACHED_GLASS_ACTIONS] when actions is a flat array', () => { const a = { label: 'A' }; const b = { label: 'B' }; - expect(BinaryWindow.normActions([a, b])).toEqual([[a, b], DEFAULT_DETACHED_GLASS_ACTIONS]); + expect(normActions([a, b])).toEqual([[a, b], DEFAULT_DETACHED_GLASS_ACTIONS]); }); it('returns [[], detachedGlassActions] when first group is absent', () => { const b = { label: 'B' }; - expect(BinaryWindow.normActions([undefined, [b]])).toEqual([[], [b]]); - expect(BinaryWindow.normActions([null, [b]])).toEqual([[], [b]]); + expect(normActions([undefined, [b]])).toEqual([[], [b]]); + expect(normActions([null, [b]])).toEqual([[], [b]]); }); it('returns [glassActions, []] when second group is absent', () => { const a = { label: 'A' }; - expect(BinaryWindow.normActions([[a], undefined])).toEqual([[a], []]); - expect(BinaryWindow.normActions([[a], null])).toEqual([[a], []]); + expect(normActions([[a], undefined])).toEqual([[a], []]); + expect(normActions([[a], null])).toEqual([[a], []]); }); it('returns actions as-is when both groups are arrays', () => { @@ -47,10 +47,10 @@ describe('BinaryWindow.normActions', () => { const b = { label: 'B' }; const grouped = [[a], [b]]; - expect(BinaryWindow.normActions(grouped)).toBe(grouped); + expect(normActions(grouped)).toBe(grouped); }); it('throws when an array is present but neither of the first two slots is one', () => { - expect(() => BinaryWindow.normActions([null, null, []])).toThrow('[bwin] Invalid actions format'); + expect(() => normActions([null, null, []])).toThrow('[bwin] Invalid actions format'); }); }); diff --git a/src/binary-window/detached-glass/action.attach.js b/src/binary-window/detached-glass/action.attach.js index 7ad71b9..1d5d123 100644 --- a/src/binary-window/detached-glass/action.attach.js +++ b/src/binary-window/detached-glass/action.attach.js @@ -1,4 +1,4 @@ -import { extractChildNodes } from '@/utils'; +import { transferGlass } from '../glass/utils'; export default { label: '', @@ -24,16 +24,13 @@ export default { size = 0.5; } - // Pass the inner nodes, not the bw-glass-content wrapper — Glass adds its own. - const contentEl = detachedGlassEl.querySelector('bw-glass-content'); - - binaryWindow.addPane(targetSashId, { + const paneSash = binaryWindow.addPane(targetSashId, { position, size, - content: extractChildNodes(contentEl), - title: detachedGlassEl.querySelector('bw-glass-title')?.textContent || '', }); + transferGlass(detachedGlassEl, paneSash.domNode); + binaryWindow.removeDetachedGlass(detachedGlassEl.id); }, }; diff --git a/src/binary-window/detached-glass/action.close.js b/src/binary-window/detached-glass/action.close.js index 11c1c6c..839970c 100644 --- a/src/binary-window/detached-glass/action.close.js +++ b/src/binary-window/detached-glass/action.close.js @@ -1,4 +1,5 @@ import { detachedGlassManager } from './manager'; +import { removeGlassBackdrop } from './utils'; export default { label: '', @@ -9,6 +10,8 @@ export default { detachedGlassManager.removeGlassById(glassEl.id); glassEl.remove(); + + // Remove the modal backdrop tied to this glass, if any (windowless modal glass). + removeGlassBackdrop(glassEl.id); }, }; - diff --git a/src/binary-window/detached-glass/activate.js b/src/binary-window/detached-glass/activate.js index 5f5d919..125834b 100644 --- a/src/binary-window/detached-glass/activate.js +++ b/src/binary-window/detached-glass/activate.js @@ -4,7 +4,7 @@ export default { enableDetachedGlassActivate() { // Clicking anywhere in a detached glass brings it to front. Move/resize // grabs bubble here too, so focus handling lives in one place. - this.windowElement.addEventListener('pointerdown', (event) => { + document.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; const glassEl = event.target.closest?.('bw-glass[detached]'); diff --git a/src/binary-window/detached-glass/detached-glass.js b/src/binary-window/detached-glass/detached-glass.js index c778f2a..df3539c 100644 --- a/src/binary-window/detached-glass/detached-glass.js +++ b/src/binary-window/detached-glass/detached-glass.js @@ -6,6 +6,11 @@ import attachAction from './action.attach'; import minimizeAction from './action.minimize'; export const DEFAULT_DETACHED_GLASS_ACTIONS = [minimizeAction, attachAction, closeAction]; + +// A windowless glass floats on `document.body` with no owning window, so minimize/attach +// (which need a `binaryWindow`) don't apply — close is the only built-in that works. +export const DEFAULT_WINDOWLESS_GLASS_ACTIONS = [closeAction]; + export class DetachedGlass extends Glass { constructor(options) { const { diff --git a/src/binary-window/detached-glass/index.js b/src/binary-window/detached-glass/index.js index beafd9e..6954fb4 100644 --- a/src/binary-window/detached-glass/index.js +++ b/src/binary-window/detached-glass/index.js @@ -5,7 +5,11 @@ import dragModule from './drag'; import resizeModule from './resize'; import restoreModule from './restore'; -export { DetachedGlass, DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass'; +export { + DetachedGlass, + DEFAULT_DETACHED_GLASS_ACTIONS, + DEFAULT_WINDOWLESS_GLASS_ACTIONS, +} from './detached-glass'; export default { enableDetachedGlassFeatures() { diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js index a1c155e..e2c3255 100644 --- a/src/binary-window/detached-glass/manager.js +++ b/src/binary-window/detached-glass/manager.js @@ -18,14 +18,17 @@ class DetachedGlassManager { // Already front-most (it owns the [active] marker) → nothing to raise. if (glassEl.hasAttribute('active')) return; - this.topZIndex += 1; + // Reserve 1 for modal on windowless glass + this.topZIndex += 2; glassEl.style.zIndex = this.topZIndex; // Only the front-most glass keeps [active]; it drives the stronger shadow in CSS. - glassEl.parentElement - ?.querySelectorAll(':scope > bw-glass[detached][active]') - .forEach((el) => el !== glassEl && el.removeAttribute('active')); + // Cleared across all managed glasses, so a detached and a windowless glass + // (different parents) can't both look active at once. + this.glasses.forEach((el) => el !== glassEl && el.removeAttribute('active')); glassEl.setAttribute('active', ''); + + return this.topZIndex; } removeGlassById(id) { diff --git a/src/binary-window/detached-glass/move.js b/src/binary-window/detached-glass/move.js index a37e563..08f6cb2 100644 --- a/src/binary-window/detached-glass/move.js +++ b/src/binary-window/detached-glass/move.js @@ -1,3 +1,6 @@ +import { clamp } from '@/utils'; +import { getResizeHandleOverhang, getContainingBlockOrigin } from './utils'; + export default { enableDetachedGlassMove() { let activeMoveGlassEl = null; @@ -5,8 +8,13 @@ export default { let moveStartY = 0; let moveStartLeft = 0; let moveStartTop = 0; + // Window-relative bounds that keep the glass within the viewport, captured at grab time. + let minMoveLeft = 0; + let maxMoveLeft = 0; + let minMoveTop = 0; + let maxMoveTop = 0; - this.windowElement.addEventListener('pointerdown', (event) => { + document.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; // Move from anywhere in the header (incl. title text), but not its buttons. @@ -25,18 +33,31 @@ export default { moveStartX = event.pageX; moveStartY = event.pageY; - // Normalize corner-anchored geometry to window-relative left/top. - const windowRect = this.windowElement.getBoundingClientRect(); + // Normalize corner-anchored geometry to left/top relative to the glass's + // containing block (the window for a detached glass, the viewport for a windowless one). + const origin = getContainingBlockOrigin(glassEl); const glassRect = glassEl.getBoundingClientRect(); - moveStartLeft = glassRect.left - windowRect.left; - moveStartTop = glassRect.top - windowRect.top; + moveStartLeft = glassRect.left - origin.left; + moveStartTop = glassRect.top - origin.top; + + // Bound the move to the viewport so dragging past an edge never grows the + // page. clientWidth/Height exclude scrollbars; the handle overhang on the + // right/bottom is reserved so hover handles stay on-screen too. + const overhang = getResizeHandleOverhang(glassEl); + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + minMoveLeft = -origin.left; + maxMoveLeft = viewportWidth - glassRect.width - overhang - origin.left; + minMoveTop = -origin.top; + maxMoveTop = viewportHeight - glassRect.height - overhang - origin.top; }); - this.windowElement.addEventListener('pointermove', (event) => { + document.addEventListener('pointermove', (event) => { if (!activeMoveGlassEl) return; - const left = moveStartLeft + (event.pageX - moveStartX); - const top = moveStartTop + (event.pageY - moveStartY); + // Clamp to the viewport; a glass larger than the viewport pins to the top-left edge. + const left = clamp(moveStartLeft + (event.pageX - moveStartX), minMoveLeft, maxMoveLeft); + const top = clamp(moveStartTop + (event.pageY - moveStartY), minMoveTop, maxMoveTop); activeMoveGlassEl.style.right = 'auto'; activeMoveGlassEl.style.bottom = 'auto'; @@ -44,7 +65,7 @@ export default { activeMoveGlassEl.style.top = `${top}px`; }); - this.windowElement.addEventListener('pointerup', (event) => { + document.addEventListener('pointerup', (event) => { if (!activeMoveGlassEl) return; if (event.target.hasPointerCapture?.(event.pointerId)) { diff --git a/src/binary-window/detached-glass/resize.js b/src/binary-window/detached-glass/resize.js index 614c8a4..628161e 100644 --- a/src/binary-window/detached-glass/resize.js +++ b/src/binary-window/detached-glass/resize.js @@ -1,4 +1,4 @@ -import { createResizeHandles } from './utils'; +import { createResizeHandles, getContainingBlockOrigin } from './utils'; const MIN_RESIZE_WIDTH = 100; const MIN_RESIZE_HEIGHT = 60; @@ -26,12 +26,12 @@ export default { let resizeStartRect = null; // Handles exist only while a glass is hovered, so idle glasses cost no extra DOM. - this.windowElement.addEventListener('pointerover', (event) => { + document.addEventListener('pointerover', (event) => { const glassEl = event.target.closest?.('bw-glass[detached]'); if (glassEl) addResizeHandles(glassEl); }); - this.windowElement.addEventListener('pointerout', (event) => { + document.addEventListener('pointerout', (event) => { const glassEl = event.target.closest?.('bw-glass[detached]'); if (!glassEl) return; @@ -45,7 +45,7 @@ export default { removeResizeHandles(glassEl); }); - this.windowElement.addEventListener('pointerdown', (event) => { + document.addEventListener('pointerdown', (event) => { if (event.button !== 0 || event.target.tagName !== 'BW-GLASS-RESIZE-HANDLE') return; const glassEl = event.target.closest('bw-glass[detached]'); @@ -59,19 +59,19 @@ export default { resizeStartX = event.pageX; resizeStartY = event.pageY; - // Normalize corner-anchored geometry to window-relative left/top/width/height - // so every edge resizes with the same math. - const windowRect = this.windowElement.getBoundingClientRect(); + // Normalize corner-anchored geometry to containing-block-relative + // left/top/width/height so every edge resizes with the same math. + const origin = getContainingBlockOrigin(glassEl); const glassRect = glassEl.getBoundingClientRect(); resizeStartRect = { - left: glassRect.left - windowRect.left, - top: glassRect.top - windowRect.top, + left: glassRect.left - origin.left, + top: glassRect.top - origin.top, width: glassRect.width, height: glassRect.height, }; }); - this.windowElement.addEventListener('pointermove', (event) => { + document.addEventListener('pointermove', (event) => { if (!activeResizeGlassEl) return; const dir = activeResizeDir; @@ -105,7 +105,7 @@ export default { activeResizeGlassEl.style.height = `${height}px`; }); - this.windowElement.addEventListener('pointerup', (event) => { + document.addEventListener('pointerup', (event) => { if (!activeResizeGlassEl) return; if (event.target.hasPointerCapture?.(event.pointerId)) { diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 99a28f0..ebba68b 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -9,6 +9,36 @@ export function createResizeHandles() { }); } +// Resize handles straddle the glass border, so each overhangs its edge by half +// the handle size. Returns that overhang, so callers can keep handles on-screen. +export function getResizeHandleOverhang(glassEl) { + const styles = getComputedStyle(glassEl); + const size = styles.getPropertyValue('--bw-detached-glass-resize-handle-size'); + return (parseFloat(size) || 0) / 2; +} + +// Remove the modal backdrop tied to a glass id, if one exists (windowless modal glass). +export function removeGlassBackdrop(glassId) { + const backdropEl = document.querySelector(`bw-glass-backdrop[for="${glassId}"]`); + backdropEl?.remove(); +} + +// Viewport-space top-left of an absolutely-positioned element's containing block. +export function getContainingBlockOrigin(el) { + const offsetParentEl = el.offsetParent; + + // Detached glass: the positioned `bw-window` IS the containing block, so its + // rect's top-left is the origin directly. + if (offsetParentEl && getComputedStyle(offsetParentEl).position !== 'static') { + const { left, top } = offsetParentEl.getBoundingClientRect(); + return { left, top }; + } + + // Windowless glass on a static `body`: the containing block is the initial one + // (the canvas at the document origin), which sits scroll-distance above the viewport. + return { left: -window.scrollX, top: -window.scrollY }; +} + // `offset` nudges the glass from the anchored corner/edge. `offsetX`/`offsetY` // override it per-axis; when only one is given, `offset` fills the other axis. export function genStylesByPosition({ position, offset, offsetX, offsetY, width, height }) { diff --git a/src/binary-window/glass/action.detach.js b/src/binary-window/glass/action.detach.js index d0766fb..a004a74 100644 --- a/src/binary-window/glass/action.detach.js +++ b/src/binary-window/glass/action.detach.js @@ -1,3 +1,5 @@ +import { transferGlass } from './utils'; + const DETACHED_GLASS_INSET = 15; export default { @@ -7,8 +9,7 @@ export default { if (!binaryWindow.addDetachedGlass) throw new Error('[bwin] Failed to detach glass from pane'); const paneEl = event.target.closest('bw-pane'); - const glassContentEl = paneEl.querySelector('bw-glass-content'); - const glassTitleEl = paneEl.querySelector('bw-glass-title'); + const glassEl = paneEl.querySelector('bw-glass'); const windowRect = binaryWindow.windowElement.getBoundingClientRect(); const width = windowRect.width - DETACHED_GLASS_INSET * 2; @@ -23,10 +24,7 @@ export default { detachedGlass.domNode.bwOriginalPosition = paneEl.getAttribute('position'); detachedGlass.domNode.bwOriginalRelativeSize = paneSash.getRelativeSize(); - detachedGlass.contentElement.replaceWith(glassContentEl); - - // Attached glass may only render tabs without title element - if (glassTitleEl) detachedGlass.titleElement.replaceWith(glassTitleEl); + transferGlass(glassEl, detachedGlass.domNode); binaryWindow.removePane(paneSashId); }, diff --git a/src/binary-window/glass/utils.js b/src/binary-window/glass/utils.js new file mode 100644 index 0000000..8e2a2c6 --- /dev/null +++ b/src/binary-window/glass/utils.js @@ -0,0 +1,22 @@ +// Moves the title/tabs and content children from one glass to another, keeping +// the target's own header actions. Used when detaching/attaching a glass. +export function transferGlass(sourceGlassElement, targetGlassElement) { + const sourceHeaderEl = sourceGlassElement.querySelector('bw-glass-header'); + const targetHeaderEl = targetGlassElement.querySelector('bw-glass-header'); + + // Remove the target's placeholder title/tabs before adopting the source's + targetHeaderEl.querySelector('bw-glass-title')?.remove(); + targetHeaderEl.querySelector('bw-glass-tab-container')?.remove(); + + const sourceTitleEl = sourceHeaderEl.querySelector('bw-glass-title'); + const sourceTabsEl = sourceHeaderEl.querySelector('bw-glass-tab-container'); + + // prepend keeps the title/tabs ahead of the target's action container + if (sourceTabsEl) targetHeaderEl.prepend(sourceTabsEl); + if (sourceTitleEl) targetHeaderEl.prepend(sourceTitleEl); + + // append moves each node out of the source content element + const sourceContentEl = sourceGlassElement.querySelector('bw-glass-content'); + const targetContentEl = targetGlassElement.querySelector('bw-glass-content'); + targetContentEl.append(...sourceContentEl.childNodes); +} diff --git a/src/binary-window/utils.js b/src/binary-window/utils.js new file mode 100644 index 0000000..29c4acb --- /dev/null +++ b/src/binary-window/utils.js @@ -0,0 +1,28 @@ +import { DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass'; +import { DEFAULT_GLASS_ACTIONS } from './glass'; + +// Returns [glassActions, detachedGlassActions] +export function normActions(actions) { + if (actions === undefined) return [DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS]; + if (!actions || !Array.isArray(actions) || actions.length === 0) return [[], []]; + + // [glassActions] + if (actions.length === 1 && Array.isArray(actions[0])) + return [actions[0], DEFAULT_DETACHED_GLASS_ACTIONS]; + + // [action1, action2, ...] + if (!actions.some(Array.isArray)) return [actions, DEFAULT_DETACHED_GLASS_ACTIONS]; + + // [undefined, detachedGlassActions] + if (actions.length >= 2 && !Array.isArray(actions[0]) && Array.isArray(actions[1])) + return [[], actions[1]]; + + // [glassActions, undefined] + if (actions.length >= 2 && Array.isArray(actions[0]) && !Array.isArray(actions[1])) + return [actions[0], []]; + + // [glassActions, detachedGlassActions] + if (actions.length >= 2 && Array.isArray(actions[0]) && Array.isArray(actions[1])) return actions; + + throw new Error(`[bwin] Invalid actions format`); +} diff --git a/src/css/detached-glass.css b/src/css/detached-glass.css index f29c10c..e1783a5 100644 --- a/src/css/detached-glass.css +++ b/src/css/detached-glass.css @@ -13,6 +13,13 @@ bw-glass[detached] > bw-glass-header[can-drag='true'] { } } +/* Modal backdrop behind a windowless glass; z-index set inline to sit just below its glass */ +bw-glass-backdrop { + position: fixed; + inset: 0; + background: var(--bw-glass-backdrop-color); +} + bw-glass-resize-handle { position: absolute; z-index: 2; diff --git a/src/css/theme.css b/src/css/theme.css new file mode 100644 index 0000000..9bec68a --- /dev/null +++ b/src/css/theme.css @@ -0,0 +1,25 @@ +/* TODO: differentiate settings between window and windowless glass */ +bw-window[theme='dark'], +bw-glass[windowless][theme='dark'] { + color-scheme: dark; + + --bw-glass-border-color: hsl(0 0% 40%); + --bw-glass-border-color-disabled: hsl(0 0% 30%); + --bw-glass-header-bg-color: hsl(0 0% 18%); + --bw-glass-bg-color-disabled: hsl(0 0% 16%); + --bw-drop-area-bg-color: hsl(0 0% 100% / 0.1); + --bw-detached-glass-shadow: 0 2px 6px hsl(0 0% 0% / 0.55), 0 4px 14px hsl(0 0% 0% / 0.65); + --bw-detached-glass-shadow-active: 0 4px 10px hsl(0 0% 0% / 0.65), 0 10px 30px hsl(0 0% 0% / 0.8); + --bw-minimized-glass-highlight-color: hsl(0 0% 65% / 0.8); + + bw-glass-content { + color: hsl(0 0% 90%); + background-color: hsl(0 0% 16%); + } + + .bw-glass-tab:hover, + .bw-glass-action:hover, + .bw-minimized-glass:hover { + background-color: hsl(0 0% 26%); + } +} diff --git a/src/css/vars.css b/src/css/vars.css index 048827e..36eae1e 100644 --- a/src/css/vars.css +++ b/src/css/vars.css @@ -1,4 +1,4 @@ -bw-window { +:root { --bw-font-family: system-ui; --bw-font-size: 14px; --bw-drop-area-bg-color: hsl(0, 0%, 0%, 0.05); @@ -16,40 +16,14 @@ bw-window { --bw-minimized-glass-highlight-color: hsl(0, 0%, 85%); --bw-detached-glass-shadow: 0 2px 6px hsl(0, 0%, 0%, 0.12), 0 4px 14px hsl(0, 0%, 0%, 0.22); - --bw-detached-glass-shadow-active: 0 4px 10px hsl(0, 0%, 0%, 0.22), 0 10px 30px hsl(0, 0%, 0%, 0.38); + --bw-detached-glass-shadow-active: + 0 4px 10px hsl(0, 0%, 0%, 0.22), 0 10px 30px hsl(0, 0%, 0%, 0.38); /* Thickness of the resize grab zone, straddling the glass border */ --bw-detached-glass-resize-handle-size: 12px; - --bw-sill-gap: 6px; -} - -bw-window[theme='dark'] { - /* Render buttons, scrollbars, and other native UI elements in dark mode */ - color-scheme: dark; - - --bw-glass-border-color: hsl(0 0% 40%); - --bw-glass-border-color-disabled: hsl(0 0% 30%); - --bw-glass-header-bg-color: hsl(0 0% 18%); - --bw-glass-bg-color-disabled: hsl(0 0% 16%); - --bw-drop-area-bg-color: hsl(0 0% 100% / 0.1); - --bw-detached-glass-shadow: 0 2px 6px hsl(0 0% 0% / 0.55), 0 4px 14px hsl(0 0% 0% / 0.65); - --bw-detached-glass-shadow-active: 0 4px 10px hsl(0 0% 0% / 0.65), 0 10px 30px hsl(0 0% 0% / 0.8); - --bw-minimized-glass-highlight-color: hsl(0 0% 65% / 0.8); + /* Modal backdrop behind a windowless glass */ + --bw-glass-backdrop-color: hsl(0, 0%, 0%, 0.4); - bw-pane, - bw-muntin { - background-color: hsl(0 0% 14%); - } - - bw-glass-content { - color: hsl(0 0% 90%); - background-color: hsl(0 0% 16%); - } - - .bw-glass-tab:hover, - .bw-glass-action:hover, - .bw-minimized-glass:hover { - background-color: hsl(0 0% 26%); - } + --bw-sill-gap: 6px; } diff --git a/src/index.js b/src/index.js index af5b2f5..4a44ac7 100644 --- a/src/index.js +++ b/src/index.js @@ -4,11 +4,12 @@ import './css/frame.css'; import './css/glass.css'; import './css/detached-glass.css'; import './css/sill.css'; +import './css/theme.css'; export { Frame } from './frame/frame'; export { BinaryWindow } from './binary-window/binary-window'; export { DEFAULT_GLASS_ACTIONS } from './binary-window/glass'; -export { DEFAULT_DETACHED_GLASS_ACTIONS } from './binary-window/detached-glass'; +export { DEFAULT_DETACHED_GLASS_ACTIONS, DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './binary-window/detached-glass'; export { Sash } from './sash'; export { SashConfig } from './config/sash-config'; export { ConfigRoot } from './config/config-root'; diff --git a/src/utils.js b/src/utils.js index 2a4c72b..9d7bece 100644 --- a/src/utils.js +++ b/src/utils.js @@ -137,6 +137,18 @@ export function parseSize(size) { return NaN; } +/** + * Clamp a number to the inclusive range [min, max] + * + * @param {number} value - The value to clamp + * @param {number} min - The lower bound + * @param {number} max - The upper bound + * @returns {number} + */ +export function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + /** * Check if a value is a plain object, not array, null, etc *