@yiarce/dsh-chat 0.0.0-stage → 0.1.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 +60 -3
- package/appearance.js +158 -0
- package/chat.html +46 -0
- package/client.js +130 -0
- package/context-menu.js +152 -0
- package/cordis.patch.yml +4 -0
- package/history-client.js +109 -0
- package/history-host.js +86 -0
- package/history-model.js +20 -0
- package/history-store.js +238 -0
- package/host.js +67 -0
- package/login.js +250 -0
- package/media-client.js +46 -0
- package/media.js +197 -0
- package/package.json +52 -6
- package/page.css +1463 -0
- package/page.js +1819 -0
- package/standalone.js +11 -0
- package/window-client.js +32 -0
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @yiarce/dsh-chat
|
|
2
|
+
|
|
3
|
+
独立聊天插件。插件拥有页面、样式、登录、图片上传、表情搜索和图片代取;普通浏览器、Electron 和自有 Wails 桌面使用同一套业务能力。
|
|
4
|
+
|
|
5
|
+
## 页面与窗口
|
|
6
|
+
|
|
7
|
+
侧栏入口注册在 sidebar.footer.action。普通环境在主页面内挂载聊天,收起后保留连接。环境判断沿用 window._wails / window.__wailsRuntimeInjected;自有桌面中点击入口请求打开或聚焦独立聊天窗口,失败时回到页内展示。
|
|
8
|
+
|
|
9
|
+
独立窗口直接加载同源 /api/yiarce-chat/page?window=desktop,脚本和样式由插件提供。桌面不再内嵌聊天页,也不代发登录、搜索、图片请求。Go 只负责创建、聚焦窗口、注入 Wails 运行时和最小化/关闭/最大化等窗口操作。环境检测没有改成另一套能力接口。
|
|
10
|
+
|
|
11
|
+
独立页使用插件中的 appearance.js,通过同源 BroadcastChannel 接收主页面的主题变量、字体和可识别的图片/视频背景;没有主页面时使用默认外观。所有同步和页面初始化代码均位于插件,不依赖 GitService.ReadTheme 或桌面主题接口。关闭独立窗口释放聊天连接,重复打开复用现有窗口。
|
|
12
|
+
|
|
13
|
+
## 登录与聊天
|
|
14
|
+
|
|
15
|
+
优先使用可选 account 插件的 /api/yiarce-account/chat-token;仅当路由返回 404 时使用本插件的微信登录。账号凭据按 profile 隔离,不把 token 放在聊天页 URL。WebSocket 直接连接账号接口返回的聊天服务。聊天服务默认地址由 CHAT_SERVER 指定,未设置时为 http://101.35.239.136:34119。HTTPS 页面需要可用的 WSS 服务。
|
|
16
|
+
|
|
17
|
+
房间及私聊都支持文字和图片。发送立即进入本地消息列表,等待服务端 client_message_id 回显;成功移除等待图标,失败显示感叹号。消息、会话及文字草稿由插件 Node 后端写入 SQLite,按 profile、聊天服务地址及当前账号隔离;切换账号仅清除旧账号的页面内存,不删除其数据库记录。Enter 发送,Ctrl+Enter 换行,最多 1000 字。
|
|
18
|
+
|
|
19
|
+
## 本地聊天记录
|
|
20
|
+
|
|
21
|
+
使用 Node 内置 node:sqlite,要求 Node >=22.13(当前运行环境为 Node 24)。数据库位于 $DSH_HOME/yiarce-chat/<profile-hash>/history.sqlite,未设置 DSH_HOME 时使用 ~/.dsh。SQLite 使用 WAL 与事务,文件路径不包含用户输入的账号或会话 ID;账号隔离在数据库键中完成。
|
|
22
|
+
|
|
23
|
+
启动恢复会话列表与文字草稿;每个会话首次打开只读取最近 50 条,顺序按本地首次保存顺序展示。不做历史分页或向上加载,不显示条数限制提示。同一页面切换会话保留已加载内容,在线每会话最多展示 1000 条;超过后只移除最早的内存和 DOM,数据库不按条数或天数清理。移除正在发送消息的展示不会取消后台上传/确认,最终结果继续更新数据库。
|
|
24
|
+
|
|
25
|
+
发送先保存初始记录,才开始上传或发送;服务端回显根据发送者和 client_message_id 更新原记录。接收方也先保存再展示,跨窗口重复消息通过唯一键去重。尚无 client_message_id 的旧消息使用服务端序号、双方、房间、时间及内容组合标识。写入失败明确提示,不自动宣称已保存;初始发送记录保存失败时不把消息发出。
|
|
26
|
+
|
|
27
|
+
退出、断线或重启时,未完成的上传/发送标记失败,不自动重发。仅记录图片远程 URL,不下载原图;本地 blob 预览及未发送图片不跨重启保存,未完成上传重启后显示占位错误。文字草稿输入防抖保存,切换和离开页面补存。新草稿不会被旧消息保存结果清空。
|
|
28
|
+
|
|
29
|
+
删除单条、清空和移除均操作数据库;清空保留会话与文字草稿,移除删除消息和草稿,广场不可移除。持久删除标记用于拦截迟到的确认,防止消息复活。删除/清空正在上传的消息会取消上传。插件通过 SSE 同步多个页面的新增、状态、删除、清空及会话信息;重连校对当前已加载消息,不额外提供历史翻页。
|
|
30
|
+
|
|
31
|
+
只保存插件实际收发到的消息。离线期间未收到的消息需要聊天服务历史接口才能补齐,不在本次范围内。数据库不保存聊天 token,也不会从旧版本不存在的数据库中恢复此前已丢失的消息。
|
|
32
|
+
|
|
33
|
+
## 插件媒体能力
|
|
34
|
+
|
|
35
|
+
全部接口由 ctx.connection.fetch 注册,沿用 DSH 同源认证。
|
|
36
|
+
|
|
37
|
+
| 接口 | 用途 |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| GET /api/yiarce-chat/page | 独立页面及页内模板 |
|
|
40
|
+
| GET /api/yiarce-chat/*.js、page.css | 显式注册的插件前端模块和样式 |
|
|
41
|
+
| GET/POST /api/yiarce-chat/login | 独立登录状态及操作 |
|
|
42
|
+
| POST /api/yiarce-chat/stickers | 搜索,JSON 参数 keyword、page |
|
|
43
|
+
| POST /api/yiarce-chat/sticker-image | 代取图片,JSON 参数 url,返回 dataURL |
|
|
44
|
+
| POST /api/yiarce-chat/upload-image | 二进制图片正文,返回 SSE 进度和最终 URL |
|
|
45
|
+
| POST /api/yiarce-chat/history | 会话、最近 50 条、消息状态、草稿及删除操作 |
|
|
46
|
+
| GET /api/yiarce-chat/history/events | 按账号绑定的 SSE 记录同步 |
|
|
47
|
+
|
|
48
|
+
表情搜索使用 https://h5api.sginput.qq.com/wxbq/search,key 为关键字、page 为页码、num 固定 40。提取 data[].indexUrl,根据 hasMore/endFlag 分页。输入去除首尾空白后为 2–4 个字符,停止输入 350ms 后触发,不限制中文。搜索和图片代取分别有 20 秒、45 秒超时。图片读取按文件头校验 JPG、PNG、GIF、WebP,最多 7MB;已知站点使用兼容 Referer,其他合法 HTTP/HTTPS 图片不按域名或路径限制。
|
|
49
|
+
|
|
50
|
+
本地选择或 Ctrl+V 粘贴图片后仅创建本地预览。点击发送后消息立即进入列表,插件 Node 后端向闪电图床上传:先请求 /api/v2/configs 建立本次上传的隔离 Cookie 会话,再以 X-XSRF-TOKEN 请求 /api/v2/upload,表单字段 file、storage_id=4、is_public=0。不保存图床凭据,不自动切换其他图床。
|
|
51
|
+
|
|
52
|
+
上传进度根据 Node 实际写出的图片字节数回传,SSE data JSON 的 type 为 progress/complete/error;progress 包含 loaded/total,complete 包含 url。传输到 100% 后显示处理中,图床业务成功才发送聊天消息,再等待聊天确认。客户端不能把上传至插件主机的传输进度当作插件到图床的进度。单图 7MB,Node 整个接收和上传限时 90 秒,前端等待上限 120 秒。
|
|
53
|
+
|
|
54
|
+
取消请求、删除消息、清空会话、断线和关闭页面会中止对应的未完成上传;插件卸载也会取消所有请求。失败保留本地消息图片和错误状态。请求取消不能撤销图床已经保存的文件,不自动重试上传。编辑区不显示上传等待提示。
|
|
55
|
+
|
|
56
|
+
## 发布与验证
|
|
57
|
+
|
|
58
|
+
所有插件前后端模块都列入 package.json 的 files。更新图床、搜索或聊天 UI 仅修改插件;本次移除旧内嵌聊天页并切换桌面开窗入口,需要一次主体重建。迁移完成后,后续聊天更新不再需要构建桌面主体。
|
|
59
|
+
|
|
60
|
+
仅进行修改文件的 JavaScript/CSS/TypeScript 语法、Go 格式和静态引用检查;没有构建、启动服务、执行真实上传或运行交互测试。两种入口的登录、同源会话、开窗、图片上传及进度由使用者运行验收。
|
package/appearance.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Appearance synchronization stays in the plugin and uses same-origin channels.
|
|
2
|
+
const CHANNEL = 'yiarce-chat-appearance-v1'
|
|
3
|
+
const relevant = name => name.startsWith('--t-') || name.startsWith('--dsw-') || name.startsWith('--dsh-') || name.startsWith('--app-font')
|
|
4
|
+
function captureAppearance() {
|
|
5
|
+
const variables = {}
|
|
6
|
+
const targets = [document.documentElement, document.body, document.getElementById('root')].filter(Boolean)
|
|
7
|
+
for (const target of targets) {
|
|
8
|
+
const computed = getComputedStyle(target)
|
|
9
|
+
for (let i = 0; i < computed.length; i++) {
|
|
10
|
+
const name = computed[i]
|
|
11
|
+
if (relevant(name)) variables[name] = computed.getPropertyValue(name)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const computed = getComputedStyle(targets.at(-1))
|
|
15
|
+
const fontFaces = []
|
|
16
|
+
for (const sheet of document.styleSheets) {
|
|
17
|
+
try {
|
|
18
|
+
for (const rule of sheet.cssRules) {
|
|
19
|
+
if (rule.type !== CSSRule.FONT_FACE_RULE) continue
|
|
20
|
+
fontFaces.push(rule.cssText.replace(/url\(([^)]+)\)/g, (_, value) => {
|
|
21
|
+
const url = new URL(value.trim().replace(/^['"]|['"]$/g, ''), sheet.href || document.baseURI)
|
|
22
|
+
return 'url(' + JSON.stringify(url.href) + ')'
|
|
23
|
+
}))
|
|
24
|
+
}
|
|
25
|
+
} catch { /* A cross-origin font stylesheet cannot be inspected. */ }
|
|
26
|
+
}
|
|
27
|
+
let media = null
|
|
28
|
+
const effectsFor = element => {
|
|
29
|
+
let parent = element, fixed = false, decorative = false, opacity = 1, filter = ''
|
|
30
|
+
while (parent) {
|
|
31
|
+
const style = getComputedStyle(parent)
|
|
32
|
+
fixed ||= style.position === 'fixed'
|
|
33
|
+
decorative ||= style.pointerEvents === 'none' || Number(style.zIndex) < 0
|
|
34
|
+
opacity *= Number(style.opacity)
|
|
35
|
+
if (style.filter !== 'none') filter += style.filter + ' '
|
|
36
|
+
parent = parent.parentElement
|
|
37
|
+
}
|
|
38
|
+
return { fixed, decorative, opacity, filter }
|
|
39
|
+
}
|
|
40
|
+
// dsh-any-background paints still wallpapers on an anonymous fixed div,
|
|
41
|
+
// not an img. Include CSS backgrounds as well as image/video elements.
|
|
42
|
+
const cssLayers = new Set([...document.body.children, ...document.querySelectorAll('[style*="background-image"]'), document.body, document.documentElement])
|
|
43
|
+
for (const element of cssLayers) {
|
|
44
|
+
const style = getComputedStyle(element)
|
|
45
|
+
if (style.backgroundImage === 'none') continue
|
|
46
|
+
const rect = element.getBoundingClientRect(), effects = effectsFor(element)
|
|
47
|
+
const pageBackground = element === document.body || element === document.documentElement
|
|
48
|
+
if (!pageBackground && (!effects.fixed || !effects.decorative || rect.width * rect.height < innerWidth * innerHeight / 2)) continue
|
|
49
|
+
const size = style.backgroundSize.match(/^([\d.]+)px ([\d.]+)px$/)
|
|
50
|
+
const position = style.backgroundPosition.match(/^(-?[\d.]+)px (-?[\d.]+)px$/)
|
|
51
|
+
media = { kind: 'css', image: style.backgroundImage, size: style.backgroundSize,
|
|
52
|
+
position: style.backgroundPosition, repeat: style.backgroundRepeat,
|
|
53
|
+
origin: style.backgroundOrigin, clip: style.backgroundClip,
|
|
54
|
+
color: style.backgroundColor, blend: style.backgroundBlendMode,
|
|
55
|
+
opacity: effects.opacity, filter: effects.filter,
|
|
56
|
+
// Explicit pixel framing is the wallpaper editor's zoom/pan mode.
|
|
57
|
+
frame: size && position && Number(size[1]) > 0 && Number(size[2]) > 0 ? {
|
|
58
|
+
width: Number(size[1]), height: Number(size[2]), viewportWidth: innerWidth, viewportHeight: innerHeight,
|
|
59
|
+
x: (Number(position[1]) + Number(size[1]) / 2) / innerWidth,
|
|
60
|
+
y: (Number(position[2]) + Number(size[2]) / 2) / innerHeight,
|
|
61
|
+
} : null }
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
if (!media) for (const element of document.querySelectorAll('video, img')) {
|
|
65
|
+
const rect = element.getBoundingClientRect()
|
|
66
|
+
if (!rect.width || !rect.height || rect.width * rect.height < innerWidth * innerHeight / 2) continue
|
|
67
|
+
const effects = effectsFor(element)
|
|
68
|
+
if (!effects.fixed || !effects.decorative) continue
|
|
69
|
+
const src = element.currentSrc || element.src
|
|
70
|
+
if (!src) continue
|
|
71
|
+
const style = getComputedStyle(element)
|
|
72
|
+
media = { kind: element.tagName === 'VIDEO' ? 'video' : 'img', src, opacity: effects.opacity, filter: effects.filter,
|
|
73
|
+
fit: style.objectFit, position: style.objectPosition,
|
|
74
|
+
x: (rect.left + rect.width / 2) / innerWidth, y: (rect.top + rect.height / 2) / innerHeight,
|
|
75
|
+
width: rect.width / innerWidth, height: rect.height / innerHeight }
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
const channels = computed.color.startsWith("rgb") ? computed.color.match(/[0-9.]+/g)?.slice(0, 3).map(Number) : null
|
|
79
|
+
const dark = computed.colorScheme === "dark" || (computed.colorScheme !== "light" && (channels?.length === 3 ? channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722 > 128 : !matchMedia("(prefers-color-scheme: light)").matches))
|
|
80
|
+
return { dark, variables, font: computed.fontFamily, fonts: fontFaces.join('\n'), colorScheme: computed.colorScheme, background: computed.backgroundColor, media }
|
|
81
|
+
}
|
|
82
|
+
export function publishAppearance() {
|
|
83
|
+
if (!globalThis.BroadcastChannel) return () => {}
|
|
84
|
+
const channel = new BroadcastChannel(CHANNEL)
|
|
85
|
+
let last = ''
|
|
86
|
+
const publish = force => {
|
|
87
|
+
const value = captureAppearance(), signature = JSON.stringify(value)
|
|
88
|
+
if (force || signature !== last) { last = signature; channel.postMessage({ type: 'appearance', value }) }
|
|
89
|
+
}
|
|
90
|
+
channel.onmessage = event => { if (event.data?.type === 'request') publish(true) }
|
|
91
|
+
const timer = setInterval(() => publish(false), 1500)
|
|
92
|
+
publish(true)
|
|
93
|
+
return () => { clearInterval(timer); channel.close() }
|
|
94
|
+
}
|
|
95
|
+
export function receiveAppearance(root) {
|
|
96
|
+
if (!globalThis.BroadcastChannel) return () => {}
|
|
97
|
+
const channel = new BroadcastChannel(CHANNEL)
|
|
98
|
+
const fonts = document.createElement('style')
|
|
99
|
+
document.head.append(fonts)
|
|
100
|
+
const background = document.createElement('div')
|
|
101
|
+
background.className = 'yc-standalone-background'; document.body.prepend(background)
|
|
102
|
+
let previous = new Set(), currentMedia = '', currentFrame = null
|
|
103
|
+
const placeCSSBackground = () => {
|
|
104
|
+
const node = background.firstElementChild, frame = currentFrame
|
|
105
|
+
if (!node || !frame) return
|
|
106
|
+
const scale = Math.min(innerWidth / frame.width, innerHeight / frame.height) / Math.min(frame.viewportWidth / frame.width, frame.viewportHeight / frame.height)
|
|
107
|
+
const width = frame.width * scale, height = frame.height * scale
|
|
108
|
+
node.style.backgroundSize = width + 'px ' + height + 'px'
|
|
109
|
+
node.style.backgroundPosition = (frame.x * innerWidth - width / 2) + 'px ' + (frame.y * innerHeight - height / 2) + 'px'
|
|
110
|
+
}
|
|
111
|
+
window.addEventListener('resize', placeCSSBackground)
|
|
112
|
+
let lastSnapshot = null
|
|
113
|
+
channel.onmessage = event => {
|
|
114
|
+
if (event.data?.type !== 'appearance' || !event.data.value) return
|
|
115
|
+
const snapshot = JSON.stringify(event.data.value)
|
|
116
|
+
if (snapshot === lastSnapshot) return
|
|
117
|
+
lastSnapshot = snapshot
|
|
118
|
+
const value = event.data.value, next = new Set()
|
|
119
|
+
for (const [key, field] of Object.entries(value.variables || {})) {
|
|
120
|
+
if (relevant(key) && typeof field === 'string') { document.documentElement.style.setProperty(key, field); next.add(key) }
|
|
121
|
+
}
|
|
122
|
+
for (const key of previous) if (!next.has(key)) document.documentElement.style.removeProperty(key)
|
|
123
|
+
previous = next
|
|
124
|
+
document.body.style.fontFamily = value.font
|
|
125
|
+
document.documentElement.style.setProperty('--app-font', value.font)
|
|
126
|
+
document.documentElement.style.colorScheme = value.colorScheme
|
|
127
|
+
document.body.style.backgroundColor = value.background
|
|
128
|
+
fonts.textContent = value.fonts || ''
|
|
129
|
+
const media = value.media, signature = JSON.stringify(media)
|
|
130
|
+
if (signature !== currentMedia) {
|
|
131
|
+
currentMedia = signature
|
|
132
|
+
const existing = background.firstElementChild
|
|
133
|
+
currentFrame = null
|
|
134
|
+
if (!media) background.replaceChildren()
|
|
135
|
+
else if (media.kind === 'css') {
|
|
136
|
+
const node = existing?.tagName === 'DIV' ? existing : document.createElement('div')
|
|
137
|
+
if (node !== existing) background.replaceChildren(node)
|
|
138
|
+
Object.assign(node.style, { position: 'absolute', inset: '0', backgroundImage: media.image,
|
|
139
|
+
backgroundSize: media.size, backgroundPosition: media.position, backgroundRepeat: media.repeat,
|
|
140
|
+
backgroundOrigin: media.origin, backgroundClip: media.clip, backgroundColor: media.color,
|
|
141
|
+
backgroundBlendMode: media.blend, opacity: String(media.opacity), filter: media.filter })
|
|
142
|
+
currentFrame = media.frame; placeCSSBackground()
|
|
143
|
+
} else {
|
|
144
|
+
let node = existing
|
|
145
|
+
if (!node || node.tagName.toLowerCase() !== media.kind || node.src !== media.src) {
|
|
146
|
+
node = document.createElement(media.kind === 'video' ? 'video' : 'img')
|
|
147
|
+
if (media.kind === 'video') { node.muted = true; node.autoplay = true; node.loop = true; node.playsInline = true }
|
|
148
|
+
node.src = media.src; background.replaceChildren(node)
|
|
149
|
+
}
|
|
150
|
+
Object.assign(node.style, { position: 'absolute', left: media.x * 100 + '%', top: media.y * 100 + '%', width: media.width * 100 + '%', height: media.height * 100 + '%', transform: 'translate(-50%,-50%)', objectFit: media.fit, objectPosition: media.position, opacity: String(media.opacity), filter: media.filter })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
root.dataset.theme = value.dark ? "dark" : "light"
|
|
154
|
+
}
|
|
155
|
+
channel.postMessage({ type: 'request' })
|
|
156
|
+
const timer = setInterval(() => channel.postMessage({ type: 'request' }), 5000)
|
|
157
|
+
return () => { clearInterval(timer); channel.close(); window.removeEventListener('resize', placeCSSBackground); fonts.remove(); background.remove() }
|
|
158
|
+
}
|
package/chat.html
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<title>在线聊天</title>
|
|
8
|
+
<link rel="stylesheet" href="./page.css">
|
|
9
|
+
<style>html,body{height:100%;margin:0;overflow:hidden}</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<main id="chat-root" class="yc-chat-surface">
|
|
13
|
+
<aside class="yc-sidebar" aria-label="聊天会话">
|
|
14
|
+
<div class="yc-sidebar-heading"><h1>聊天<span>CHAT</span></h1></div>
|
|
15
|
+
<label class="yc-search"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/></svg><input id="search" placeholder="查找会话" aria-label="查找会话"></label>
|
|
16
|
+
<div class="yc-list-caption">会话 <span id="sessionCount">0</span></div>
|
|
17
|
+
<nav id="sessions" class="yc-sessions" aria-label="房间与私聊"></nav>
|
|
18
|
+
<div class="yc-profile">
|
|
19
|
+
<div id="myAvatar" class="yc-avatar"></div>
|
|
20
|
+
<div class="yc-profile-info"><strong id="myName">未登录</strong><div class="yc-presence"><i id="presenceDot"></i><span id="status" role="status" aria-live="polite">正在检查登录…</span></div></div>
|
|
21
|
+
<details id="accountMenu" class="yc-account-menu"><summary aria-label="连接与账号操作" title="连接与账号操作">⋯</summary><div class="yc-account-actions"><button id="btnConnect">连接 / 重试</button><button id="btnRelogin" disabled>重新扫码</button><button id="btnQuit" disabled>断开连接</button></div></details>
|
|
22
|
+
</div>
|
|
23
|
+
</aside>
|
|
24
|
+
<section class="yc-workspace" aria-label="当前会话">
|
|
25
|
+
<div id="window-bar" class="yc-window-bar">
|
|
26
|
+
<div id="window-controls" class="yc-window-controls" hidden aria-label="窗口操作">
|
|
27
|
+
<button id="window-minimize" type="button" aria-label="最小化" title="最小化"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
|
28
|
+
<button id="window-maximize" type="button" aria-label="最大化或还原" title="最大化或还原"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1"/></svg></button>
|
|
29
|
+
<button id="window-close" type="button" aria-label="关闭" title="关闭"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<header class="yc-conversation-header">
|
|
33
|
+
<div class="yc-conversation-heading"><h2 id="conversationTitle">广场</h2><p id="conversationMeta" hidden></p></div>
|
|
34
|
+
<div id="conversationExtras" class="yc-conversation-extras" aria-label="会话信息"></div>
|
|
35
|
+
<button id="btnPanelClose" class="yc-icon-button" aria-label="收起聊天" hidden>×</button>
|
|
36
|
+
</header>
|
|
37
|
+
<div id="messageViewport" class="yc-message-viewport"><div id="messages" class="yc-messages" role="log" aria-label="聊天消息" aria-live="polite" aria-relevant="additions"></div></div>
|
|
38
|
+
<button id="btnLatest" class="yc-latest" hidden>↓ 查看新消息</button>
|
|
39
|
+
<div id="feedback" class="yc-feedback" role="status" hidden></div>
|
|
40
|
+
<form id="composer" class="yc-composer"><textarea id="messageInput" maxlength="1000" rows="2" placeholder="输入消息…" aria-label="消息内容" disabled></textarea><div class="yc-composer-footer"><span id="composerHint">Enter 发送 · Ctrl+Enter 换行</span><span id="charCount" class="yc-char-count">0 / 1000</span><button id="btnSend" class="yc-send" type="submit" disabled>发送</button></div></form>
|
|
41
|
+
<section id="loginBox" class="yc-login" hidden><div class="yc-login-card"><span class="yc-login-mark" aria-hidden="true">◉</span><h2>登录,开始聊天</h2><p id="loginHint">使用微信扫码,和大家打个招呼</p><img id="qr" alt="微信登录二维码" hidden><button id="btnLogin" class="yc-send">微信扫码登录</button><p class="yc-login-note">登录成功后自动进入广场</p></div></section>
|
|
42
|
+
</section>
|
|
43
|
+
</main>
|
|
44
|
+
<script type="module" src="./standalone.js"></script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
package/client.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@yiarce/dsh-chat',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
return {
|
|
7
|
+
inject: ['slots'],
|
|
8
|
+
apply(ctx) {
|
|
9
|
+
let state = { created: false, open: false, unread: 0, error: '' }
|
|
10
|
+
let alive = true
|
|
11
|
+
const clientLife = new AbortController()
|
|
12
|
+
const subscribers = new Set()
|
|
13
|
+
const subscribe = fn => { subscribers.add(fn); return () => subscribers.delete(fn) }
|
|
14
|
+
const update = patch => {
|
|
15
|
+
if (!alive) return
|
|
16
|
+
state = { ...state, ...patch }
|
|
17
|
+
for (const fn of subscribers) fn()
|
|
18
|
+
}
|
|
19
|
+
const snapshot = () => state
|
|
20
|
+
const page = new URL('api/yiarce-chat/page', document.baseURI).href
|
|
21
|
+
const wails = () => Boolean(window._wails || window.__wailsRuntimeInjected)
|
|
22
|
+
ctx.effect(() => {
|
|
23
|
+
let dispose = () => {}
|
|
24
|
+
let cancelled = false
|
|
25
|
+
import(new URL("./appearance.js", page).href).then(module => { if (!cancelled) dispose = module.publishAppearance(page) }).catch(() => {})
|
|
26
|
+
return () => { cancelled = true; dispose() }
|
|
27
|
+
})
|
|
28
|
+
async function open() {
|
|
29
|
+
if (wails()) {
|
|
30
|
+
try {
|
|
31
|
+
const module = await import(new URL("./window-client.js", page).href)
|
|
32
|
+
await module.openNativeChat(clientLife.signal)
|
|
33
|
+
update({ error: '' })
|
|
34
|
+
} catch { if (!alive) return; update({ created: true, open: true, error: '独立窗口打开失败,已切换页内聊天' }) }
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
update({ created: true, open: !state.open, unread: 0, error: '' })
|
|
38
|
+
}
|
|
39
|
+
function Launcher({ wide }) {
|
|
40
|
+
const s = React.useSyncExternalStore(subscribe, snapshot, snapshot)
|
|
41
|
+
const badge = s.unread > 99 ? '99+' : s.unread
|
|
42
|
+
return h('button', { type: 'button', className: `yc-launcher${wide ? '' : ' yc-launcher-rail'}`, onClick: open, title: '在线聊天', 'aria-label': `在线聊天${s.unread ? `,${s.unread} 条未读` : ''}`, 'aria-expanded': s.open },
|
|
43
|
+
h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, 'aria-hidden': true }, h('path', { d: 'M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5Z' })),
|
|
44
|
+
wide && h('span', { className: 'yc-launcher-label' }, '聊天'),
|
|
45
|
+
s.unread > 0 && h('span', { className: `yc-badge${wide ? '' : ' yc-badge-rail'}`, 'aria-hidden': true }, badge))
|
|
46
|
+
}
|
|
47
|
+
function ChatSurface() {
|
|
48
|
+
const root = React.useRef(null)
|
|
49
|
+
const [attempt, retry] = React.useState(0)
|
|
50
|
+
const [error, setError] = React.useState('')
|
|
51
|
+
React.useEffect(() => {
|
|
52
|
+
let cancelled = false
|
|
53
|
+
let dispose = () => {}
|
|
54
|
+
const controller = new AbortController()
|
|
55
|
+
setError('')
|
|
56
|
+
const html = fetch(page, { credentials: 'same-origin', cache: 'no-store', signal: controller.signal }).then(response => {
|
|
57
|
+
if (!response.ok) throw new Error('transport')
|
|
58
|
+
return response.text()
|
|
59
|
+
})
|
|
60
|
+
Promise.all([import(new URL('./page.js', page).href), html]).then(([module, text]) => {
|
|
61
|
+
if (cancelled || !root.current) return
|
|
62
|
+
const parsed = new DOMParser().parseFromString(text, 'text/html')
|
|
63
|
+
const template = parsed.getElementById('chat-root')
|
|
64
|
+
if (!template) throw new Error('invalid-page')
|
|
65
|
+
root.current.innerHTML = template.innerHTML
|
|
66
|
+
const close = root.current.querySelector('#btnPanelClose')
|
|
67
|
+
close.hidden = false
|
|
68
|
+
close.onclick = () => update({ open: false })
|
|
69
|
+
dispose = module.mountChat(root.current, { baseURL: page, onMessage: () => {
|
|
70
|
+
if (!state.open || !document.hasFocus() || document.hidden) update({ unread: Math.min(999, state.unread + 1) })
|
|
71
|
+
} })
|
|
72
|
+
}).catch(() => { if (!cancelled) setError('聊天界面加载失败,请重试') })
|
|
73
|
+
return () => { cancelled = true; controller.abort(); dispose(); if (root.current) root.current.replaceChildren() }
|
|
74
|
+
}, [attempt])
|
|
75
|
+
return h(React.Fragment, null,
|
|
76
|
+
error && h('p', { role: 'alert' }, error, h('button', { onClick: () => retry(value => value + 1) }, '重试')),
|
|
77
|
+
h('div', { className: 'yc-chat-surface', ref: root, style: { fontFamily: 'inherit' } }))
|
|
78
|
+
}
|
|
79
|
+
function Panel() {
|
|
80
|
+
const s = React.useSyncExternalStore(subscribe, snapshot, snapshot)
|
|
81
|
+
if (!s.created) return null
|
|
82
|
+
return h('section', { className: 'yc-panel', style: { display: s.open ? 'flex' : 'none' }, role: 'dialog', 'aria-label': '在线聊天', 'aria-hidden': !s.open },
|
|
83
|
+
s.error && h('p', { role: 'alert' }, s.error), h(ChatSurface))
|
|
84
|
+
}
|
|
85
|
+
ctx.effect(() => ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'yiarce-chat-launcher', order: 20 }, Launcher)))
|
|
86
|
+
ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'yiarce-chat-panel', order: 70 }, Panel)))
|
|
87
|
+
ctx.effect(() => {
|
|
88
|
+
const focus = () => { if (state.open && !document.hidden) update({ unread: 0 }) }
|
|
89
|
+
const key = event => { if (event.key === 'Escape' && state.open) update({ open: false }) }
|
|
90
|
+
window.addEventListener('focus', focus)
|
|
91
|
+
document.addEventListener('visibilitychange', focus)
|
|
92
|
+
document.addEventListener('keydown', key)
|
|
93
|
+
return () => {
|
|
94
|
+
window.removeEventListener('focus', focus)
|
|
95
|
+
document.removeEventListener('visibilitychange', focus)
|
|
96
|
+
document.removeEventListener('keydown', key)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
ctx.effect(() => {
|
|
100
|
+
const link = document.createElement('link')
|
|
101
|
+
link.rel = 'stylesheet'
|
|
102
|
+
link.href = new URL('./page.css', page).href
|
|
103
|
+
document.head.appendChild(link)
|
|
104
|
+
return () => link.remove()
|
|
105
|
+
})
|
|
106
|
+
ctx.effect(() => {
|
|
107
|
+
const style = document.createElement('style')
|
|
108
|
+
style.textContent = `
|
|
109
|
+
.yc-launcher{display:flex;align-items:center;gap:7px;position:relative;padding:8px 10px;border:0;border-radius:8px;background:transparent;color:inherit;cursor:pointer;font:inherit}
|
|
110
|
+
.yc-launcher:hover{background:color-mix(in srgb,currentColor 10%,transparent)}
|
|
111
|
+
.yc-launcher:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
112
|
+
.yc-launcher svg{display:block;flex:none}
|
|
113
|
+
.yc-launcher-label{white-space:nowrap}
|
|
114
|
+
.yc-launcher-rail{box-sizing:border-box;width:36px;height:36px;padding:0;gap:0;justify-content:center;border-radius:var(--dsw-radius-md,8px)}
|
|
115
|
+
.yc-launcher-rail .yc-launcher-label{display:none}
|
|
116
|
+
.yc-badge{background:#df4d59;color:white;border-radius:10px;padding:0 5px;font-size:11px}
|
|
117
|
+
.yc-badge-rail{position:absolute;top:-3px;right:-4px;display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:15px;height:15px;padding:0 3px;border-radius:8px;font-size:9px;line-height:1}
|
|
118
|
+
.yc-panel{position:fixed;right:22px;bottom:22px;width:min(960px,calc(100vw - 44px));height:min(740px,calc(100dvh - 44px));flex-direction:column;z-index:1000;overflow:hidden;border:1px solid var(--t-border,var(--dsw-alias-border-l1,#2a3247));border-radius:14px;background:transparent;color:inherit;box-shadow:0 18px 70px #0006}
|
|
119
|
+
.yc-panel .yc-chat-surface{width:100%;flex:1;min-height:0;overflow:hidden}
|
|
120
|
+
.yc-panel>p{margin:6px 16px;font-size:12px}
|
|
121
|
+
@media(max-width:540px){.yc-panel{right:8px;bottom:8px;width:calc(100vw - 16px);height:calc(100dvh - 16px)}}
|
|
122
|
+
`
|
|
123
|
+
document.head.appendChild(style)
|
|
124
|
+
return () => style.remove()
|
|
125
|
+
})
|
|
126
|
+
ctx.effect(() => () => { alive = false; clientLife.abort(); subscribers.clear() })
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
})
|
package/context-menu.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export function mountChatContextMenu(root, api) {
|
|
2
|
+
const life = new AbortController();
|
|
3
|
+
const make = (tag, cls, text) => { const n = document.createElement(tag); n.className = cls; if (text !== undefined) n.textContent = text; return n; };
|
|
4
|
+
const menu = make("div", "yc-chat-context-menu");
|
|
5
|
+
menu.hidden = true; menu.setAttribute("role", "menu"); menu.setAttribute("aria-label", "聊天操作"); document.body.append(menu);
|
|
6
|
+
let returnFocus;
|
|
7
|
+
function close(restore = false) {
|
|
8
|
+
if (menu.hidden) return; menu.hidden = true; menu.replaceChildren();
|
|
9
|
+
if (restore && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
10
|
+
}
|
|
11
|
+
function theme(node) {
|
|
12
|
+
const style = getComputedStyle(root);
|
|
13
|
+
for (const key of ["--yc-text", "--yc-muted", "--yc-line", "--yc-hover", "--yc-accent"]) node.style.setProperty(key, style.getPropertyValue(key));
|
|
14
|
+
node.style.setProperty("--yc-menu-bg", style.getPropertyValue("--t-bg2").trim() || style.getPropertyValue("--dsw-specific-sidebar-fill").trim() || style.getPropertyValue("--dsw-alias-bg-layer-1").trim() || style.getPropertyValue("--yc-fallback-bg"));
|
|
15
|
+
node.style.fontFamily = style.fontFamily;
|
|
16
|
+
}
|
|
17
|
+
const dialog = make("dialog", "yc-chat-confirm");
|
|
18
|
+
dialog.setAttribute("aria-label", "确认会话操作");
|
|
19
|
+
const prompt = make("p", ""), footer = make("div", "yc-chat-confirm-actions");
|
|
20
|
+
const cancel = make("button", "", "取消"), accept = make("button", "is-danger", "确认");
|
|
21
|
+
cancel.type = accept.type = "button"; footer.append(cancel, accept); dialog.append(prompt, footer); document.body.append(dialog);
|
|
22
|
+
let confirmAction;
|
|
23
|
+
cancel.onclick = () => dialog.close();
|
|
24
|
+
accept.onclick = () => { const action = confirmAction; dialog.close(); try { Promise.resolve(action?.()).catch(error => api.feedback(error.message || "操作失败", "err")); } catch (error) { api.feedback(error.message || "操作失败", "err"); } };
|
|
25
|
+
dialog.addEventListener("close", () => { confirmAction = null; }, { signal: life.signal });
|
|
26
|
+
function confirm(text, action) { if (dialog.open) return; theme(dialog); prompt.textContent = text; confirmAction = action; dialog.showModal(); cancel.focus(); }
|
|
27
|
+
async function copy(text) {
|
|
28
|
+
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text);
|
|
29
|
+
else {
|
|
30
|
+
const field = make("textarea", ""); field.value = text; field.style.cssText = "position:fixed;left:-9999px;top:0"; document.body.append(field);
|
|
31
|
+
const previous = document.activeElement;
|
|
32
|
+
try { field.select(); if (!document.execCommand("copy")) throw new Error("复制失败"); }
|
|
33
|
+
finally { field.remove(); previous?.focus({ preventScroll: true }); }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function editing(input) {
|
|
37
|
+
const start = input.selectionStart, end = input.selectionEnd, value = input.value;
|
|
38
|
+
const owner = api.current();
|
|
39
|
+
const selected = value.slice(start, end), editable = !input.disabled && !input.readOnly;
|
|
40
|
+
function restore() {
|
|
41
|
+
if (!input.isConnected || input.value !== value || api.current() !== owner) throw new Error("输入内容已变化,请重新操作");
|
|
42
|
+
input.focus({ preventScroll: true }); input.setSelectionRange(start, end);
|
|
43
|
+
}
|
|
44
|
+
function edit(command) { restore(); if (!document.execCommand(command)) throw new Error("当前环境无法执行此操作,请使用键盘快捷键"); }
|
|
45
|
+
return [
|
|
46
|
+
{ label: "撤销", disabled: !editable || !document.queryCommandEnabled("undo"), run: () => edit("undo") },
|
|
47
|
+
{ label: "重做", disabled: !editable || !document.queryCommandEnabled("redo"), run: () => edit("redo") },
|
|
48
|
+
{ label: "剪切", disabled: !editable || !selected, run: () => edit("cut") },
|
|
49
|
+
{ label: "复制", disabled: !selected, run: () => copy(selected) },
|
|
50
|
+
{ label: "粘贴", disabled: !editable, run: async () => {
|
|
51
|
+
restore();
|
|
52
|
+
if (!navigator.clipboard?.readText) { edit("paste"); return; }
|
|
53
|
+
const text = await navigator.clipboard.readText();
|
|
54
|
+
if (life.signal.aborted || input.disabled || input.readOnly) return;
|
|
55
|
+
restore();
|
|
56
|
+
const available = input.maxLength < 0 ? text.length : Math.max(0, input.maxLength - value.length + end - start);
|
|
57
|
+
let insertion = "";
|
|
58
|
+
for (const character of text) { if (insertion.length + character.length > available) break; insertion += character; }
|
|
59
|
+
if (insertion && !document.execCommand("insertText", false, insertion)) throw new Error("无法粘贴,请使用 Ctrl+V");
|
|
60
|
+
} },
|
|
61
|
+
{ label: "全选", disabled: input.disabled || !value, run: () => { input.focus(); input.select(); } },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
function actions(target) {
|
|
65
|
+
if (target.closest(".yc-emoji-panel, .yc-sticker-panel, .yc-draft-sticker, .yc-composer-toolbar")) return [];
|
|
66
|
+
if (target === api.input) return editing(api.input);
|
|
67
|
+
const sessionButton = target.closest(".yc-session");
|
|
68
|
+
if (sessionButton) {
|
|
69
|
+
const item = api.sessionFor(sessionButton);
|
|
70
|
+
if (!item) return [];
|
|
71
|
+
const entries = [{ label: "清空消息", disabled: !(item.messageCount || item.entries.length), run: () => confirm("清空本地保存的全部消息记录?会话和草稿会保留。", () => api.clear(item)) }];
|
|
72
|
+
if (item.key !== "room:1") entries.push({ label: "移除会话", danger: true, run: () => confirm("移除此会话?本地保存的全部消息记录和草稿也会删除。", () => api.remove(item)) });
|
|
73
|
+
return entries;
|
|
74
|
+
}
|
|
75
|
+
const row = target.closest(".yc-message");
|
|
76
|
+
if (row) {
|
|
77
|
+
const found = api.entryFor(row); if (!found) return [];
|
|
78
|
+
const { item, entry } = found;
|
|
79
|
+
if (target.closest(".yc-message-avatar")) {
|
|
80
|
+
const id = entry.message.fromUserId;
|
|
81
|
+
if (item.kind !== "room") return [];
|
|
82
|
+
const relation = api.relation(id);
|
|
83
|
+
const entries = [];
|
|
84
|
+
if (relation === "friend") {
|
|
85
|
+
entries.push({ label: "私聊", disabled: !id || api.isSelf(id), run: () => api.privateChat(entry.message) });
|
|
86
|
+
entries.push({ label: "删除好友", danger: true, run: () => api.deleteFriend(entry.message) });
|
|
87
|
+
} else if (relation === "blocked") {
|
|
88
|
+
entries.push({ label: "解除屏蔽", run: () => api.toggleBlock(entry.message) });
|
|
89
|
+
} else {
|
|
90
|
+
entries.push({ label: api.pendingFriend(id) ? "好友申请中" : "加为好友", disabled: !id || api.isSelf(id) || api.pendingFriend(id), run: () => api.addFriend(entry.message) });
|
|
91
|
+
entries.push({ label: "屏蔽用户", danger: true, disabled: !id || api.isSelf(id), run: () => api.toggleBlock(entry.message) });
|
|
92
|
+
}
|
|
93
|
+
entries.push({ label: "复制 ID", disabled: !id, run: () => copy(id) });
|
|
94
|
+
return entries;
|
|
95
|
+
}
|
|
96
|
+
if (!target.closest(".yc-bubble") || target.closest(".yc-message-send-status")) return [];
|
|
97
|
+
return [
|
|
98
|
+
{ label: entry.message.sticker ? "复制图片地址" : "复制", disabled: Boolean(entry.message.sticker && !/^https?:/.test(entry.message.sticker.url)), run: () => copy(entry.message.sticker?.url || entry.message.content || "") },
|
|
99
|
+
{ label: "删除", danger: true, run: () => api.delete(item, entry) },
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
if (target.closest(".yc-message-scroll-area")) {
|
|
103
|
+
const item = api.current(); if (!item) return [];
|
|
104
|
+
return [
|
|
105
|
+
{ label: "回到最新消息", run: api.latest },
|
|
106
|
+
{ label: "清空当前会话消息", disabled: !(item.messageCount || item.entries.length), run: () => confirm("清空本地保存的全部消息记录?会话和草稿会保留。", () => api.clear(item)) },
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
function open(target, x, y, keyboard = false) {
|
|
112
|
+
close(); const entries = actions(target); if (!entries.length) return;
|
|
113
|
+
returnFocus = document.activeElement; theme(menu);
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const button = make("button", entry.danger ? "is-danger" : "", entry.label);
|
|
116
|
+
button.type = "button"; button.disabled = Boolean(entry.disabled); button.setAttribute("role", "menuitem");
|
|
117
|
+
button.onclick = () => { close(true);
|
|
118
|
+
try {
|
|
119
|
+
Promise.resolve(entry.run()).catch(error => { if (!life.signal.aborted) api.feedback(error.name === "NotAllowedError" ? "无法访问剪贴板,请使用键盘快捷键" : error.message || "操作失败", "err"); });
|
|
120
|
+
} catch (error) { api.feedback(error.message || "操作失败", "err"); }
|
|
121
|
+
};
|
|
122
|
+
menu.append(button);
|
|
123
|
+
}
|
|
124
|
+
menu.hidden = false; menu.style.left = "0px"; menu.style.top = "0px";
|
|
125
|
+
const rect = menu.getBoundingClientRect();
|
|
126
|
+
menu.style.left = Math.max(4, Math.min(x, innerWidth - rect.width - 4)) + "px";
|
|
127
|
+
menu.style.top = Math.max(4, Math.min(y, innerHeight - rect.height - 4)) + "px";
|
|
128
|
+
if (keyboard) menu.querySelector("button:not(:disabled)")?.focus();
|
|
129
|
+
}
|
|
130
|
+
const listen = (node, event, handler, options = {}) => node.addEventListener(event, handler, { ...options, signal: life.signal });
|
|
131
|
+
listen(root, "contextmenu", event => { event.preventDefault(); event.stopPropagation(); open(event.target, event.clientX, event.clientY); });
|
|
132
|
+
listen(menu, "contextmenu", event => event.preventDefault());
|
|
133
|
+
listen(dialog, "contextmenu", event => event.preventDefault());
|
|
134
|
+
listen(menu, "mousedown", event => event.preventDefault());
|
|
135
|
+
listen(root, "keydown", event => {
|
|
136
|
+
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { event.preventDefault(); const rect = event.target.getBoundingClientRect(); open(event.target, rect.left, rect.bottom, true); }
|
|
137
|
+
});
|
|
138
|
+
listen(document, "keydown", event => {
|
|
139
|
+
if (menu.hidden) return;
|
|
140
|
+
if (["Escape", "Tab"].includes(event.key)) { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); } close(true); return; }
|
|
141
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End", "Enter", " "].includes(event.key)) return;
|
|
142
|
+
event.preventDefault(); event.stopPropagation();
|
|
143
|
+
const buttons = [...menu.querySelectorAll("button:not(:disabled)")], index = buttons.indexOf(document.activeElement);
|
|
144
|
+
if (event.key === "Enter" || event.key === " ") { if (index >= 0) buttons[index].click(); return; }
|
|
145
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : event.key === "ArrowDown" ? (index + 1) % buttons.length : (index <= 0 ? buttons.length : index) - 1;
|
|
146
|
+
buttons[next]?.focus();
|
|
147
|
+
}, { capture: true });
|
|
148
|
+
listen(document, "pointerdown", event => { if (!menu.contains(event.target)) close(); });
|
|
149
|
+
listen(document, "scroll", event => { if (!menu.contains(event.target)) close(); }, { capture: true });
|
|
150
|
+
listen(window, "blur", () => close()); listen(window, "resize", () => close());
|
|
151
|
+
return { close, dispose() { life.abort(); menu.remove(); dialog.close(); dialog.remove(); } };
|
|
152
|
+
}
|
package/cordis.patch.yml
ADDED