rori 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +279 -0
- data/app/assets/stylesheets/rori/_init.css +79 -0
- data/app/assets/stylesheets/rori/layout.css +162 -0
- data/app/assets/stylesheets/rori/menubar.css +117 -0
- data/app/assets/stylesheets/rori/notifications.css +120 -0
- data/app/assets/stylesheets/rori/palette.css +114 -0
- data/app/assets/stylesheets/rori/shortcuts.css +110 -0
- data/app/assets/stylesheets/rori/terminal.css +98 -0
- data/app/assets/stylesheets/rori/themes.css +156 -0
- data/app/assets/stylesheets/rori/typing_badge.css +56 -0
- data/app/assets/stylesheets/rori/wallpaper.css +52 -0
- data/app/assets/stylesheets/rori/window.css +174 -0
- data/app/controllers/concerns/rori/windowed.rb +15 -0
- data/app/controllers/rori/commands/bars_controller.rb +9 -0
- data/app/controllers/rori/commands/runs_controller.rb +22 -0
- data/app/controllers/rori/commands/themes_controller.rb +9 -0
- data/app/controllers/rori/commands/ui_controller.rb +9 -0
- data/app/controllers/rori/commands/wallpapers_controller.rb +9 -0
- data/app/controllers/rori/commands_controller.rb +7 -0
- data/app/controllers/rori/desktops_controller.rb +7 -0
- data/app/helpers/rori/assets_helper.rb +9 -0
- data/app/helpers/rori/keymap_helper.rb +50 -0
- data/app/helpers/rori/prefs_helper.rb +13 -0
- data/app/helpers/rori/theme_helper.rb +14 -0
- data/app/helpers/rori/wallpaper_helper.rb +19 -0
- data/app/helpers/rori/window_helper.rb +36 -0
- data/app/javascript/rori/clock_controller.js +18 -0
- data/app/javascript/rori/command_index.js +84 -0
- data/app/javascript/rori/fuzzy.js +50 -0
- data/app/javascript/rori/index.js +21 -0
- data/app/javascript/rori/notifications_controller.js +45 -0
- data/app/javascript/rori/palette_controller.js +307 -0
- data/app/javascript/rori/prefs_controller.js +68 -0
- data/app/javascript/rori/rori_controller.js +996 -0
- data/app/javascript/rori/terminal_controller.js +198 -0
- data/app/javascript/rori/theme_controller.js +35 -0
- data/app/javascript/rori/wallpaper_controller.js +67 -0
- data/app/models/rori/command.rb +105 -0
- data/app/models/rori/notification.rb +20 -0
- data/app/models/rori/theme/color.rb +30 -0
- data/app/models/rori/theme.rb +87 -0
- data/app/models/rori/wallpaper.rb +54 -0
- data/app/views/layouts/rori/_head.html.haml +2 -0
- data/app/views/layouts/rori/shell.html.haml +97 -0
- data/app/views/layouts/rori/window.html.haml +10 -0
- data/app/views/rori/_menubar.html.haml +14 -0
- data/app/views/rori/_notification.html.haml +9 -0
- data/app/views/rori/_notifications.html.haml +10 -0
- data/app/views/rori/_palette.html.haml +13 -0
- data/app/views/rori/_shortcuts.html.haml +47 -0
- data/app/views/rori/_terminal.html.haml +9 -0
- data/app/views/rori/_wallpaper.html.haml +7 -0
- data/app/views/rori/_wallpaper_credit.html.haml +3 -0
- data/app/views/rori/_window.html.haml +15 -0
- data/app/views/rori/commands/_command.html.haml +17 -0
- data/app/views/rori/commands/index.html.haml +5 -0
- data/app/views/rori/desktops/show.html.haml +1 -0
- data/config/importmap.rb +3 -0
- data/config/locales/rori.en.yml +133 -0
- data/config/routes/rori.rb +13 -0
- data/lib/generators/rori/install/install_generator.rb +67 -0
- data/lib/generators/rori/install/templates/rori.rb.tt +22 -0
- data/lib/rori/engine.rb +19 -0
- data/lib/rori/version.rb +3 -0
- data/lib/rori.rb +180 -0
- data/lib/tasks/rori.rake +9 -0
- data/vendor/themes/LICENSE +25 -0
- data/vendor/themes/ghostty/Catppuccin Latte +22 -0
- data/vendor/themes/ghostty/Catppuccin Mocha +22 -0
- data/vendor/themes/ghostty/Everforest Dark Med +22 -0
- data/vendor/themes/ghostty/Flexoki Light +22 -0
- data/vendor/themes/ghostty/GitHub Dark Dimmed +22 -0
- data/vendor/themes/ghostty/Gruvbox Material Dark +22 -0
- data/vendor/themes/ghostty/Kanagawa Wave +22 -0
- data/vendor/themes/ghostty/Melange Dark +22 -0
- data/vendor/themes/ghostty/Nord +22 -0
- data/vendor/themes/ghostty/Rose Pine Dawn +22 -0
- data/vendor/themes/ghostty/Rose Pine Moon +22 -0
- data/vendor/themes/ghostty/TokyoNight Storm +22 -0
- data/vendor/themes/ghostty/Tomorrow Night Eighties +22 -0
- data/vendor/themes/ghostty/Zenbones Light +22 -0
- data/vendor/wallpapers/unsplash.yml +23 -0
- metadata +219 -0
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
const GAP = 12
|
|
4
|
+
const STORAGE_KEY = "rori"
|
|
5
|
+
const WIDTHS = [1 / 3, 1 / 2, 2 / 3]
|
|
6
|
+
const SIZES = { sm: 1 / 3, md: 1 / 2, lg: 2 / 3, xl: 1 }
|
|
7
|
+
const MODAL_WIDTHS = { sm: 440, md: 640, lg: 900, xl: 1200 }
|
|
8
|
+
const MODIFIERS = ["Control", "Alt", "Meta", "Shift"]
|
|
9
|
+
const FIELDS = "input, textarea, select, [contenteditable]"
|
|
10
|
+
// Chrome can leave focus on a field inside a dialog that just closed (the
|
|
11
|
+
// palette); nobody is typing there, so it mustn't swallow desk keys.
|
|
12
|
+
const typing = (target) => Boolean(target.closest?.(FIELDS) && !target.closest("dialog:not([open])"))
|
|
13
|
+
const CLOSED_LIMIT = 20
|
|
14
|
+
// A resting pointer that jitters less than this doesn't re-arm hover keys.
|
|
15
|
+
const HOVER_JITTER = 4
|
|
16
|
+
|
|
17
|
+
const clamp = (n, min, max) => Math.max(min, Math.min(n, max))
|
|
18
|
+
|
|
19
|
+
// Scrollable tiling (niri/PaperWM): each workspace is an endless horizontal strip
|
|
20
|
+
// of columns, each column stacks one or more windows, workspaces stack vertically.
|
|
21
|
+
//
|
|
22
|
+
// Every window is a <dialog> around a <turbo-frame refresh="morph">, so links and
|
|
23
|
+
// forms inside it navigate just that window. Pages describe how they want to be
|
|
24
|
+
// shown with a <template data-window-meta> (Rori::WindowHelper#window), re-read on every
|
|
25
|
+
// frame load. New windows load hidden in `floating` and are placed once their page
|
|
26
|
+
// says where they belong; modals stay there, shown with showModal().
|
|
27
|
+
export default class extends Controller {
|
|
28
|
+
static targets = ["viewport", "stack", "floating", "window", "template", "workspaces", "minimap"]
|
|
29
|
+
static values = {
|
|
30
|
+
rootUrl: String, loadError: String, workspaceGroup: String, newWorkspaceLabel: String, discardPrompt: String,
|
|
31
|
+
keymap: Object, hoverKeymap: Object, hoverTimeout: { type: Number, default: 1500 }, native: Boolean,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
initialize() {
|
|
35
|
+
this.offsets = new WeakMap() // workspace → strip scroll offset
|
|
36
|
+
this.spans = new WeakMap() // workspace → { spans, total } from the last layout
|
|
37
|
+
this.lastFocus = new WeakMap() // workspace → window
|
|
38
|
+
this.lastInColumn = new WeakMap() // column → window
|
|
39
|
+
this.histories = new WeakMap() // window → back stack
|
|
40
|
+
this.pendingReloads = new Set()
|
|
41
|
+
this.closed = [] // recently closed windows, for reopen_window
|
|
42
|
+
this.overview = false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
connect() {
|
|
46
|
+
this.element.classList.add("is-booting")
|
|
47
|
+
this.#restore()
|
|
48
|
+
requestAnimationFrame(() => requestAnimationFrame(() => this.element.classList.remove("is-booting")))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Chord ("Alt+Shift+Digit2") → { action, digit } from Rori.keymap (see app/models/desk.rb).
|
|
52
|
+
keymapValueChanged(keymap) {
|
|
53
|
+
this.bindings = this.#bindingsFrom(keymap)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Rori.hover_keymap: bare keys for the window under the pointer (empty when off).
|
|
57
|
+
hoverKeymapValueChanged(keymap) {
|
|
58
|
+
this.hoverBindings = this.#bindingsFrom(keymap)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Entry points ---------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// Server-side commands (`run`) are rori-notifications' job.
|
|
64
|
+
command({ detail: { url, action, param, run, newWindow } }) {
|
|
65
|
+
if (run) return
|
|
66
|
+
if (url) return this.#spawn(url, { reuse: !newWindow })
|
|
67
|
+
this.#perform(action, { workspace: param })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Client-side command lists (Rori::Command `source:`), for ⌘K and the terminal:
|
|
71
|
+
// workspaces only exist in the browser.
|
|
72
|
+
commandSource({ detail }) {
|
|
73
|
+
if (detail.source !== "workspaces" || !this.#focusIn(this.current)) return
|
|
74
|
+
|
|
75
|
+
const item = (label, param) => ({ label, group: this.workspaceGroupValue, action: "move_to_workspace", param })
|
|
76
|
+
detail.items = [
|
|
77
|
+
...this.#workspaces().filter((ws) => ws !== this.current).map((ws) => item(this.#workspaceLabel(ws), ws.dataset.name)),
|
|
78
|
+
item(this.newWorkspaceLabelValue, ""),
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// rori_link_to (data-turbo-frame="_top") and any other top-level visit open a window instead.
|
|
83
|
+
interceptVisit(event) {
|
|
84
|
+
event.preventDefault()
|
|
85
|
+
this.#spawn(event.detail.url, { reuse: true })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Remember which windows a refresh came from; their stream sources live inside them.
|
|
89
|
+
streamMessage(event) {
|
|
90
|
+
if (!event.target.matches?.("turbo-cable-stream-source")) return
|
|
91
|
+
if (typeof event.data === "string" && event.data.includes('action="refresh"')) {
|
|
92
|
+
const win = event.target.closest(".rori-win")
|
|
93
|
+
if (win) this.pendingReloads.add(win)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A page refresh would morph the whole body and drop client-opened windows,
|
|
98
|
+
// so refreshes reload (and morph) only the affected window frames.
|
|
99
|
+
streamRender(event) {
|
|
100
|
+
if (event.target.getAttribute?.("action") !== "refresh") return
|
|
101
|
+
event.detail.render = () => this.#reloadSoon()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
keydown(event) {
|
|
105
|
+
// Surfaces that own the keyboard (the terminal) opt out of every desk key.
|
|
106
|
+
if (event.target.closest?.("[data-rori-keys=off]")) return
|
|
107
|
+
if (this.#emptyDeskKey(event)) return
|
|
108
|
+
if (this.#bareKey(event)) return
|
|
109
|
+
if (event.key === "Escape") return this.#escape(event)
|
|
110
|
+
|
|
111
|
+
const binding = this.bindings.get(this.#chord(event))
|
|
112
|
+
// Chords edit text inside fields (⌥← jumps a word, ⌘← to line start): leave them be.
|
|
113
|
+
if (!binding || typing(event.target)) return
|
|
114
|
+
|
|
115
|
+
event.preventDefault()
|
|
116
|
+
this.#perform(binding.action, { workspace: this.#workspaceAt(binding.digit) })
|
|
117
|
+
if (this.focused && !this.focused.contains(document.activeElement)) this.focused.focus({ preventScroll: true })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Blender-style: after the pointer deliberately moves onto an inactive window,
|
|
121
|
+
// bare hover keys act on that window (even while a field elsewhere has focus)
|
|
122
|
+
// until the pointer rests for hoverTimeout, leaves, or you type anything else.
|
|
123
|
+
hover(event) {
|
|
124
|
+
if (!this.hoverBindings?.size || this.overview || !["mouse", "pen"].includes(event.pointerType)) return
|
|
125
|
+
this.pointer = [event.clientX, event.clientY]
|
|
126
|
+
const win = event.target.closest(".rori-col > .rori-win")
|
|
127
|
+
if (!win || win === this.focused) return this.#disarm()
|
|
128
|
+
if (win !== this.armed && this.restingAt &&
|
|
129
|
+
Math.hypot(event.clientX - this.restingAt[0], event.clientY - this.restingAt[1]) < HOVER_JITTER) return
|
|
130
|
+
this.#arm(win)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
unhover() {
|
|
134
|
+
this.#disarm()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Esc closes the overview, or hands focus from a field back to its window so
|
|
138
|
+
// the keymap applies again. In modals Esc keeps closing the modal.
|
|
139
|
+
#escape(event) {
|
|
140
|
+
if (this.overview) {
|
|
141
|
+
event.preventDefault()
|
|
142
|
+
return this.toggleOverview()
|
|
143
|
+
}
|
|
144
|
+
const win = event.target.closest?.(".rori-win")
|
|
145
|
+
if (win && !win.matches(":modal") && typing(event.target)) {
|
|
146
|
+
event.preventDefault()
|
|
147
|
+
return win.focus({ preventScroll: true })
|
|
148
|
+
}
|
|
149
|
+
// Native shell: WebKit hands an Esc the page didn't claim to the window, and
|
|
150
|
+
// a macOS full-screen window leaves full screen — even when the Esc also
|
|
151
|
+
// closed a modal (its default action). So claim every Esc and close the
|
|
152
|
+
// topmost modal ourselves, the way the browser would.
|
|
153
|
+
if (this.nativeValue && !event.defaultPrevented) {
|
|
154
|
+
event.preventDefault()
|
|
155
|
+
this.#cancelTopModal()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// What Esc does to a modal <dialog>: a cancelable `cancel`, then close unless
|
|
160
|
+
// a listener objected (modal windows handle cancel themselves, see #cancel).
|
|
161
|
+
#cancelTopModal() {
|
|
162
|
+
const modal = [...document.querySelectorAll("dialog:modal")].at(-1)
|
|
163
|
+
if (modal?.dispatchEvent(new Event("cancel", { cancelable: true }))) modal.close()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Bare keys (Rori.hover_keymap) act on the armed hovered window — even while a
|
|
167
|
+
// field elsewhere has focus — or, when no field has focus, on the focused
|
|
168
|
+
// window (vim-style normal mode). Held-down repeats never count.
|
|
169
|
+
#bareKey(event) {
|
|
170
|
+
if (!this.hoverBindings?.size || MODIFIERS.includes(event.key)) return false
|
|
171
|
+
|
|
172
|
+
const armed = this.armed?.isConnected ? this.armed : null
|
|
173
|
+
const binding = this.hoverBindings.get(this.#chord(event))
|
|
174
|
+
const blocked = event.repeat || event.isComposing || this.overview || document.querySelector("dialog:modal")
|
|
175
|
+
if (!binding || blocked) {
|
|
176
|
+
if (this.armed) this.#disarm()
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
if (!armed && typing(event.target)) return false
|
|
180
|
+
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
this.#perform(binding.action, { win: armed || undefined, workspace: this.#workspaceAt(binding.digit) })
|
|
183
|
+
|
|
184
|
+
if (armed) armed.isConnected && armed !== this.focused && armed.closest(".rori-workspace") === this.current ? this.#arm(armed) : this.#disarm()
|
|
185
|
+
// A field left behind on another workspace would take the next keys unseen.
|
|
186
|
+
const stayInField = armed && this.current?.contains(document.activeElement)
|
|
187
|
+
if (!stayInField && this.focused && !this.focused.contains(document.activeElement)) this.focused.focus({ preventScroll: true })
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// On an empty workspace there's nothing for keys to act on, so Space or Enter
|
|
192
|
+
// opens ⌘K — the one useful next step. Buttons and links keep their own.
|
|
193
|
+
#emptyDeskKey(event) {
|
|
194
|
+
if (!["Space", "Enter", "NumpadEnter"].includes(event.code) || event.repeat || event.defaultPrevented) return false
|
|
195
|
+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false
|
|
196
|
+
if (this.current?.querySelector(".rori-win") || this.overview || document.querySelector("dialog:modal")) return false
|
|
197
|
+
if (typing(event.target) || event.target.closest?.("button, a, summary")) return false
|
|
198
|
+
|
|
199
|
+
event.preventDefault()
|
|
200
|
+
this.dispatch("open-palette")
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#arm(win) {
|
|
205
|
+
if (win !== this.armed) {
|
|
206
|
+
this.armed?.classList.remove("is-armed")
|
|
207
|
+
win.classList.add("is-armed")
|
|
208
|
+
this.armed = win
|
|
209
|
+
}
|
|
210
|
+
this.restingAt = null
|
|
211
|
+
clearTimeout(this.armTimer)
|
|
212
|
+
this.armTimer = setTimeout(() => this.#disarm(), this.hoverTimeoutValue)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Re-arming needs fresh movement from where the pointer is now.
|
|
216
|
+
#disarm() {
|
|
217
|
+
clearTimeout(this.armTimer)
|
|
218
|
+
this.armed?.classList.remove("is-armed")
|
|
219
|
+
this.armed = null
|
|
220
|
+
this.restingAt = this.pointer
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Every desk action, whether from a key or a ⌘K command. `win` defaults to the
|
|
224
|
+
// focused window; `workspace` is a workspace name, "" meaning a new one.
|
|
225
|
+
#perform(action, { win, workspace } = {}) {
|
|
226
|
+
const target = () => (workspace ? this.#workspace(workspace) : null)
|
|
227
|
+
const actions = {
|
|
228
|
+
focus_left: () => this.#focusDirection("left"),
|
|
229
|
+
focus_right: () => this.#focusDirection("right"),
|
|
230
|
+
focus_up: () => this.#focusDirection("up"),
|
|
231
|
+
focus_down: () => this.#focusDirection("down"),
|
|
232
|
+
move_left: () => this.#move("left", win),
|
|
233
|
+
move_right: () => this.#move("right", win),
|
|
234
|
+
move_up: () => this.#move("up", win),
|
|
235
|
+
move_down: () => this.#move("down", win),
|
|
236
|
+
// Only to workspaces that exist: a number past the last isn't worth an empty
|
|
237
|
+
// one (⌥↓ and New workspace make those). Moving there does make one.
|
|
238
|
+
switch_to_workspace: () => target() && this.#switchTo(target()),
|
|
239
|
+
move_to_workspace: () => this.#moveToWorkspace(target(), win, { follow: true }),
|
|
240
|
+
new_workspace: () => this.#switchTo(this.#newWorkspace()),
|
|
241
|
+
consume_left: () => this.#consumeOrExpel(-1, win),
|
|
242
|
+
consume_right: () => this.#consumeOrExpel(1, win),
|
|
243
|
+
cycle_width: () => this.#cycleWidth(win),
|
|
244
|
+
full_width: () => this.#toggleFullWidth(win),
|
|
245
|
+
center_column: () => this.#center(win),
|
|
246
|
+
overview: () => this.toggleOverview(),
|
|
247
|
+
close_window: () => this.#remove(win || this.focused),
|
|
248
|
+
reopen_window: () => this.#reopen(),
|
|
249
|
+
toggle_terminal: () => this.dispatch("toggle-terminal"),
|
|
250
|
+
shortcuts: () => this.dispatch("open-shortcuts"),
|
|
251
|
+
}
|
|
252
|
+
actions[action]?.()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#bindingsFrom(keymap) {
|
|
256
|
+
const bindings = new Map()
|
|
257
|
+
for (const [action, chords] of Object.entries(keymap)) {
|
|
258
|
+
for (const chord of chords) {
|
|
259
|
+
const digits = chord.endsWith("Digit*") ? [1, 2, 3, 4, 5, 6, 7, 8, 9] : [null]
|
|
260
|
+
for (const digit of digits) {
|
|
261
|
+
const spelled = digit ? chord.replace(/\*$/, digit) : chord
|
|
262
|
+
bindings.set(this.#normalize(spelled.split("+")), { action, digit })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return bindings
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#chord(event) {
|
|
270
|
+
return this.#normalize([...MODIFIERS.filter((key) => event.getModifierState(key)), event.code])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#normalize(keys) {
|
|
274
|
+
const code = keys.at(-1)
|
|
275
|
+
return [...MODIFIERS.filter((key) => keys.includes(key)), code].join("+")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Digit bindings address the nth workspace; past the last one means a new one ("").
|
|
279
|
+
#workspaceAt(digit) {
|
|
280
|
+
return digit ? (this.#workspaces()[digit - 1]?.dataset.name ?? "") : undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Horizontal wheel / trackpad swipes scroll the strip; focus follows once it settles.
|
|
284
|
+
wheel(event) {
|
|
285
|
+
if (this.overview || event.ctrlKey) return
|
|
286
|
+
const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
|
287
|
+
const delta = horizontal ? event.deltaX : event.shiftKey ? event.deltaY : 0
|
|
288
|
+
if (!delta || (horizontal && this.#scrollsSideways(event.target))) return
|
|
289
|
+
event.preventDefault()
|
|
290
|
+
|
|
291
|
+
const ws = this.current
|
|
292
|
+
this.offsets.set(ws, (this.offsets.get(ws) || 0) + delta)
|
|
293
|
+
this.element.classList.add("is-scrolling")
|
|
294
|
+
this.#layoutStrip(ws, { reveal: false })
|
|
295
|
+
this.#renderMinimap()
|
|
296
|
+
|
|
297
|
+
clearTimeout(this.wheelTimer)
|
|
298
|
+
this.wheelTimer = setTimeout(() => {
|
|
299
|
+
this.element.classList.remove("is-scrolling")
|
|
300
|
+
const win = this.#mostVisible(ws)
|
|
301
|
+
if (win && win !== this.focused) this.focus(win)
|
|
302
|
+
}, 160)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
toggleOverview() {
|
|
306
|
+
this.overview = !this.overview
|
|
307
|
+
this.layout()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
save() {
|
|
311
|
+
const workspaces = this.#workspaces().map((ws) => {
|
|
312
|
+
const columns = this.#columns(ws)
|
|
313
|
+
const focused = this.#focusIn(ws)
|
|
314
|
+
const column = focused ? columns.indexOf(focused.closest(".rori-col")) : -1
|
|
315
|
+
return {
|
|
316
|
+
name: ws.dataset.name,
|
|
317
|
+
focus: focused ? [column, this.#windowsIn(columns[column]).indexOf(focused)] : null,
|
|
318
|
+
columns: columns.map((col) => ({
|
|
319
|
+
w: Number(col.dataset.w),
|
|
320
|
+
windows: this.#windowsIn(col).map((win) => ({ url: this.#src(win), mode: win.dataset.mode, size: win.dataset.size })),
|
|
321
|
+
})),
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ current: this.current?.dataset.name, workspaces }))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Window actions -------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
focus(win) {
|
|
330
|
+
if (win && win === this.armed) this.#disarm()
|
|
331
|
+
if (win) {
|
|
332
|
+
const ws = this.#wsOf(win)
|
|
333
|
+
if (ws) {
|
|
334
|
+
this.current = ws
|
|
335
|
+
this.lastFocus.set(ws, win)
|
|
336
|
+
this.lastInColumn.set(win.closest(".rori-col"), win)
|
|
337
|
+
}
|
|
338
|
+
if (this.#title(win).textContent) document.title = this.#title(win).textContent
|
|
339
|
+
}
|
|
340
|
+
this.focused = win || null
|
|
341
|
+
this.windowTargets.forEach((other) => other.classList.toggle("is-focused", other === win))
|
|
342
|
+
this.#syncUrl(win)
|
|
343
|
+
this.#prune()
|
|
344
|
+
this.layout()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Pointer or keyboard (Tab / focusin) entering a window makes it the focused one.
|
|
348
|
+
focusWindow(event) {
|
|
349
|
+
const win = this.#win(event)
|
|
350
|
+
// Focus on the <dialog> itself is programmatic: dialog.show() moves focus to
|
|
351
|
+
// the window it opens (e.g. every window a reload restores, sometimes after
|
|
352
|
+
// the restore has finished). Only focus inside the content is the user's.
|
|
353
|
+
if (event.type === "focusin" && event.target === win) return
|
|
354
|
+
if (this.overview) {
|
|
355
|
+
if (event.type !== "pointerdown") return
|
|
356
|
+
event.preventDefault()
|
|
357
|
+
this.overview = false
|
|
358
|
+
return this.focus(win)
|
|
359
|
+
}
|
|
360
|
+
if (win === this.focused) return
|
|
361
|
+
|
|
362
|
+
// Revealing the column now would slide it under the pointer mid-click and
|
|
363
|
+
// the click would land elsewhere; scroll once the button is released.
|
|
364
|
+
if (event.type === "pointerdown") this.#holdRevealUntilPointerUp()
|
|
365
|
+
this.focus(win)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Edited forms are skipped by broadcast reloads (see #busy).
|
|
369
|
+
markDirty(event) {
|
|
370
|
+
event.target.form?.toggleAttribute("data-rori-dirty", true)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
layout() {
|
|
374
|
+
const H = this.viewportTarget.clientHeight
|
|
375
|
+
const workspaces = this.#workspaces()
|
|
376
|
+
workspaces.forEach((ws) => this.#layoutStrip(ws))
|
|
377
|
+
|
|
378
|
+
if (this.overview) {
|
|
379
|
+
const V = this.viewportTarget.clientWidth
|
|
380
|
+
const widest = Math.max(V, ...workspaces.map((ws) => this.spans.get(ws).total))
|
|
381
|
+
const scale = Math.min(0.5, (V * 0.92) / widest, 0.92 / workspaces.length)
|
|
382
|
+
const x = (V - widest * scale) / 2
|
|
383
|
+
const y = (H - H * workspaces.length * scale) / 2
|
|
384
|
+
this.stackTarget.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
|
|
385
|
+
} else {
|
|
386
|
+
this.stackTarget.style.transform = `translateY(${-workspaces.indexOf(this.current) * H}px)`
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
this.element.classList.toggle("is-overview", this.overview)
|
|
390
|
+
this.viewportTarget.classList.toggle("is-empty", !this.current.querySelector(".rori-win"))
|
|
391
|
+
this.#renderWorkspaces()
|
|
392
|
+
this.#renderMinimap()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
frameLoaded(event) {
|
|
396
|
+
const win = this.#win(event)
|
|
397
|
+
if (event.target === this.#frame(win)) this.#applyMeta(win)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
frameMissing(event) {
|
|
401
|
+
const win = this.#win(event)
|
|
402
|
+
if (event.target !== this.#frame(win)) return
|
|
403
|
+
event.preventDefault()
|
|
404
|
+
|
|
405
|
+
const message = document.createElement("p")
|
|
406
|
+
message.className = "rori-win__error"
|
|
407
|
+
message.textContent = this.loadErrorValue
|
|
408
|
+
event.target.replaceChildren(message)
|
|
409
|
+
this.#title(win).textContent = event.detail.response.status
|
|
410
|
+
win.classList.remove("is-loading")
|
|
411
|
+
if (!win.dataset.placed) {
|
|
412
|
+
win.dataset.placed = "1"
|
|
413
|
+
this.#place(win, "tile", "md")
|
|
414
|
+
}
|
|
415
|
+
this.focus(win)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Esc on a modal window closes it for good.
|
|
419
|
+
cancel(event) {
|
|
420
|
+
event.preventDefault()
|
|
421
|
+
this.#remove(this.#win(event))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
close(event) {
|
|
425
|
+
this.#remove(this.#win(event))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
toggleFullWidth(event) {
|
|
429
|
+
if (event.target.closest("button")) return
|
|
430
|
+
this.focus(this.#win(event))
|
|
431
|
+
this.#toggleFullWidth()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
back(event) {
|
|
435
|
+
const win = this.#win(event)
|
|
436
|
+
const stack = this.histories.get(win) || []
|
|
437
|
+
if (stack.length < 2) return
|
|
438
|
+
stack.pop()
|
|
439
|
+
this.#frame(win).setAttribute("src", stack.pop())
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
detach(event) {
|
|
443
|
+
const url = this.#src(this.#win(event))
|
|
444
|
+
if (url) window.open(url, "_blank", "noopener")
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Placement ------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
#spawn(url, { reuse = false, into = null } = {}) {
|
|
450
|
+
const win = this.templateTarget.content.firstElementChild.cloneNode(true)
|
|
451
|
+
const frame = this.#frame(win)
|
|
452
|
+
frame.id = `win_${crypto.randomUUID().slice(0, 8)}`
|
|
453
|
+
frame.setAttribute("src", url)
|
|
454
|
+
win.dataset.reuse = reuse
|
|
455
|
+
win.classList.add("is-loading")
|
|
456
|
+
; (into || this.floatingTarget).append(win)
|
|
457
|
+
if (!into) setTimeout(() => this.#revealIfSlow(win), 600)
|
|
458
|
+
return win
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#applyMeta(win) {
|
|
462
|
+
const template = this.#frame(win).querySelector(":scope > template[data-window-meta]")
|
|
463
|
+
if (!template) return
|
|
464
|
+
|
|
465
|
+
const meta = JSON.parse(template.dataset.windowMeta)
|
|
466
|
+
const first = !win.dataset.placed
|
|
467
|
+
const wasModal = win.dataset.mode === "modal"
|
|
468
|
+
win.dataset.key = meta.key || ""
|
|
469
|
+
this.#title(win).textContent = meta.title
|
|
470
|
+
win.classList.remove("is-loading")
|
|
471
|
+
|
|
472
|
+
// Reuse: a freshly opened window, or a modal that finished (e.g. redirected to
|
|
473
|
+
// a show page), takes the place of the existing window with the same key.
|
|
474
|
+
const reusable = meta.key && meta.mode !== "modal" && (wasModal || (first && win.dataset.reuse === "true"))
|
|
475
|
+
const twin = reusable && this.windowTargets.find((other) =>
|
|
476
|
+
other !== win && other.dataset.key === meta.key && other.dataset.mode !== "modal")
|
|
477
|
+
win.dataset.reuse = false
|
|
478
|
+
if (twin) return this.#takeOver(win, twin)
|
|
479
|
+
|
|
480
|
+
if (first) {
|
|
481
|
+
win.dataset.placed = "1"
|
|
482
|
+
this.#place(win, meta.mode, meta.size, this.#hintedWorkspace(meta.workspace))
|
|
483
|
+
} else if (meta.mode !== win.dataset.mode) {
|
|
484
|
+
this.#place(win, meta.mode, meta.size, this.#wsOf(win) || this.current)
|
|
485
|
+
}
|
|
486
|
+
this.#pushHistory(win)
|
|
487
|
+
|
|
488
|
+
if (first || wasModal !== (meta.mode === "modal")) return this.focus(win)
|
|
489
|
+
if (win === this.focused) this.#syncUrl(win)
|
|
490
|
+
this.layout()
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#place(win, mode = "tile", size = "md", ws = this.current) {
|
|
494
|
+
win.dataset.mode = mode
|
|
495
|
+
win.dataset.size = size
|
|
496
|
+
|
|
497
|
+
if (mode === "modal") {
|
|
498
|
+
if (win.open && !win.matches(":modal")) win.close()
|
|
499
|
+
this.#moveTo(win, this.floatingTarget)
|
|
500
|
+
win.style.width = `${MODAL_WIDTHS[size] || MODAL_WIDTHS.sm}px`
|
|
501
|
+
if (!win.open) {
|
|
502
|
+
win.returnFocus = document.activeElement // see #remove
|
|
503
|
+
win.showModal()
|
|
504
|
+
}
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (win.matches(":modal")) win.close()
|
|
509
|
+
win.style.width = ""
|
|
510
|
+
const width = mode === "fullscreen" ? 1 : SIZES[size] || SIZES.md
|
|
511
|
+
const col = win.closest(".rori-col")
|
|
512
|
+
if (col) col.dataset.w = width
|
|
513
|
+
else this.#moveTo(win, this.#newColumnIn(ws, width))
|
|
514
|
+
this.#open(win)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// The loaded window takes the twin's slot and back-history, so to the user it
|
|
518
|
+
// looks like the twin navigated.
|
|
519
|
+
#takeOver(win, twin) {
|
|
520
|
+
if (win.matches(":modal")) win.close()
|
|
521
|
+
win.dataset.mode = twin.dataset.mode
|
|
522
|
+
win.dataset.size = twin.dataset.size
|
|
523
|
+
win.dataset.placed = "1"
|
|
524
|
+
this.histories.set(win, [...(this.histories.get(twin) || [])])
|
|
525
|
+
this.#moveTo(win, twin.parentElement, twin)
|
|
526
|
+
twin.remove()
|
|
527
|
+
this.#open(win)
|
|
528
|
+
this.#pushHistory(win)
|
|
529
|
+
this.focus(win)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#revealIfSlow(win) {
|
|
533
|
+
if (!win.isConnected || win.dataset.placed) return
|
|
534
|
+
win.dataset.placed = "1"
|
|
535
|
+
this.#place(win, "tile", "md")
|
|
536
|
+
this.focus(win)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Every close (×, keys, Esc on a modal, ⌘K) ends here. Unsaved edits ask
|
|
540
|
+
// first; tiled windows can be brought back with reopen_window.
|
|
541
|
+
#remove(win) {
|
|
542
|
+
if (!win) return
|
|
543
|
+
if (win.querySelector("form[data-rori-dirty]") && !window.confirm(this.discardPromptValue)) return
|
|
544
|
+
|
|
545
|
+
const col = win.closest(".rori-col")
|
|
546
|
+
const neighbour = col && (win.nextElementSibling || win.previousElementSibling ||
|
|
547
|
+
this.#remembered(col.nextElementSibling) || this.#remembered(col.previousElementSibling))
|
|
548
|
+
if (col && this.#src(win)) this.#rememberClosed(win, col)
|
|
549
|
+
// Removing a modal (unlike dialog.close()) doesn't hand focus back to what
|
|
550
|
+
// opened it — e.g. the terminal prompt a `new user` came from.
|
|
551
|
+
const returnFocus = win.matches(":modal") ? win.returnFocus : null
|
|
552
|
+
|
|
553
|
+
win.remove()
|
|
554
|
+
if (col && !col.querySelector(".rori-win")) col.remove()
|
|
555
|
+
if (win !== this.focused && this.focused?.isConnected) this.layout()
|
|
556
|
+
else this.focus(neighbour || this.#focusIn(this.current) || this.current.querySelector(".rori-win"))
|
|
557
|
+
if (returnFocus?.isConnected && returnFocus.checkVisibility()) returnFocus.focus({ preventScroll: true })
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#rememberClosed(win, col) {
|
|
561
|
+
const ws = this.#wsOf(col)
|
|
562
|
+
this.closed.push({
|
|
563
|
+
url: this.#src(win), mode: win.dataset.mode, size: win.dataset.size, w: col.dataset.w,
|
|
564
|
+
workspace: ws.dataset.name, index: this.#columns(ws).indexOf(col),
|
|
565
|
+
})
|
|
566
|
+
if (this.closed.length > CLOSED_LIMIT) this.closed.shift()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Brings the last closed window back as a column where it was.
|
|
570
|
+
#reopen() {
|
|
571
|
+
const entry = this.closed.pop()
|
|
572
|
+
if (!entry) return
|
|
573
|
+
|
|
574
|
+
const ws = this.#workspace(entry.workspace) || this.current
|
|
575
|
+
const col = this.#createColumn(entry.w)
|
|
576
|
+
const at = this.#columns(ws)[entry.index]
|
|
577
|
+
at ? at.before(col) : this.#strip(ws).append(col)
|
|
578
|
+
const win = this.#spawn(entry.url, { into: col })
|
|
579
|
+
Object.assign(win.dataset, { placed: "1", mode: entry.mode, size: entry.size })
|
|
580
|
+
this.#open(win)
|
|
581
|
+
this.focus(win)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#moveTo(win, parent, before = null) {
|
|
585
|
+
// Re-inserting a modal dialog would drop it from the top layer.
|
|
586
|
+
if (win.parentElement === parent && !before) return
|
|
587
|
+
const col = win.closest(".rori-col")
|
|
588
|
+
parent.insertBefore(win, before)
|
|
589
|
+
if (col && col !== parent && !col.querySelector(".rori-win")) col.remove()
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#open(win) {
|
|
593
|
+
if (!win.open && win.isConnected) win.dataset.mode === "modal" ? win.showModal() : win.show()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
#restore() {
|
|
597
|
+
const saved = this.#load()
|
|
598
|
+
const here = location.pathname + location.search
|
|
599
|
+
const main = this.windowTargets.find((win) => this.#frame(win).id === "win_main")
|
|
600
|
+
let mainPlaced = false
|
|
601
|
+
|
|
602
|
+
for (const { name, columns = [], focus } of saved.workspaces || []) {
|
|
603
|
+
const ws = this.#createWorkspace(name)
|
|
604
|
+
columns.forEach(({ w, windows = [] }, c) => {
|
|
605
|
+
const col = this.#createColumn(w)
|
|
606
|
+
this.#strip(ws).append(col)
|
|
607
|
+
windows.filter(({ url }) => url).forEach(({ url, mode, size }, r) => {
|
|
608
|
+
const reuseMain = main && !mainPlaced && url === here
|
|
609
|
+
const win = reuseMain ? main : this.#spawn(url, { into: col })
|
|
610
|
+
if (reuseMain) { col.append(main); mainPlaced = true }
|
|
611
|
+
Object.assign(win.dataset, { placed: "1", mode: mode || "tile", size: size || "md" })
|
|
612
|
+
this.#open(win)
|
|
613
|
+
if (focus?.[0] === c && focus?.[1] === r) this.lastFocus.set(ws, win)
|
|
614
|
+
})
|
|
615
|
+
if (!col.children.length) col.remove()
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
this.current = this.#workspace(saved.current) || this.#workspaces()[0] || this.#createWorkspace("1")
|
|
620
|
+
if (!main) return this.#switchTo(this.current)
|
|
621
|
+
this.#applyMeta(main)
|
|
622
|
+
this.focus(main)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Navigation -----------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
#focusDirection(direction) {
|
|
628
|
+
const win = this.#focusIn(this.current)
|
|
629
|
+
if (!win) {
|
|
630
|
+
if (direction === "up" || direction === "down") this.#stepWorkspace(direction)
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
const col = win.closest(".rori-col")
|
|
634
|
+
if (direction === "left") return this.focus(this.#remembered(col.previousElementSibling) || win)
|
|
635
|
+
if (direction === "right") return this.focus(this.#remembered(col.nextElementSibling) || win)
|
|
636
|
+
|
|
637
|
+
const next = direction === "up" ? win.previousElementSibling : win.nextElementSibling
|
|
638
|
+
next ? this.focus(next) : this.#stepWorkspace(direction)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Past the last workspace there is always an empty one to go to.
|
|
642
|
+
#stepWorkspace(direction) {
|
|
643
|
+
const workspaces = this.#workspaces()
|
|
644
|
+
const i = workspaces.indexOf(this.current)
|
|
645
|
+
if (direction === "up" && i > 0) this.#switchTo(workspaces[i - 1])
|
|
646
|
+
if (direction === "down") {
|
|
647
|
+
const below = workspaces[i + 1] || (this.current.querySelector(".rori-win") && this.#newWorkspace())
|
|
648
|
+
if (below) this.#switchTo(below)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Window actions below act on `win` — the focused window by default, or the
|
|
653
|
+
// hovered one for hover keys — and only move focus when it was the focused one.
|
|
654
|
+
|
|
655
|
+
#move(direction, win = this.#focusIn(this.current)) {
|
|
656
|
+
if (!win) return
|
|
657
|
+
const col = win.closest(".rori-col")
|
|
658
|
+
|
|
659
|
+
const workspaces = this.#workspaces()
|
|
660
|
+
const i = workspaces.indexOf(this.current)
|
|
661
|
+
if (direction === "up") return i > 0 && this.#moveToWorkspace(workspaces[i - 1], win)
|
|
662
|
+
if (direction === "down") return this.#moveToWorkspace(workspaces[i + 1], win)
|
|
663
|
+
|
|
664
|
+
if (direction === "left") col.previousElementSibling?.before(col)
|
|
665
|
+
else col.nextElementSibling?.after(col)
|
|
666
|
+
this.#settle(win)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Moves the window's column to `target` — a new workspace when missing. Focus
|
|
670
|
+
// follows if the column holds the focused window; with `follow` (the move-to-
|
|
671
|
+
// workspace action, e.g. a hovered window's ⇧1–9) it always goes to `win`, so
|
|
672
|
+
// the moved window never just vanishes from view.
|
|
673
|
+
#moveToWorkspace(target, win = this.#focusIn(this.current), { follow = false } = {}) {
|
|
674
|
+
if (!win || target === this.#wsOf(win)) return follow && this.focus(win)
|
|
675
|
+
|
|
676
|
+
target ||= this.#newWorkspace()
|
|
677
|
+
const col = win.closest(".rori-col")
|
|
678
|
+
const anchor = this.#focusIn(target)?.closest(".rori-col")
|
|
679
|
+
anchor ? anchor.after(col) : this.#strip(target).append(col)
|
|
680
|
+
this.#windowsIn(col).forEach((other) => this.#open(other))
|
|
681
|
+
if (follow) this.focus(win)
|
|
682
|
+
else col.contains(this.focused) ? this.focus(this.focused) : this.#settle(win)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// niri's consume-or-expel: a window alone in its column joins the neighbouring
|
|
686
|
+
// column; a stacked window leaves into a new column on that side.
|
|
687
|
+
#consumeOrExpel(side, win = this.#focusIn(this.current)) {
|
|
688
|
+
if (!win) return
|
|
689
|
+
const col = win.closest(".rori-col")
|
|
690
|
+
|
|
691
|
+
if (this.#windowsIn(col).length > 1) {
|
|
692
|
+
const fresh = this.#createColumn(col.dataset.w)
|
|
693
|
+
side < 0 ? col.before(fresh) : col.after(fresh)
|
|
694
|
+
fresh.append(win)
|
|
695
|
+
} else {
|
|
696
|
+
const target = side < 0 ? col.previousElementSibling : col.nextElementSibling
|
|
697
|
+
if (!target) return
|
|
698
|
+
target.append(win)
|
|
699
|
+
col.remove()
|
|
700
|
+
}
|
|
701
|
+
this.#open(win)
|
|
702
|
+
this.#settle(win)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
#settle(win) {
|
|
706
|
+
win === this.focused ? this.focus(win) : this.layout()
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
#cycleWidth(win = this.#focusIn(this.current)) {
|
|
710
|
+
const col = win?.closest(".rori-col")
|
|
711
|
+
if (!col) return
|
|
712
|
+
col.dataset.w = WIDTHS.find((width) => width > Number(col.dataset.w) + 0.01) || WIDTHS[0]
|
|
713
|
+
delete col.dataset.previous
|
|
714
|
+
this.layout()
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
#toggleFullWidth(win = this.#focusIn(this.current)) {
|
|
718
|
+
const col = win?.closest(".rori-col")
|
|
719
|
+
if (!col) return
|
|
720
|
+
if (Number(col.dataset.w) === 1) {
|
|
721
|
+
col.dataset.w = col.dataset.previous || SIZES.md
|
|
722
|
+
delete col.dataset.previous
|
|
723
|
+
} else {
|
|
724
|
+
col.dataset.previous = col.dataset.w
|
|
725
|
+
col.dataset.w = 1
|
|
726
|
+
}
|
|
727
|
+
this.layout()
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
#center(win = this.#focusIn(this.current)) {
|
|
731
|
+
const ws = this.current
|
|
732
|
+
const col = win?.closest(".rori-col")
|
|
733
|
+
if (!col || this.#wsOf(col) !== ws) return
|
|
734
|
+
const [left, right] = this.spans.get(ws).spans[this.#columns(ws).indexOf(col)]
|
|
735
|
+
this.offsets.set(ws, left - (this.viewportTarget.clientWidth - (right - left)) / 2)
|
|
736
|
+
this.#layoutStrip(ws, { reveal: false })
|
|
737
|
+
this.#renderMinimap()
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
#layoutStrip(ws, { reveal = true } = {}) {
|
|
741
|
+
const V = this.viewportTarget.clientWidth
|
|
742
|
+
const columns = this.#columns(ws)
|
|
743
|
+
let x = GAP
|
|
744
|
+
const spans = columns.map((col) => {
|
|
745
|
+
const width = Math.round(Number(col.dataset.w) * (V - GAP) - GAP)
|
|
746
|
+
col.style.width = `${width}px`
|
|
747
|
+
const span = [x, x + width]
|
|
748
|
+
x += width + GAP
|
|
749
|
+
return span
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
let offset = this.offsets.get(ws) || 0
|
|
753
|
+
const focused = columns.indexOf(this.#focusIn(ws)?.closest(".rori-col"))
|
|
754
|
+
if (reveal && !this.holdingReveal && focused >= 0) {
|
|
755
|
+
const [left, right] = spans[focused]
|
|
756
|
+
if (right + GAP > offset + V) offset = right + GAP - V
|
|
757
|
+
if (left - GAP < offset) offset = left - GAP
|
|
758
|
+
}
|
|
759
|
+
offset = clamp(offset, 0, Math.max(0, x - V))
|
|
760
|
+
|
|
761
|
+
this.offsets.set(ws, offset)
|
|
762
|
+
this.spans.set(ws, { spans, total: x })
|
|
763
|
+
this.#strip(ws).style.transform = this.overview ? "none" : `translateX(${-offset}px)`
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#reloadSoon() {
|
|
767
|
+
clearTimeout(this.reloadTimer)
|
|
768
|
+
this.reloadTimer = setTimeout(() => {
|
|
769
|
+
const windows = this.pendingReloads.size ? [...this.pendingReloads] : this.windowTargets
|
|
770
|
+
this.pendingReloads.clear()
|
|
771
|
+
windows.filter((win) => win.isConnected && !this.#busy(win)).forEach((win) => this.#frame(win).reload())
|
|
772
|
+
}, 100)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
#holdRevealUntilPointerUp() {
|
|
776
|
+
this.holdingReveal = true
|
|
777
|
+
const release = () => {
|
|
778
|
+
removeEventListener("pointerup", release)
|
|
779
|
+
removeEventListener("pointercancel", release)
|
|
780
|
+
this.holdingReveal = false
|
|
781
|
+
this.layout()
|
|
782
|
+
}
|
|
783
|
+
addEventListener("pointerup", release)
|
|
784
|
+
addEventListener("pointercancel", release)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Don't morph a form out from under the user: modals, windows being typed in
|
|
788
|
+
// and windows with unsaved edits (even when you've moved on) are skipped.
|
|
789
|
+
#busy(win) {
|
|
790
|
+
return win.dataset.mode === "modal" ||
|
|
791
|
+
Boolean(win.querySelector("form[data-rori-dirty]")) ||
|
|
792
|
+
Boolean(win.contains(document.activeElement) && document.activeElement.closest("form"))
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
#pushHistory(win) {
|
|
796
|
+
const url = this.#src(win)
|
|
797
|
+
const stack = this.histories.get(win) || []
|
|
798
|
+
if (url && stack.at(-1) !== url) stack.push(url)
|
|
799
|
+
this.histories.set(win, stack)
|
|
800
|
+
this.#part(win, "back").disabled = stack.length < 2
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
#syncUrl(win) {
|
|
804
|
+
const url = (win && this.#src(win)) || this.rootUrlValue
|
|
805
|
+
if (url !== location.pathname + location.search) history.replaceState(history.state, "", url)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Workspaces -----------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
#switchTo(ws) {
|
|
811
|
+
this.current = ws
|
|
812
|
+
this.focus(this.#focusIn(ws) || ws.querySelector(".rori-win"))
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
#hintedWorkspace(name) {
|
|
816
|
+
if (!name) return this.current
|
|
817
|
+
return name === "new" ? this.#newWorkspace() : this.#workspace(name) || this.#createWorkspace(name)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#createWorkspace(name) {
|
|
821
|
+
const ws = document.createElement("section")
|
|
822
|
+
ws.className = "rori-workspace"
|
|
823
|
+
ws.dataset.name = name
|
|
824
|
+
const strip = document.createElement("div")
|
|
825
|
+
strip.className = "rori-strip"
|
|
826
|
+
ws.append(strip)
|
|
827
|
+
this.stackTarget.append(ws)
|
|
828
|
+
return ws
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
#newWorkspace() {
|
|
832
|
+
const numbers = this.#workspaces().map((ws) => ws.dataset.name).filter((name) => /^\d+$/.test(name)).map(Number)
|
|
833
|
+
return this.#createWorkspace(String(Math.max(0, ...numbers) + 1))
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Empty workspaces disappear once you leave them.
|
|
837
|
+
#prune() {
|
|
838
|
+
this.#workspaces().forEach((ws) => {
|
|
839
|
+
if (ws !== this.current && !ws.querySelector(".rori-win")) ws.remove()
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Chrome ---------------------------------------------------------------------
|
|
844
|
+
|
|
845
|
+
#renderWorkspaces() {
|
|
846
|
+
this.workspacesTarget.replaceChildren(...this.#workspaces().map((ws, i) => {
|
|
847
|
+
const button = document.createElement("button")
|
|
848
|
+
button.type = "button"
|
|
849
|
+
button.className = "rori-workspace-button"
|
|
850
|
+
button.textContent = this.#workspaceLabel(ws, i)
|
|
851
|
+
if (ws === this.current) button.setAttribute("aria-current", "true")
|
|
852
|
+
button.addEventListener("click", () => {
|
|
853
|
+
this.overview = false
|
|
854
|
+
this.#switchTo(ws)
|
|
855
|
+
})
|
|
856
|
+
return button
|
|
857
|
+
}))
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
#renderMinimap() {
|
|
861
|
+
const ws = this.current
|
|
862
|
+
const V = this.viewportTarget.clientWidth
|
|
863
|
+
const offset = this.offsets.get(ws) || 0
|
|
864
|
+
const { spans = [] } = this.spans.get(ws) || {}
|
|
865
|
+
|
|
866
|
+
this.minimapTarget.replaceChildren(...this.#columns(ws).map((col, i) => {
|
|
867
|
+
const [left, right] = spans[i] || [0, 0]
|
|
868
|
+
const button = document.createElement("button")
|
|
869
|
+
button.type = "button"
|
|
870
|
+
button.className = "rori-minimap__col"
|
|
871
|
+
button.style.width = `${Number(col.dataset.w) * 72}px`
|
|
872
|
+
button.title = this.#windowsIn(col).map((win) => this.#title(win).textContent).join(" + ")
|
|
873
|
+
button.classList.toggle("is-visible", left >= offset - 1 && right <= offset + V + 1)
|
|
874
|
+
this.#windowsIn(col).forEach((win) => {
|
|
875
|
+
const cell = document.createElement("span")
|
|
876
|
+
cell.classList.toggle("is-focused", win === this.focused)
|
|
877
|
+
button.append(cell)
|
|
878
|
+
})
|
|
879
|
+
button.addEventListener("click", () => this.focus(this.#remembered(col)))
|
|
880
|
+
return button
|
|
881
|
+
}))
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Lookups --------------------------------------------------------------------
|
|
885
|
+
|
|
886
|
+
#workspaces() {
|
|
887
|
+
return [...this.stackTarget.children]
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Position, plus the name when a page gave the workspace one ("2 projects").
|
|
891
|
+
#workspaceLabel(ws, i = this.#workspaces().indexOf(ws)) {
|
|
892
|
+
const name = ws.dataset.name
|
|
893
|
+
return /^\d+$/.test(name) ? String(i + 1) : `${i + 1} ${name}`
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
#workspace(name) {
|
|
897
|
+
return this.#workspaces().find((ws) => ws.dataset.name === name)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
#strip(ws) {
|
|
901
|
+
return ws.firstElementChild
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
#columns(ws) {
|
|
905
|
+
return [...this.#strip(ws).children]
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
#createColumn(width) {
|
|
909
|
+
const col = document.createElement("div")
|
|
910
|
+
col.className = "rori-col"
|
|
911
|
+
col.dataset.w = width
|
|
912
|
+
return col
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
#newColumnIn(ws, width) {
|
|
916
|
+
const col = this.#createColumn(width)
|
|
917
|
+
const anchor = this.#focusIn(ws)?.closest(".rori-col")
|
|
918
|
+
anchor ? anchor.after(col) : this.#strip(ws).append(col)
|
|
919
|
+
return col
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
#windowsIn(col) {
|
|
923
|
+
return col ? [...col.querySelectorAll(":scope > .rori-win")] : []
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
#wsOf(win) {
|
|
927
|
+
return win.closest(".rori-workspace")
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
#focusIn(ws) {
|
|
931
|
+
const win = this.lastFocus.get(ws)
|
|
932
|
+
return win?.isConnected && this.#wsOf(win) === ws ? win : null
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
#remembered(col) {
|
|
936
|
+
if (!col) return null
|
|
937
|
+
const win = this.lastInColumn.get(col)
|
|
938
|
+
return win?.parentElement === col ? win : col.querySelector(".rori-win")
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
#mostVisible(ws) {
|
|
942
|
+
const V = this.viewportTarget.clientWidth
|
|
943
|
+
const offset = this.offsets.get(ws) || 0
|
|
944
|
+
const { spans } = this.spans.get(ws)
|
|
945
|
+
let best = null
|
|
946
|
+
let bestSeen = 0
|
|
947
|
+
this.#columns(ws).forEach((col, i) => {
|
|
948
|
+
const [left, right] = spans[i]
|
|
949
|
+
const seen = Math.min(right, offset + V) - Math.max(left, offset)
|
|
950
|
+
if (seen > bestSeen) [best, bestSeen] = [col, seen]
|
|
951
|
+
})
|
|
952
|
+
return this.#remembered(best)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Content under the pointer that scrolls sideways itself (a wide table) keeps
|
|
956
|
+
// the swipe. Only real scrollers count: clipped boxes like .rori-workspace are
|
|
957
|
+
// wider inside than out but don't scroll, and treating them as scrollers
|
|
958
|
+
// made swipes over the bare desk do nothing.
|
|
959
|
+
#scrollsSideways(target) {
|
|
960
|
+
for (let el = target; el && el !== this.viewportTarget; el = el.parentElement) {
|
|
961
|
+
if (el.scrollWidth > el.clientWidth && ["auto", "scroll"].includes(getComputedStyle(el).overflowX)) return true
|
|
962
|
+
}
|
|
963
|
+
return false
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
#win(event) {
|
|
967
|
+
return event.target.closest(".rori-win")
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
#frame(win) {
|
|
971
|
+
return win.querySelector(":scope > .rori-win__body > turbo-frame")
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
#part(win, name) {
|
|
975
|
+
return win.querySelector(`:scope > .rori-win__bar [data-window-part="${name}"]`)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
#title(win) {
|
|
979
|
+
return this.#part(win, "title")
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
#src(win) {
|
|
983
|
+
const src = this.#frame(win)?.getAttribute("src")
|
|
984
|
+
if (!src) return null
|
|
985
|
+
const url = new URL(src, location.href)
|
|
986
|
+
return url.pathname + url.search
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
#load() {
|
|
990
|
+
try {
|
|
991
|
+
return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
|
|
992
|
+
} catch {
|
|
993
|
+
return {}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|