@followbin/dsh-selection 0.2.0 → 0.2.1
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/client.js +140 -2
- package/index.js +5 -0
- package/package.json +1 -1
- package/src/host/service.mjs +3 -0
- package/src/worker/controller.mjs +10 -2
- package/src/worker/main.mjs +3 -0
- package/src/worker/overlay.mjs +26 -0
- package/ui/toolbar.css +9 -8
- package/ui/toolbar.js +57 -38
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Now select some text in any app. Settings are under **Selection assistant** in D
|
|
|
27
27
|
|
|
28
28
|
To install the newest code from GitHub instead, enter `https://github.com/followbin/dsh-selection` in step 2.
|
|
29
29
|
|
|
30
|
-
**Updating**: when a new version is published, the plugin's page
|
|
30
|
+
**Updating**: when a new version is published, **General → Version** on the plugin's settings page offers **Update to x.y.z**. It is the same update as **Versions → Update** on the plugin's page under **Plugins**: DSH stops the old version, installs the new one and puts the old one back if that fails. A copy installed from GitHub or a local folder cannot update this way: uninstall it and install it again. Your settings and usage are kept, because they are stored in `~/.dsh/selection`, outside the plugin.
|
|
31
31
|
|
|
32
32
|
## Features
|
|
33
33
|
|
package/README.zh-CN.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
想装 GitHub 上的最新代码,第 2 步改填 `https://github.com/followbin/dsh-selection`。
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
**更新**:发布新版本后,插件设置页「常规 → 版本」里会出现「更新到 x.y.z」,点一下就更新。这和在「插件」里打开它、点「版本 → 更新」是同一个操作:DSH 先停掉旧版、装上新版,失败会还原。从 GitHub 或本地目录装的不能这样更新,要先卸载再重新安装。设置和用量存在 `~/.dsh/selection`,不在插件目录里,卸载不会丢。
|
|
31
31
|
|
|
32
32
|
## 功能
|
|
33
33
|
|
package/client.js
CHANGED
|
@@ -68,6 +68,21 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
'toolbar.style.full': '常规',
|
|
69
69
|
'toolbar.style.mini': '迷你',
|
|
70
70
|
'toolbar.style.hint': '迷你模式只显示一个图标,鼠标停在上面会展开全部技能。在工具栏的菜单里也能切换。',
|
|
71
|
+
'version.label': '版本',
|
|
72
|
+
'version.current': '当前 {version}',
|
|
73
|
+
'version.latest': '当前 {version},已是最新版本',
|
|
74
|
+
'version.available': '当前 {installed},可以更新到 {version}',
|
|
75
|
+
'version.needsDsh': '当前 {installed};新版本 {version} 需要先更新 DSH',
|
|
76
|
+
'version.notUpdatable': '当前 {version}。从 GitHub 或本地目录安装的版本不能在这里更新,可以到「插件」页卸载后重新安装',
|
|
77
|
+
'version.checkFailed': '当前 {version};检查更新失败:{reason}',
|
|
78
|
+
'version.updating': '正在更新到 {version},工具栏会短暂停止',
|
|
79
|
+
'version.updated': '已更新到 {version},新版本已经在运行',
|
|
80
|
+
'version.restart': '已安装 {version},重启 DSH 后生效',
|
|
81
|
+
'version.failed': '更新到 {version} 失败:{reason}。可以到「插件」页查看详情',
|
|
82
|
+
'version.check': '检查更新',
|
|
83
|
+
'version.checking': '正在检查…',
|
|
84
|
+
'version.update': '更新到 {version}',
|
|
85
|
+
'version.updatingShort': '正在更新…',
|
|
71
86
|
'language.title': '语言',
|
|
72
87
|
'language.native': '我的母语',
|
|
73
88
|
'language.translate': '翻译成',
|
|
@@ -195,6 +210,21 @@ window.__ModuleLoader__.load({
|
|
|
195
210
|
'toolbar.style.full': 'Full',
|
|
196
211
|
'toolbar.style.mini': 'Mini',
|
|
197
212
|
'toolbar.style.hint': 'The mini toolbar is a single icon; rest the pointer on it to see every skill. You can also switch from the toolbar’s menu.',
|
|
213
|
+
'version.label': 'Version',
|
|
214
|
+
'version.current': 'Version {version}',
|
|
215
|
+
'version.latest': 'Version {version}, the latest',
|
|
216
|
+
'version.available': 'Version {installed}; {version} is available',
|
|
217
|
+
'version.needsDsh': 'Version {installed}; {version} needs a newer DSH',
|
|
218
|
+
'version.notUpdatable': 'Version {version}. A copy installed from GitHub or a local folder cannot be updated here; uninstall it on the Plugins page and install it again',
|
|
219
|
+
'version.checkFailed': 'Version {version}; checking for updates failed: {reason}',
|
|
220
|
+
'version.updating': 'Updating to {version}; the toolbar stops for a moment',
|
|
221
|
+
'version.updated': 'Updated to {version}; the new version is running',
|
|
222
|
+
'version.restart': '{version} is installed and takes effect after DSH restarts',
|
|
223
|
+
'version.failed': 'Updating to {version} failed: {reason}. The Plugins page has the details',
|
|
224
|
+
'version.check': 'Check for updates',
|
|
225
|
+
'version.checking': 'Checking…',
|
|
226
|
+
'version.update': 'Update to {version}',
|
|
227
|
+
'version.updatingShort': 'Updating…',
|
|
198
228
|
'language.title': 'Language',
|
|
199
229
|
'language.native': 'My language',
|
|
200
230
|
'language.translate': 'Translate into',
|
|
@@ -717,6 +747,104 @@ window.__ModuleLoader__.load({
|
|
|
717
747
|
|
|
718
748
|
// ---------- Page ----------
|
|
719
749
|
|
|
750
|
+
// Updates go through DSH's plugin manager, reached the way DSH's own Plugins page reaches it: it stops
|
|
751
|
+
// this version, installs the new one and puts the old one back if that fails. Null where this DSH
|
|
752
|
+
// does not let a page reach it.
|
|
753
|
+
const PACKAGE = '@followbin/dsh-selection'
|
|
754
|
+
const updates = (() => {
|
|
755
|
+
let manager = null
|
|
756
|
+
let state = { phase: 'none' }
|
|
757
|
+
const listeners = new Set()
|
|
758
|
+
const set = (next) => {
|
|
759
|
+
state = next
|
|
760
|
+
for (const listener of listeners) listener(state)
|
|
761
|
+
}
|
|
762
|
+
const reasonOf = (value) => String(value?.message ?? value ?? 'unknown')
|
|
763
|
+
return {
|
|
764
|
+
get: () => state,
|
|
765
|
+
subscribe(listener) {
|
|
766
|
+
listeners.add(listener)
|
|
767
|
+
return () => listeners.delete(listener)
|
|
768
|
+
},
|
|
769
|
+
attach(next) {
|
|
770
|
+
manager = next
|
|
771
|
+
if (state.phase !== 'updating') set({ phase: next ? 'idle' : 'none' })
|
|
772
|
+
},
|
|
773
|
+
async check() {
|
|
774
|
+
if (!manager || state.phase === 'checking' || state.phase === 'updating') return
|
|
775
|
+
set({ phase: 'checking' })
|
|
776
|
+
let list
|
|
777
|
+
try {
|
|
778
|
+
const answer = await manager.listVersions(PACKAGE, {})
|
|
779
|
+
list = answer.ok ? answer.value : { status: 'refused', problem: 'unknown', reason: reasonOf(answer.error) }
|
|
780
|
+
} catch (error) {
|
|
781
|
+
list = { status: 'refused', problem: 'unknown', reason: reasonOf(error) }
|
|
782
|
+
}
|
|
783
|
+
if (list.status === 'listed') {
|
|
784
|
+
// Newest first; a prerelease, a deprecated version or one without a bundle is never offered.
|
|
785
|
+
const usable = list.versions.filter((entry) => entry.bundle && !entry.prerelease && !entry.deprecated)
|
|
786
|
+
const next = usable.find((entry) => !entry.incompatible)
|
|
787
|
+
const blocked = usable.find((entry) => entry.incompatible)
|
|
788
|
+
if (next) set({ phase: 'available', installed: list.installed, version: next.version, registry: list.registry })
|
|
789
|
+
else if (blocked) set({ phase: 'needs-dsh', installed: list.installed, version: blocked.version })
|
|
790
|
+
else set({ phase: 'latest', installed: list.installed })
|
|
791
|
+
} else {
|
|
792
|
+
set(list.problem === 'not-updatable' ? { phase: 'not-updatable' } : { phase: 'check-failed', reason: list.reason || list.problem })
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
async update() {
|
|
796
|
+
if (!manager || state.phase !== 'available') return
|
|
797
|
+
const { version, registry } = state
|
|
798
|
+
set({ phase: 'updating', version })
|
|
799
|
+
const requestId = globalThis.crypto?.randomUUID?.() ?? `dss-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
800
|
+
let answer
|
|
801
|
+
try {
|
|
802
|
+
answer = await manager.updateBundle(PACKAGE, version, { registry, requestId })
|
|
803
|
+
} catch (error) {
|
|
804
|
+
answer = { ok: false, error }
|
|
805
|
+
}
|
|
806
|
+
// Once applied, DSH replaces this page with the new version's.
|
|
807
|
+
const result = answer.ok ? answer.value.result : null
|
|
808
|
+
if (result?.application === 'applied') set({ phase: 'updated', version })
|
|
809
|
+
else if (result?.application === 'restart-required') set({ phase: 'restart', version })
|
|
810
|
+
else set({ phase: 'failed', version, reason: answer.ok ? (result?.error?.code ?? result?.application ?? 'unknown') : reasonOf(answer.error) })
|
|
811
|
+
},
|
|
812
|
+
}
|
|
813
|
+
})()
|
|
814
|
+
|
|
815
|
+
function VersionRow({ t, version }) {
|
|
816
|
+
const [state, setState] = useState(updates.get())
|
|
817
|
+
useEffect(() => updates.subscribe(setState), [])
|
|
818
|
+
// Checks once when the page opens, or when DSH's plugin manager arrives after it.
|
|
819
|
+
useEffect(() => {
|
|
820
|
+
if (state.phase === 'idle') void updates.check()
|
|
821
|
+
}, [state.phase])
|
|
822
|
+
const installed = state.installed || version
|
|
823
|
+
const hint = {
|
|
824
|
+
none: fill(t('version.current'), { version }),
|
|
825
|
+
idle: fill(t('version.current'), { version }),
|
|
826
|
+
checking: fill(t('version.current'), { version }),
|
|
827
|
+
latest: fill(t('version.latest'), { version: installed }),
|
|
828
|
+
available: fill(t('version.available'), { installed, version: state.version }),
|
|
829
|
+
'needs-dsh': fill(t('version.needsDsh'), { installed, version: state.version }),
|
|
830
|
+
'not-updatable': fill(t('version.notUpdatable'), { version }),
|
|
831
|
+
'check-failed': fill(t('version.checkFailed'), { version, reason: state.reason }),
|
|
832
|
+
updating: fill(t('version.updating'), { version: state.version }),
|
|
833
|
+
updated: fill(t('version.updated'), { version: state.version }),
|
|
834
|
+
restart: fill(t('version.restart'), { version: state.version }),
|
|
835
|
+
failed: fill(t('version.failed'), { version: state.version, reason: state.reason }),
|
|
836
|
+
}[state.phase]
|
|
837
|
+
let action = null
|
|
838
|
+
if (state.phase === 'available') {
|
|
839
|
+
action = h('button', { type: 'button', className: 'dss-button primary', onClick: () => void updates.update() }, fill(t('version.update'), { version: state.version }))
|
|
840
|
+
} else if (state.phase === 'checking' || state.phase === 'updating') {
|
|
841
|
+
action = h('button', { type: 'button', className: 'dss-button', disabled: true }, t(state.phase === 'checking' ? 'version.checking' : 'version.updatingShort'))
|
|
842
|
+
} else if (['latest', 'needs-dsh', 'check-failed', 'failed'].includes(state.phase)) {
|
|
843
|
+
action = h('button', { type: 'button', className: 'dss-button', onClick: () => void updates.check() }, t('version.check'))
|
|
844
|
+
}
|
|
845
|
+
return h(Row, { label: t('version.label'), hint }, action)
|
|
846
|
+
}
|
|
847
|
+
|
|
720
848
|
function SettingsPage(props) {
|
|
721
849
|
const t = typeof props.t === 'function' ? props.t : (key) => zh[key] || key
|
|
722
850
|
const [state, setState] = useState(null)
|
|
@@ -875,7 +1003,8 @@ window.__ModuleLoader__.load({
|
|
|
875
1003
|
|
|
876
1004
|
panel('general',
|
|
877
1005
|
h('div', { className: 'dss-card', style: { marginTop: 20 } },
|
|
878
|
-
h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) }))
|
|
1006
|
+
h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) })),
|
|
1007
|
+
h(VersionRow, { t, version: state.version })),
|
|
879
1008
|
h(Section, { title: t('toolbar.title') },
|
|
880
1009
|
h('div', { className: 'dss-card' },
|
|
881
1010
|
h(Row, { label: t('toolbar.style'), hint: t('toolbar.style.hint') },
|
|
@@ -1027,7 +1156,7 @@ window.__ModuleLoader__.load({
|
|
|
1027
1156
|
return h(PageBoundary, props, h(SettingsPage, props))
|
|
1028
1157
|
}
|
|
1029
1158
|
|
|
1030
|
-
//
|
|
1159
|
+
// The plugin's mark as a line drawing; src/shared/icons.mjs has the same path as BRAND_LINE.
|
|
1031
1160
|
const SIDEBAR_ICON = 'M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M12 6.8c.47 2.87 2.33 4.73 5.2 5.2-2.87.47-4.73 2.33-5.2 5.2-.47-2.87-2.33-4.73-5.2-5.2 2.87-.47 4.73-2.33 5.2-5.2Z'
|
|
1032
1161
|
|
|
1033
1162
|
function SidebarIcon({ size = 18, active }) {
|
|
@@ -1054,6 +1183,15 @@ window.__ModuleLoader__.load({
|
|
|
1054
1183
|
'dsh-selection: settings page',
|
|
1055
1184
|
)
|
|
1056
1185
|
ctx.effect(() => events.start(), 'dsh-selection: event stream')
|
|
1186
|
+
// Updates need DSH's plugin manager; a DSH that gives a page none still shows everything else.
|
|
1187
|
+
if (typeof ctx.inject === 'function') {
|
|
1188
|
+
ctx.inject(['remote', 'remote.pluginManager'], (sub) => {
|
|
1189
|
+
sub.effect(() => {
|
|
1190
|
+
updates.attach(sub.remote.pluginManager)
|
|
1191
|
+
return () => updates.attach(null)
|
|
1192
|
+
}, 'dsh-selection: updates')
|
|
1193
|
+
})
|
|
1194
|
+
}
|
|
1057
1195
|
// "More settings…" in the toolbar menu asks the Host to bring DSH forward on this page.
|
|
1058
1196
|
ctx.effect(() => events.subscribe((event) => {
|
|
1059
1197
|
if (event !== 'navigate') return
|
package/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// DSH plugin entry (Host side). Starts the selection service and mounts its HTTP API.
|
|
2
2
|
// The settings page lives in client.js; the toolbar runs in a worker process (src/worker).
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
3
4
|
import { join } from 'node:path'
|
|
4
5
|
import { createHttpHandler, ROUTE } from './src/host/http.mjs'
|
|
5
6
|
import { createLogger } from './src/host/log.mjs'
|
|
6
7
|
import { createSelectionService } from './src/host/service.mjs'
|
|
7
8
|
import { dataDirectory } from './src/shared/paths.mjs'
|
|
8
9
|
|
|
10
|
+
// Shown on the settings page beside the update check.
|
|
11
|
+
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
|
|
12
|
+
|
|
9
13
|
export const name = 'dsh-selection'
|
|
10
14
|
export const inject = ['webServer', 'connection']
|
|
11
15
|
|
|
@@ -26,6 +30,7 @@ export function apply(ctx, config = {}) {
|
|
|
26
30
|
dataDir,
|
|
27
31
|
desktop,
|
|
28
32
|
promptForPermission: config.promptForPermission !== false,
|
|
33
|
+
version,
|
|
29
34
|
})
|
|
30
35
|
log('info', 'plugin started', { platform: process.platform, desktop })
|
|
31
36
|
ctx.effect(() => () => service.dispose(), 'dsh-selection: service')
|
package/package.json
CHANGED
package/src/host/service.mjs
CHANGED
|
@@ -30,12 +30,14 @@ export class BadRequest extends Error {}
|
|
|
30
30
|
* platform?: string,
|
|
31
31
|
* startWorker?: typeof startWorker,
|
|
32
32
|
* openExternal?: (url: string) => void,
|
|
33
|
+
* version?: string,
|
|
33
34
|
* }} options `desktop` is false when the Host is not on the user's machine (e.g. a remote web Host).
|
|
34
35
|
* Tests replace `platform`, `startWorker` and `openExternal`.
|
|
35
36
|
*/
|
|
36
37
|
export function createSelectionService({
|
|
37
38
|
getLlm, log, dataDir = dataDirectory(), desktop, promptForPermission = true,
|
|
38
39
|
platform = process.platform, startWorker: launchWorker = startWorker, openExternal = (url) => openWithSystem(url, log),
|
|
40
|
+
version = '',
|
|
39
41
|
}) {
|
|
40
42
|
const settings = createSettingsStore({ file: join(dataDir, 'settings.json'), log })
|
|
41
43
|
const model = createModelClient({ getLlm, log })
|
|
@@ -241,6 +243,7 @@ export function createSelectionService({
|
|
|
241
243
|
defaults: defaultSettings(),
|
|
242
244
|
status,
|
|
243
245
|
recentApps: [...recentApps],
|
|
246
|
+
version,
|
|
244
247
|
catalog: {
|
|
245
248
|
builtins: BUILTIN_SKILLS,
|
|
246
249
|
languages: LANGUAGES,
|
|
@@ -20,12 +20,14 @@ const SEARCH_MAX_LENGTH = 2000
|
|
|
20
20
|
* idPrefix: string,
|
|
21
21
|
* now?: () => number,
|
|
22
22
|
* later?: (callback: () => void, ms: number) => unknown,
|
|
23
|
+
* trackPointer?: (on: boolean) => void,
|
|
23
24
|
* }} options `positions` are selection-hook's INVALID_COORDINATE and PositionLevel.SEL_FULL.
|
|
24
25
|
* `idPrefix` must differ between worker processes, so a late answer never lands in a new panel.
|
|
26
|
+
* `trackPointer` turns the hook's mouse moves on while the overlay shows, and off again.
|
|
25
27
|
*/
|
|
26
28
|
export function createController({
|
|
27
29
|
overlay, speech, send, log, writeClipboard, openUrl, positions, idPrefix,
|
|
28
|
-
now = Date.now, later = setTimeout,
|
|
30
|
+
now = Date.now, later = setTimeout, trackPointer = () => {},
|
|
29
31
|
}) {
|
|
30
32
|
let config = null
|
|
31
33
|
/** `text` goes to the model and the panel; `full` is what Copy, Read aloud and Search use. */
|
|
@@ -75,6 +77,7 @@ export function createController({
|
|
|
75
77
|
selection = { id: ++nextSelection, text: cut(text, config.maxTextLength), full: text, app: data.programName }
|
|
76
78
|
shownAt = now()
|
|
77
79
|
overlay.showBar(anchor)
|
|
80
|
+
trackPointer(true)
|
|
78
81
|
// The text stays here until the user picks an action; the Host only learns which app it came from.
|
|
79
82
|
send({ type: 'selection', app: data.programName })
|
|
80
83
|
}
|
|
@@ -93,6 +96,10 @@ export function createController({
|
|
|
93
96
|
if (overlay.mode !== 'hidden' && !pressedOnOverlay) dismiss({ reason: 'click-outside', point })
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
function onMouseMove(point) {
|
|
100
|
+
if (overlay.mode !== 'hidden') overlay.pointer(point)
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
function onMouseWheel(point) {
|
|
97
104
|
if (overlay.mode === 'bar' && !overlay.contains(point)) dismiss({ reason: 'scroll-outside', point })
|
|
98
105
|
}
|
|
@@ -261,9 +268,10 @@ export function createController({
|
|
|
261
268
|
panel = null
|
|
262
269
|
overlay.setKeyable(false)
|
|
263
270
|
overlay.hide()
|
|
271
|
+
trackPointer(false)
|
|
264
272
|
}
|
|
265
273
|
|
|
266
|
-
return { configure, onSelection, onMouseDown, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
|
|
274
|
+
return { configure, onSelection, onMouseDown, onMouseMove, onMouseWheel, onKeyDown, onKeyUp, onPage, onResult }
|
|
267
275
|
}
|
|
268
276
|
|
|
269
277
|
/** Shortens text without splitting a surrogate pair, which encodeURIComponent and JSON would reject. */
|
package/src/worker/main.mjs
CHANGED
|
@@ -61,12 +61,15 @@ controller = createController({
|
|
|
61
61
|
openUrl: (url) => openUrl(url, log),
|
|
62
62
|
positions: { invalid: SelectionHook.INVALID_COORDINATE, fullSelection: SelectionHook.PositionLevel.SEL_FULL },
|
|
63
63
|
idPrefix: String(process.pid),
|
|
64
|
+
// Mouse moves cost CPU on every move anywhere, so the hook reports them only while the overlay shows.
|
|
65
|
+
trackPointer: (on) => (on ? hook.enableMouseMoveEvent() : hook.disableMouseMoveEvent()),
|
|
64
66
|
})
|
|
65
67
|
applyConfig(config)
|
|
66
68
|
|
|
67
69
|
hook.on('text-selection', guarded('selection', (data) => controller.onSelection(data)))
|
|
68
70
|
hook.on('mouse-down', guarded('mouse-down', (point) => controller.onMouseDown(point)))
|
|
69
71
|
hook.on('mouse-wheel', guarded('mouse-wheel', (point) => controller.onMouseWheel(point)))
|
|
72
|
+
hook.on('mouse-move', guarded('mouse-move', (point) => controller.onMouseMove(point)))
|
|
70
73
|
hook.on('key-down', guarded('key-down', ({ uniKey }) => {
|
|
71
74
|
if (uniKey === 'Alt' && !altDown) {
|
|
72
75
|
altDown = true
|
package/src/worker/overlay.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { fitWindow, place, roomBelow } from './placement.mjs'
|
|
|
10
10
|
const PUMP_VISIBLE_MS = 16
|
|
11
11
|
const PUMP_HIDDEN_MS = 250
|
|
12
12
|
const PAGE_READY_TIMEOUT_MS = 5000
|
|
13
|
+
// Pointer updates for hover: one per frame is as often as the page can draw them.
|
|
14
|
+
const POINTER_INTERVAL_MS = 16
|
|
13
15
|
// The least height a panel gets for its answer before it scrolls: about eight lines plus the header and
|
|
14
16
|
// the follow-up box. Below the selection it may grow further, down to the bottom of the screen.
|
|
15
17
|
const PANEL_MIN_ROOM = 280
|
|
@@ -79,6 +81,10 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
79
81
|
let bounds = null
|
|
80
82
|
// How tall the panel was last allowed to grow, and whether to fill that at once, as told to the page.
|
|
81
83
|
let room = null
|
|
84
|
+
// The pointer as passed to the page: at most once a frame, and once more when it leaves the window.
|
|
85
|
+
let pointerTimer = null
|
|
86
|
+
let pointerNext = null
|
|
87
|
+
let pointerInside = false
|
|
82
88
|
// Once the user drags the window it stays where they put it: later size changes keep its top-left.
|
|
83
89
|
let moved = false
|
|
84
90
|
let drag = null
|
|
@@ -232,6 +238,9 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
232
238
|
},
|
|
233
239
|
hide() {
|
|
234
240
|
if (mode === 'hidden') return
|
|
241
|
+
clearTimeout(pointerTimer)
|
|
242
|
+
pointerTimer = null
|
|
243
|
+
pointerInside = false
|
|
235
244
|
overlayWindow.hide()
|
|
236
245
|
mode = 'hidden'
|
|
237
246
|
placement = null
|
|
@@ -242,6 +251,23 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
242
251
|
call('reset')
|
|
243
252
|
setPump(PUMP_HIDDEN_MS)
|
|
244
253
|
},
|
|
254
|
+
/**
|
|
255
|
+
* Passes the pointer (selection-hook coordinates) to the page while the window shows: macOS sends
|
|
256
|
+
* no mouse moves to the window of a background process, so the page draws hover from these.
|
|
257
|
+
*/
|
|
258
|
+
pointer(point) {
|
|
259
|
+
if (mode === 'hidden' || !bounds) return
|
|
260
|
+
const { x, y } = toLogical(point)
|
|
261
|
+
const inside = x >= bounds.x && x < bounds.x + bounds.width && y >= bounds.y && y < bounds.y + bounds.height
|
|
262
|
+
if (!inside && !pointerInside) return
|
|
263
|
+
pointerInside = inside
|
|
264
|
+
pointerNext = inside ? { x: Math.round(x - bounds.x), y: Math.round(y - bounds.y) } : null
|
|
265
|
+
if (pointerTimer) return
|
|
266
|
+
pointerTimer = setTimeout(() => {
|
|
267
|
+
pointerTimer = null
|
|
268
|
+
if (mode !== 'hidden') call('pointer', pointerNext)
|
|
269
|
+
}, POINTER_INTERVAL_MS)
|
|
270
|
+
},
|
|
245
271
|
get mode() { return mode },
|
|
246
272
|
/** Where the window was placed, in the same coordinates as `contains` (self tests and diagnostics). */
|
|
247
273
|
get bounds() { return bounds },
|
package/ui/toolbar.css
CHANGED
|
@@ -49,9 +49,10 @@ svg { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; s
|
|
|
49
49
|
|
|
50
50
|
button, select, textarea { font: inherit; color: inherit; }
|
|
51
51
|
button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; background: none; border-radius: 8px; cursor: default; }
|
|
52
|
-
/* Hover
|
|
53
|
-
|
|
54
|
-
button
|
|
52
|
+
/* Hover is data-hover, set from the pointer the worker reports (see bar.pointer in toolbar.js): macOS
|
|
53
|
+
sends no mouse moves to the window of a background process, so :hover would never apply. */
|
|
54
|
+
button[data-hover] { background: var(--hover); }
|
|
55
|
+
button:active { background: var(--press); }
|
|
55
56
|
button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
56
57
|
.icon-button { width: 30px; height: 30px; padding: 0; color: var(--text); }
|
|
57
58
|
.icon-button[disabled] { opacity: 0.35; pointer-events: none; }
|
|
@@ -96,10 +97,10 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
96
97
|
color: var(--text); font-size: 12px; font-weight: 500; line-height: 26px; cursor: default;
|
|
97
98
|
text-overflow: ellipsis; white-space: nowrap; overflow: hidden;
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
+
.panel-language[data-hover] { background-color: var(--press); }
|
|
100
101
|
[data-kind="translate"] .panel-language { display: inline-block; }
|
|
101
102
|
.tool { width: 28px; height: 28px; color: var(--muted); }
|
|
102
|
-
[data-
|
|
103
|
+
.tool[data-hover] { color: var(--text); }
|
|
103
104
|
.tool[aria-pressed="true"] { color: var(--accent); background: var(--hover); }
|
|
104
105
|
.tool.done { color: #1f9d55; }
|
|
105
106
|
[data-kind="ask"] .tool[data-tool="retry"] { display: none; }
|
|
@@ -133,7 +134,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
133
134
|
.composer { flex: none; display: none; align-items: flex-end; gap: 6px; padding: 8px 10px 10px 14px; border-top: 1px solid var(--hairline); }
|
|
134
135
|
[data-kind="ask"] .composer, [data-composer="shown"] .composer { display: flex; }
|
|
135
136
|
.composer textarea {
|
|
136
|
-
flex: 1; min-height:
|
|
137
|
+
flex: 1; min-height: 28px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
|
|
137
138
|
overflow-y: hidden; scrollbar-width: none;
|
|
138
139
|
line-height: 1.5; user-select: text; -webkit-user-select: text; cursor: text;
|
|
139
140
|
}
|
|
@@ -141,7 +142,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
141
142
|
.composer textarea::-webkit-scrollbar { display: none; }
|
|
142
143
|
.composer textarea::placeholder { color: var(--muted); }
|
|
143
144
|
.send { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff; }
|
|
144
|
-
[data-
|
|
145
|
+
.send[data-hover] { background: var(--accent); filter: brightness(1.08); }
|
|
145
146
|
.send[disabled] { opacity: 0.35; }
|
|
146
147
|
|
|
147
148
|
/* Model output */
|
|
@@ -155,7 +156,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
155
156
|
.markdown pre code { padding: 0; background: none; font-size: 12px; line-height: 1.5; }
|
|
156
157
|
.markdown blockquote { padding-left: 10px; border-left: 3px solid var(--hairline); color: var(--muted); }
|
|
157
158
|
.markdown a { color: var(--accent); text-decoration: none; cursor: pointer; }
|
|
158
|
-
|
|
159
|
+
.markdown a[data-hover] { text-decoration: underline; }
|
|
159
160
|
.markdown table { border-collapse: collapse; font-size: 12.5px; }
|
|
160
161
|
.markdown th, .markdown td { padding: 4px 8px; border: 1px solid var(--hairline); text-align: left; vertical-align: top; }
|
|
161
162
|
.markdown th { background: var(--soft); font-weight: 600; }
|
package/ui/toolbar.js
CHANGED
|
@@ -79,7 +79,14 @@
|
|
|
79
79
|
const emptyState = (requestId = null, pending = false) => ({ requestId, text: '', turn: null, pending, failure: null })
|
|
80
80
|
let skills = { bar: [], more: [] }
|
|
81
81
|
let style = 'full'
|
|
82
|
-
|
|
82
|
+
// The mini toolbar opens its menu once the pointer rests on the icon, and closes it once the pointer
|
|
83
|
+
// has been out of the window for a moment.
|
|
84
|
+
const HOVER_OPEN_MS = 120
|
|
85
|
+
const HOVER_CLOSE_MS = 300
|
|
86
|
+
let openTimer = null
|
|
87
|
+
let closeTimer = null
|
|
88
|
+
/** The control drawn as hovered. */
|
|
89
|
+
let hovered = null
|
|
83
90
|
|
|
84
91
|
// The menu offers whichever style is not in use.
|
|
85
92
|
function drawStyleItem() {
|
|
@@ -87,6 +94,18 @@
|
|
|
87
94
|
els.styleItem.querySelector('span').textContent = style === 'mini' ? t.fullStyle : t.miniStyle
|
|
88
95
|
}
|
|
89
96
|
drawStyleItem()
|
|
97
|
+
|
|
98
|
+
function stopHoverTimers() {
|
|
99
|
+
clearTimeout(openTimer)
|
|
100
|
+
clearTimeout(closeTimer)
|
|
101
|
+
openTimer = null
|
|
102
|
+
closeTimer = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clearHover() {
|
|
106
|
+
hovered?.removeAttribute('data-hover')
|
|
107
|
+
hovered = null
|
|
108
|
+
}
|
|
90
109
|
let state = emptyState()
|
|
91
110
|
let renderTimer = null
|
|
92
111
|
// Timers rather than requestAnimationFrame: the view can be occluded or the screen locked, and then
|
|
@@ -104,6 +123,7 @@
|
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
function setMenu(open) {
|
|
126
|
+
stopHoverTimers()
|
|
107
127
|
if (open) {
|
|
108
128
|
const bar = $('.bar')
|
|
109
129
|
const button = style === 'mini' ? els.mini : els.more
|
|
@@ -182,8 +202,8 @@
|
|
|
182
202
|
queueSize()
|
|
183
203
|
},
|
|
184
204
|
showBar() {
|
|
185
|
-
|
|
186
|
-
|
|
205
|
+
stopHoverTimers()
|
|
206
|
+
clearHover()
|
|
187
207
|
body.dataset.mode = 'bar'
|
|
188
208
|
setMenu(false)
|
|
189
209
|
},
|
|
@@ -191,7 +211,7 @@
|
|
|
191
211
|
if (body.dataset.mode === 'bar') setMenu(Boolean(open))
|
|
192
212
|
},
|
|
193
213
|
showPanel({ skill, text, app, languages, translateTarget }) {
|
|
194
|
-
|
|
214
|
+
clearHover()
|
|
195
215
|
body.dataset.mode = 'panel'
|
|
196
216
|
body.dataset.menu = 'closed'
|
|
197
217
|
body.dataset.kind = skill.kind === 'ask' ? 'ask' : skill.id === 'translate' ? 'translate' : 'prompt'
|
|
@@ -260,6 +280,7 @@
|
|
|
260
280
|
if (requestId !== state.requestId) return
|
|
261
281
|
state.pending = false
|
|
262
282
|
body.dataset.composer = 'shown'
|
|
283
|
+
resizeInput()
|
|
263
284
|
scheduleRender()
|
|
264
285
|
},
|
|
265
286
|
fail(requestId, failure) {
|
|
@@ -291,9 +312,36 @@
|
|
|
291
312
|
else delete body.dataset.fill
|
|
292
313
|
queueSize()
|
|
293
314
|
},
|
|
315
|
+
/**
|
|
316
|
+
* Where the pointer is in the window, or null once it has left, from the worker's global mouse
|
|
317
|
+
* hook: macOS sends no mouse moves to the window of a background process, so hover is drawn here.
|
|
318
|
+
*/
|
|
319
|
+
pointer(point) {
|
|
320
|
+
const target = point ? document.elementFromPoint(point.x, point.y) : null
|
|
321
|
+
const control = target?.closest('button, select, a') ?? null
|
|
322
|
+
if (control !== hovered) {
|
|
323
|
+
clearHover()
|
|
324
|
+
control?.setAttribute('data-hover', '')
|
|
325
|
+
hovered = control
|
|
326
|
+
}
|
|
327
|
+
if (style !== 'mini' || body.dataset.mode !== 'bar') return
|
|
328
|
+
if (body.dataset.menu !== 'open') {
|
|
329
|
+
if (!target?.closest('.mini-button') || press) {
|
|
330
|
+
clearTimeout(openTimer)
|
|
331
|
+
openTimer = null
|
|
332
|
+
} else if (openTimer === null) {
|
|
333
|
+
openTimer = setTimeout(() => setMenu(true), HOVER_OPEN_MS)
|
|
334
|
+
}
|
|
335
|
+
} else if (point) {
|
|
336
|
+
clearTimeout(closeTimer)
|
|
337
|
+
closeTimer = null
|
|
338
|
+
} else if (closeTimer === null) {
|
|
339
|
+
closeTimer = setTimeout(() => setMenu(false), HOVER_CLOSE_MS)
|
|
340
|
+
}
|
|
341
|
+
},
|
|
294
342
|
reset() {
|
|
295
|
-
|
|
296
|
-
|
|
343
|
+
stopHoverTimers()
|
|
344
|
+
clearHover()
|
|
297
345
|
body.dataset.mode = 'hidden'
|
|
298
346
|
body.dataset.menu = 'closed'
|
|
299
347
|
if (document.activeElement) document.activeElement.blur()
|
|
@@ -310,6 +358,7 @@
|
|
|
310
358
|
type: 'probe',
|
|
311
359
|
mode: body.dataset.mode,
|
|
312
360
|
menu: body.dataset.menu,
|
|
361
|
+
hovered: hovered ? hovered.getAttribute('aria-label') || hovered.textContent.trim() : null,
|
|
313
362
|
viewport: { width: innerWidth, height: innerHeight },
|
|
314
363
|
card: { width: Math.round(box.width), height: Math.round(box.height) },
|
|
315
364
|
buttons: buttons.map((button) => button.getAttribute('aria-label') || button.textContent.trim()),
|
|
@@ -368,7 +417,8 @@
|
|
|
368
417
|
function resizeInput() {
|
|
369
418
|
els.input.style.height = 'auto'
|
|
370
419
|
const height = els.input.scrollHeight
|
|
371
|
-
|
|
420
|
+
// A hidden box measures nothing; it is measured again when it shows.
|
|
421
|
+
if (height) els.input.style.height = `${Math.min(height, 96)}px`
|
|
372
422
|
// Scrollable only past its tallest size: at fractional display scaling one line can overflow by a
|
|
373
423
|
// fraction of a pixel, which would otherwise let it scroll.
|
|
374
424
|
els.input.style.overflowY = height > 96 ? 'auto' : 'hidden'
|
|
@@ -391,7 +441,6 @@
|
|
|
391
441
|
return
|
|
392
442
|
}
|
|
393
443
|
if (event.target.closest('.mini-button')) {
|
|
394
|
-
clearTimeout(hoverTimer)
|
|
395
444
|
setMenu(true)
|
|
396
445
|
return
|
|
397
446
|
}
|
|
@@ -515,36 +564,6 @@
|
|
|
515
564
|
if (body.dataset.mode === 'panel') post({ type: 'keyboard', needed: true })
|
|
516
565
|
})
|
|
517
566
|
|
|
518
|
-
// The mini toolbar opens its menu once the pointer rests on the icon, and closes it once the pointer
|
|
519
|
-
// has left the window for a moment. Leaving the icon alone is not enough: a menu that does not fit
|
|
520
|
-
// below makes the worker move the whole window, which can take the icon from under the pointer.
|
|
521
|
-
const HOVER_OPEN_MS = 120
|
|
522
|
-
const HOVER_CLOSE_MS = 300
|
|
523
|
-
els.mini.addEventListener('pointerenter', () => {
|
|
524
|
-
clearTimeout(hoverTimer)
|
|
525
|
-
if (style !== 'mini' || body.dataset.menu === 'open' || press) return
|
|
526
|
-
hoverTimer = setTimeout(() => setMenu(true), HOVER_OPEN_MS)
|
|
527
|
-
})
|
|
528
|
-
els.mini.addEventListener('pointerleave', () => {
|
|
529
|
-
if (body.dataset.menu !== 'open') clearTimeout(hoverTimer)
|
|
530
|
-
})
|
|
531
|
-
els.mini.addEventListener('pointerdown', () => clearTimeout(hoverTimer))
|
|
532
|
-
document.addEventListener('pointerout', (event) => {
|
|
533
|
-
if (event.relatedTarget || style !== 'mini' || body.dataset.menu !== 'open') return
|
|
534
|
-
clearTimeout(hoverTimer)
|
|
535
|
-
hoverTimer = setTimeout(() => setMenu(false), HOVER_CLOSE_MS)
|
|
536
|
-
})
|
|
537
|
-
document.addEventListener('pointerover', () => {
|
|
538
|
-
if (body.dataset.menu === 'open') clearTimeout(hoverTimer)
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
// WebKit keeps :hover on whatever was under the pointer when it last saw one, through hiding and
|
|
542
|
-
// showing the window, and this process is never the active app, so a pointer that does not move
|
|
543
|
-
// over the page is not tracked. Hover styles therefore wait for a real movement after each show.
|
|
544
|
-
document.addEventListener('pointermove', () => {
|
|
545
|
-
if (!('pointer' in body.dataset)) body.dataset.pointer = ''
|
|
546
|
-
})
|
|
547
|
-
|
|
548
567
|
// Dropping a link or file would navigate the view away from this page.
|
|
549
568
|
for (const type of ['dragover', 'drop']) document.addEventListener(type, (event) => event.preventDefault())
|
|
550
569
|
window.addEventListener('error', (event) => post({ type: 'page-error', message: String(event.message) }))
|