@signal9/era-ui 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/os/taskbar.svelte +11 -0
- package/dist/os/window.svelte +54 -16
- package/dist/os/wm.svelte.d.ts +18 -2
- package/dist/os/wm.svelte.js +34 -1
- package/dist/ui/pane/pane-root.svelte +29 -0
- package/package.json +1 -1
package/dist/os/taskbar.svelte
CHANGED
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
const wm = getWindowManager();
|
|
27
27
|
const notif = getNotifications();
|
|
28
28
|
|
|
29
|
+
// Register our height as reserved chrome: maximize stops above the bar, and
|
|
30
|
+
// drag containment keeps window bars reachable above it (not hidden behind).
|
|
31
|
+
let barHeight = $state(0);
|
|
32
|
+
$effect(() => {
|
|
33
|
+
wm.insets.bottom = barHeight;
|
|
34
|
+
return () => {
|
|
35
|
+
wm.insets.bottom = 0;
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
29
39
|
// Live clock (minute resolution is plenty; tick often enough to feel current).
|
|
30
40
|
let now = $state(new Date());
|
|
31
41
|
$effect(() => {
|
|
@@ -39,6 +49,7 @@
|
|
|
39
49
|
at the bar's own tier (h-lg → rd-lg). The corners axis gates it for free:
|
|
40
50
|
data-corners="square" zeroes --era-roundness-scale and with it rd-lg. -->
|
|
41
51
|
<div
|
|
52
|
+
bind:clientHeight={barHeight}
|
|
42
53
|
class={cn(
|
|
43
54
|
'absolute inset-x-0 bottom-0 flex h-(--era-h-lg) items-center gap-(--era-gap) rounded-t-(--era-rd-lg) border-t border-divider-faded bg-(--era-surface-bg-elevated) px-(--era-inset-sm) shadow-(--era-shadow-lg) glass-blur',
|
|
44
55
|
LAYER.taskbar,
|
package/dist/os/window.svelte
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import * as Pane from '../ui/pane';
|
|
3
3
|
import { Button } from '../ui/button';
|
|
4
4
|
import Minus from '@lucide/svelte/icons/minus';
|
|
5
|
+
import Square from '@lucide/svelte/icons/square';
|
|
5
6
|
import X from '@lucide/svelte/icons/x';
|
|
7
|
+
import type { Plugin } from '@neodrag/svelte';
|
|
6
8
|
import { cn } from '../utils/index.js';
|
|
7
9
|
import { getWindowManager, type AppWindow } from './wm.svelte.js';
|
|
8
10
|
|
|
@@ -11,14 +13,40 @@
|
|
|
11
13
|
const wm = getWindowManager();
|
|
12
14
|
const focused = $derived(wm.focusedId === win.id);
|
|
13
15
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// the
|
|
21
|
-
|
|
16
|
+
// Shell containment: a window can never become unrecoverable. The bar may not
|
|
17
|
+
// leave the TOP of the shell at all (y ≥ top inset), and on every other side
|
|
18
|
+
// at least wm.dragKeep px of the window must stay inside the work area (which
|
|
19
|
+
// excludes the taskbar — a strip hidden behind chrome would be just as lost).
|
|
20
|
+
// Clamped mid-drag the way neodrag's own bounds plugin does it: offset +
|
|
21
|
+
// proposed delta → clamp → re-propose the delta, so the cursor simply stops
|
|
22
|
+
// pulling at the fence instead of rubber-banding on release. Only a `drag`
|
|
23
|
+
// hook (width read live), and a STABLE plugin list — an inline array would
|
|
24
|
+
// change identity every render and make neodrag reconcile mid-session.
|
|
25
|
+
const shellClamp: Plugin = {
|
|
26
|
+
name: 'era:shell-clamp',
|
|
27
|
+
drag(ctx) {
|
|
28
|
+
const { width: W, height: H } = wm.bounds;
|
|
29
|
+
if (!W || !H) return;
|
|
30
|
+
const { top, right, bottom, left } = wm.insets;
|
|
31
|
+
const keep = wm.dragKeep;
|
|
32
|
+
const width = (ctx.rootNode as HTMLElement).offsetWidth;
|
|
33
|
+
const px = ctx.offset.x + (ctx.proposed.x ?? 0);
|
|
34
|
+
const py = ctx.offset.y + (ctx.proposed.y ?? 0);
|
|
35
|
+
const cx = Math.min(Math.max(px, left + keep - width), W - right - keep);
|
|
36
|
+
const cy = Math.min(Math.max(py, top), H - bottom - keep);
|
|
37
|
+
ctx.propose(cx - ctx.offset.x, cy - ctx.offset.y);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const windowPlugins = [shellClamp];
|
|
41
|
+
|
|
42
|
+
// The window's single control: left-click CLOSES, right-click MINIMIZES (the
|
|
43
|
+
// taskbar restores it), middle-click toggles MAXIMIZE — no dedicated buttons.
|
|
44
|
+
// While a non-primary button is held the glyph previews its action (minus /
|
|
45
|
+
// square); a left press keeps the ×, so holding never implies another action
|
|
46
|
+
// when the release will close. Like a native click, each action COMMITS on
|
|
47
|
+
// release, not press: the press only arms it (contextmenu / autoscroll are
|
|
48
|
+
// suppressed), pointerup over the button fires it, dragging off first cancels.
|
|
49
|
+
let armed = $state<'minimize' | 'maximize' | null>(null);
|
|
22
50
|
|
|
23
51
|
// z-index is set imperatively, NOT via a style="" prop: Pane positions itself
|
|
24
52
|
// by writing element.style.translate, and a whole-attribute style binding would
|
|
@@ -34,6 +62,7 @@
|
|
|
34
62
|
bind:position={win.pos}
|
|
35
63
|
bind:size={win.size}
|
|
36
64
|
resizable={win.resizable}
|
|
65
|
+
plugins={windowPlugins}
|
|
37
66
|
onpointerdowncapture={() => wm.focus(win.id)}
|
|
38
67
|
class={cn(
|
|
39
68
|
'transition-[box-shadow,opacity]',
|
|
@@ -49,25 +78,34 @@
|
|
|
49
78
|
{/if}
|
|
50
79
|
<span class={cn('truncate text-body', focused ? 'text-bright' : 'text-muted')}>{win.title}</span
|
|
51
80
|
>
|
|
52
|
-
<!-- Close / minimize. data-pane-control so it clicks instead of
|
|
81
|
+
<!-- Close / minimize / maximize. data-pane-control so it clicks instead of
|
|
82
|
+
dragging the pane. -->
|
|
53
83
|
<div class="ml-auto shrink-0" data-pane-control>
|
|
54
84
|
<Button
|
|
55
85
|
icon
|
|
56
86
|
size="icon"
|
|
57
87
|
tone="destructive"
|
|
58
|
-
aria-label="Close (right-click
|
|
88
|
+
aria-label="Close (right-click minimizes, middle-click maximizes)"
|
|
59
89
|
onclick={() => wm.close(win.id)}
|
|
60
90
|
oncontextmenu={(e: MouseEvent) => e.preventDefault()}
|
|
61
|
-
onpointerdown={(e: PointerEvent) =>
|
|
91
|
+
onpointerdown={(e: PointerEvent) => {
|
|
92
|
+
// Cancelling the middle pointerdown suppresses its compatibility
|
|
93
|
+
// mouse events, so the browser's autoscroll never engages.
|
|
94
|
+
if (e.button === 1) e.preventDefault();
|
|
95
|
+
armed = e.button === 2 ? 'minimize' : e.button === 1 ? 'maximize' : null;
|
|
96
|
+
}}
|
|
62
97
|
onpointerup={(e: PointerEvent) => {
|
|
63
|
-
if (e.button === 2 &&
|
|
64
|
-
|
|
98
|
+
if (e.button === 2 && armed === 'minimize') wm.minimize(win.id);
|
|
99
|
+
else if (e.button === 1 && armed === 'maximize') wm.toggleMaximize(win.id);
|
|
100
|
+
armed = null;
|
|
65
101
|
}}
|
|
66
|
-
onpointerleave={() => (
|
|
67
|
-
onpointercancel={() => (
|
|
102
|
+
onpointerleave={() => (armed = null)}
|
|
103
|
+
onpointercancel={() => (armed = null)}
|
|
68
104
|
>
|
|
69
|
-
{#if
|
|
105
|
+
{#if armed === 'minimize'}
|
|
70
106
|
<Minus class="size-(--era-h-xs)" />
|
|
107
|
+
{:else if armed === 'maximize'}
|
|
108
|
+
<Square class="size-(--era-h-xs)" />
|
|
71
109
|
{:else}
|
|
72
110
|
<X class="size-(--era-h-xs)" />
|
|
73
111
|
{/if}
|
package/dist/os/wm.svelte.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Component } from 'svelte';
|
|
2
2
|
import type { IconProps } from '@lucide/svelte';
|
|
3
|
-
export type WindowState = 'normal' | 'minimized';
|
|
3
|
+
export type WindowState = 'normal' | 'minimized' | 'maximized';
|
|
4
4
|
/** Snap targets: the four edges (halves) or four corners (quadrants). */
|
|
5
5
|
export type SnapZone = 'left' | 'right' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
6
6
|
/** Props every app body receives — its own window record and the manager, so it
|
|
@@ -76,6 +76,17 @@ export declare class WindowManager {
|
|
|
76
76
|
width: number;
|
|
77
77
|
height: number;
|
|
78
78
|
};
|
|
79
|
+
/** Edges reserved by chrome (the Taskbar registers its height as `bottom`).
|
|
80
|
+
* The work area = bounds − insets; maximize and drag containment honour it. */
|
|
81
|
+
insets: {
|
|
82
|
+
top: number;
|
|
83
|
+
right: number;
|
|
84
|
+
bottom: number;
|
|
85
|
+
left: number;
|
|
86
|
+
};
|
|
87
|
+
/** Drag containment: a window's bar may never leave the top of the shell, and
|
|
88
|
+
* at least this many px of it must stay reachable on every other side. */
|
|
89
|
+
dragKeep: number;
|
|
79
90
|
/** The focused window = the top-most non-minimized one. */
|
|
80
91
|
focusedId: string | null;
|
|
81
92
|
register(app: AppDefinition): void;
|
|
@@ -88,7 +99,12 @@ export declare class WindowManager {
|
|
|
88
99
|
minimize(id: string): void;
|
|
89
100
|
/** Taskbar behaviour: focus if not focused, else minimize; restore if minimized. */
|
|
90
101
|
toggleMinimize(id: string): void;
|
|
91
|
-
/**
|
|
102
|
+
/** Fill the WORK AREA — bounds minus chrome insets, so a maximized window
|
|
103
|
+
* doesn't hide under the taskbar (middle-click on the window's ×). Keeps the
|
|
104
|
+
* first pre-maximize/pre-snap snapshot so restore returns to the true origin. */
|
|
105
|
+
maximize(id: string): void;
|
|
106
|
+
toggleMaximize(id: string): void;
|
|
107
|
+
/** Un-maximize / un-snap back to the pre-change geometry (or just un-minimize). */
|
|
92
108
|
restore(id: string): void;
|
|
93
109
|
/** Half / quadrant tiling against the desktop bounds. */
|
|
94
110
|
snap(id: string, zone: SnapZone): void;
|
package/dist/os/wm.svelte.js
CHANGED
|
@@ -12,6 +12,12 @@ export class WindowManager {
|
|
|
12
12
|
apps = $state.raw({});
|
|
13
13
|
/** Desktop viewport size — set by the Desktop host; drives maximize / snap. */
|
|
14
14
|
bounds = $state({ width: 0, height: 0 });
|
|
15
|
+
/** Edges reserved by chrome (the Taskbar registers its height as `bottom`).
|
|
16
|
+
* The work area = bounds − insets; maximize and drag containment honour it. */
|
|
17
|
+
insets = $state({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
18
|
+
/** Drag containment: a window's bar may never leave the top of the shell, and
|
|
19
|
+
* at least this many px of it must stay reachable on every other side. */
|
|
20
|
+
dragKeep = 25;
|
|
15
21
|
#z = 0;
|
|
16
22
|
#seq = 0;
|
|
17
23
|
/** The focused window = the top-most non-minimized one. */
|
|
@@ -95,7 +101,34 @@ export class WindowManager {
|
|
|
95
101
|
else
|
|
96
102
|
this.focus(id);
|
|
97
103
|
}
|
|
98
|
-
/**
|
|
104
|
+
/** Fill the WORK AREA — bounds minus chrome insets, so a maximized window
|
|
105
|
+
* doesn't hide under the taskbar (middle-click on the window's ×). Keeps the
|
|
106
|
+
* first pre-maximize/pre-snap snapshot so restore returns to the true origin. */
|
|
107
|
+
maximize(id) {
|
|
108
|
+
const w = this.#find(id);
|
|
109
|
+
if (!w)
|
|
110
|
+
return;
|
|
111
|
+
if (!w.restore)
|
|
112
|
+
w.restore = { pos: { ...w.pos }, size: w.size ? { ...w.size } : null };
|
|
113
|
+
const { top, right, bottom, left } = this.insets;
|
|
114
|
+
w.pos = { x: left, y: top };
|
|
115
|
+
w.size = {
|
|
116
|
+
width: this.bounds.width - left - right,
|
|
117
|
+
height: this.bounds.height - top - bottom
|
|
118
|
+
};
|
|
119
|
+
w.state = 'maximized';
|
|
120
|
+
this.focus(id);
|
|
121
|
+
}
|
|
122
|
+
toggleMaximize(id) {
|
|
123
|
+
const w = this.#find(id);
|
|
124
|
+
if (!w)
|
|
125
|
+
return;
|
|
126
|
+
if (w.state === 'maximized')
|
|
127
|
+
this.restore(id);
|
|
128
|
+
else
|
|
129
|
+
this.maximize(id);
|
|
130
|
+
}
|
|
131
|
+
/** Un-maximize / un-snap back to the pre-change geometry (or just un-minimize). */
|
|
99
132
|
restore(id) {
|
|
100
133
|
const w = this.#find(id);
|
|
101
134
|
if (!w)
|
|
@@ -83,6 +83,33 @@
|
|
|
83
83
|
}
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
+
// Replaces neodrag's DEFAULT threshold plugin (same name — the engine keys
|
|
87
|
+
// plugins by name, last one wins, and user plugins come after defaults). The
|
|
88
|
+
// default keeps its 3px click-vs-drag distance gate but ALSO cancels the drag
|
|
89
|
+
// when the first pointermove's target has escaped the dragged node — which is
|
|
90
|
+
// exactly what a fast flick does (the cursor out-runs the bar before the drag
|
|
91
|
+
// starts and captures the pointer), so quick grabs randomly never engaged.
|
|
92
|
+
// Same distance gate here, no containment bail: the pointerdown already
|
|
93
|
+
// decided eligibility (dragGate), where the cursor is one move later must not.
|
|
94
|
+
const flickSafeThreshold: Plugin = {
|
|
95
|
+
name: 'neodrag:threshold',
|
|
96
|
+
setup() {
|
|
97
|
+
return { x: 0, y: 0 };
|
|
98
|
+
},
|
|
99
|
+
shouldStart(_ctx, state: { x: number; y: number }, event) {
|
|
100
|
+
const e = event as PointerEvent;
|
|
101
|
+
state.x = e.clientX;
|
|
102
|
+
state.y = e.clientY;
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
drag(ctx, state: { x: number; y: number }, event) {
|
|
106
|
+
if (ctx.isDragging) return;
|
|
107
|
+
const e = event as PointerEvent;
|
|
108
|
+
// Clean clicks stay clicks: no drag until the pointer has moved > 3px.
|
|
109
|
+
if ((e.clientX - state.x) ** 2 + (e.clientY - state.y) ** 2 <= 9) ctx.preventStart();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
86
113
|
// Ctrl held is ALSO tracked reactively — but only for the visual affordances
|
|
87
114
|
// (grab cursor, no text selection, inert tabs), never for the drag itself. If
|
|
88
115
|
// this lags the keypress, the drag still works (the gate reads the event); the
|
|
@@ -303,6 +330,8 @@
|
|
|
303
330
|
// Gates which pointerdown starts a drag: handle bar only, or the whole pane
|
|
304
331
|
// while Ctrl is held. Stateless — decided live off the event. See above.
|
|
305
332
|
dragGate,
|
|
333
|
+
// Overrides the default threshold by name — fast flicks must engage. See above.
|
|
334
|
+
flickSafeThreshold,
|
|
306
335
|
// Exposes data-neodrag-state="idle | dragging" on the root — the drag-only
|
|
307
336
|
// layer-promotion hook hangs off it.
|
|
308
337
|
stateMarker(),
|