From 66d03fdbb19914fcef35e12ba406de9b0b3332d1 Mon Sep 17 00:00:00 2001 From: YBF <47051132+YBFACC@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:07:57 +0800 Subject: [PATCH] feat: centralize OneTalk DOM interactions --- .../frontend/onetalk/page-controls.md | 3 +- .../check.jsonl | 4 + .../design.md | 44 ++ .../implement.jsonl | 5 + .../implement.md | 33 ++ .../09-14-centralize-dom-interactions/prd.md | 36 ++ .../task.json | 26 ++ apps/chrome-extension/package.json | 3 + .../main-page/action-status-tooltip.ts | 199 +------- .../onetalk/main-page/conversation-id-copy.ts | 149 +----- .../main-page/dom/action-status-tooltip.ts | 192 ++++++++ .../main-page/dom/conversation-id-copy.ts | 147 ++++++ .../main-page/dom/react-file-uploader.ts | 53 +++ .../onetalk/main-page/dom/selection-events.ts | 9 + .../src/onetalk/main-page/dom/selection.ts | 17 + .../src/onetalk/main-page/image-send.ts | 48 +- .../src/onetalk/main-page/page-context.ts | 12 +- .../onetalk/main-page/page-script-entry.ts | 4 +- .../src/onetalk/page-bridge/main.ts | 10 +- .../src/onetalk/page-bridge/model.ts | 2 +- apps/chrome-extension/test/dom-fixture.js | 15 + .../onetalk-action-status-tooltip.test.js | 2 +- .../test/onetalk-conversation-id-copy.test.js | 140 ++---- pnpm-lock.yaml | 428 ++++++++++++++++++ 24 files changed, 1085 insertions(+), 496 deletions(-) create mode 100644 .trellis/tasks/09-14-centralize-dom-interactions/check.jsonl create mode 100644 .trellis/tasks/09-14-centralize-dom-interactions/design.md create mode 100644 .trellis/tasks/09-14-centralize-dom-interactions/implement.jsonl create mode 100644 .trellis/tasks/09-14-centralize-dom-interactions/implement.md create mode 100644 .trellis/tasks/09-14-centralize-dom-interactions/prd.md create mode 100644 .trellis/tasks/09-14-centralize-dom-interactions/task.json create mode 100644 apps/chrome-extension/src/onetalk/main-page/dom/action-status-tooltip.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/dom/conversation-id-copy.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/dom/react-file-uploader.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/dom/selection-events.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/dom/selection.ts create mode 100644 apps/chrome-extension/test/dom-fixture.js diff --git a/.trellis/spec/chrome-extension/frontend/onetalk/page-controls.md b/.trellis/spec/chrome-extension/frontend/onetalk/page-controls.md index bff0e0d..44bf896 100644 --- a/.trellis/spec/chrome-extension/frontend/onetalk/page-controls.md +++ b/.trellis/spec/chrome-extension/frontend/onetalk/page-controls.md @@ -24,7 +24,7 @@ class ConnectionStatusTooltip { } ``` -入口由 `src/onetalk/main-page/page-script-entry.ts` 安装;复制控件实现在 `src/onetalk/main-page/conversation-id-copy.ts`,ID 读取复用 `readCurrentConversationId(pageWindow)`。动作提示实现在 `src/onetalk/main-page/action-status-tooltip.ts`,并将 facade 安装到 `window.__tradeMessageCenterOneTalk.tooltip`。 +入口由 `src/onetalk/main-page/page-script-entry.ts` 安装;复制控件和动作提示的 DOM 实现分别位于 `src/onetalk/main-page/dom/conversation-id-copy.ts` 与 `src/onetalk/main-page/dom/action-status-tooltip.ts`。旧根路径仅保留兼容 re-export,不能再次拥有 DOM API。复制控件的 ID 读取复用 `readCurrentConversationId(pageWindow)`;动作提示将 facade 安装到 `window.__tradeMessageCenterOneTalk.tooltip`。 ## 3. Contracts @@ -33,6 +33,7 @@ class ConnectionStatusTooltip { - 按钮直接追加到标题节点,使用 `inline-flex` 和 `justify-content: center` 保持同行及文字水平居中;文字为“复制会话 Id”,字体显式为 `12px`,不能依赖会话标题的继承字体。 - 点击处理在用户手势中调用 `navigator.clipboard.writeText(conversationId)`;成功显示“已复制”,不可用或 reject 显示“复制失败”,随后恢复按钮文案。不会以 `execCommand`、隐形文本框或其它方式降级复制。 - 页面 DOM 尚未就绪或 SPA 重绘时可由 `MutationObserver` 重新尝试挂载;同一页面只能存在一个该属性的按钮。 +- 非观测页面 DOM 查询、控件创建和更新仅归 `main-page/dom/` 所有;调用方只能使用其语义 API,不能重新取得同一 DOM 权限。 - 动作提示以固定定位、`pointer-events: none` 的 extension-owned `data-tmc-action-status-*` DOM 节点呈现,不依赖 OneTalk 的业务 DOM,也不改变宿主布局。多个活动条目按照首次 `start` 的顺序纵向显示。 - `start` 对同一 `id` 幂等,不覆盖已有行;`update` 与 `close` 只作用于已存在的 `id`,找不到时返回 `false`,不得隐式创建行。颜色只接受 `neutral`、`info`、`warning`、`error`,任意 CSS 色值必须显式失败。 - 状态 facade 必须保留身份,并在再次安装、`start` 或 `update` 时重新挂载所有仍活动的行;宿主移除了 container 或某一行都不能改变状态顺序或丢失活动项。 diff --git a/.trellis/tasks/09-14-centralize-dom-interactions/check.jsonl b/.trellis/tasks/09-14-centralize-dom-interactions/check.jsonl new file mode 100644 index 0000000..94dfd07 --- /dev/null +++ b/.trellis/tasks/09-14-centralize-dom-interactions/check.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/project/source-file-conventions.md","reason":"检查新增和移动 TypeScript 文件的职责注释、bottom-up 和 main-last。"} +{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"检查包级依赖位置、Node test runner、typecheck 和 Vite 构建。"} +{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"验证重构后复制控件和提示浮层的回归边界。"} +{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"验证页面身份、bridge 和自包含 MAIN 入口不发生行为漂移。"} diff --git a/.trellis/tasks/09-14-centralize-dom-interactions/design.md b/.trellis/tasks/09-14-centralize-dom-interactions/design.md new file mode 100644 index 0000000..adb6328 --- /dev/null +++ b/.trellis/tasks/09-14-centralize-dom-interactions/design.md @@ -0,0 +1,44 @@ +# 非观测 DOM 边界设计 + +## Boundary + +`apps/chrome-extension/src/onetalk/main-page/dom/` 是本任务中 OneTalk MAIN-world 非观测 DOM 访问的唯一所有者。它是 OneTalk 专属的页面适配边界,不提升到 `src/lib/`,也不与 Popup 共用。 + +```text +main-page/ +├── dom/ +│ ├── selection.ts # selected 会话和标题节点的 DOM 读取 +│ ├── selection-events.ts # document click 订阅 +│ ├── react-file-uploader.ts # file input 到 React uploader 的窄适配 +│ ├── action-status-tooltip.ts # extension-owned 状态浮层 +│ └── conversation-id-copy.ts # extension-owned 复制按钮 +├── page-context.ts # URL/页面运行时身份与 selection 领域投影 +├── image-send.ts # 媒体发送状态机和结果语义 +└── page-script-entry.ts # 组合入口 +``` + +`buyer-fact-observer/`、`contact-observer/` 不移动、不改 import、不改测试;它们仍自行拥有被动观察和投影。 + +## Responsibilities + +| 现有责任 | 新 DOM 所有者 | 保留的业务所有者 | +| --- | --- | --- | +| selected `[data-cid]`、标题定位 | `dom/selection.ts` | `page-context.ts` 的 `none/single/multiple/unavailable` 语义 | +| document click 刷新 identity | `dom/selection-events.ts` | `page-bridge/main.ts` 的 hello/retry/bridge 生命周期 | +| 找到 file input 并上溯 React uploader | `dom/react-file-uploader.ts` | `image-send.ts` 的请求截止、上传拦截和 delivery result | +| tooltip DOM 创建、重挂载和销毁 | `dom/action-status-tooltip.ts` | connection/history 组件对 tooltip facade 的调用 | +| 复制按钮 DOM 创建、更新和移除 | `dom/conversation-id-copy.ts` | 当前会话 ID 可信来源及 Clipboard 成功/失败语义 | + +移动只改变 DOM 适配层归属。公开函数签名、stable error/reason、桥接 message、SDK 发送和页面可见行为必须不变。 + +## Testing Library decision + +`@testing-library/dom` 与 `@testing-library/user-event` 是测试依赖,而不是 MAIN-world 自动化引擎。现有代码没有主动操作 OneTalk UI 的业务命令;把 `user-event` 打进生产包既不能产生 trusted event,也会增加无消费者的 bundle 依赖。 + +测试层新增由 `jsdom` 创建和销毁的真实 DOM fixture,以 Testing Library 的 role/name 查询和 `userEvent.setup().click` 覆盖 extension-owned 复制按钮。项目的 Node `>=22.22.2` 基线满足当前 jsdom 的 Node 要求;每个测试必须显式传入 fixture document,并在结束时恢复替换过的全局对象。现有手写 fixture 仍可保留给页面结构异常、MutationObserver 和边界错误的单元测试。未来新增真实页面动作时,另行定义动作契约、失败语义和运行时策略,不能把测试工具直接暴露给 Bridge command。 + +## Compatibility and rollback + +- 入口仍从 `page-script-entry.ts` 安装同名控件,MAIN/ISOLATED 入口和 Manifest 不变。 +- 所有新导入必须保持 MAIN entry 的 IIFE + `inlineDynamicImports` 构建约束;Testing Library 仅被 test 文件导入,不能进入该入口依赖图。 +- 这是可逆目录/依赖重构:若语义回归,恢复原 import 路径和 DOM adapter 文件即可;不得通过并行旧/新实现作运行时 fallback。 diff --git a/.trellis/tasks/09-14-centralize-dom-interactions/implement.jsonl b/.trellis/tasks/09-14-centralize-dom-interactions/implement.jsonl new file mode 100644 index 0000000..a88ac3b --- /dev/null +++ b/.trellis/tasks/09-14-centralize-dom-interactions/implement.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/project/module-organization.md","reason":"确定 OneTalk 专属 DOM 外部适配器的最近共同所有者和职责边界。"} +{"file":".trellis/spec/project/module-ownership.md","reason":"移动类型与导出时保持唯一所有者和 canonical import path。"} +{"file":".trellis/spec/chrome-extension/frontend/architecture.md","reason":"遵守渠道目录、MAIN world 和 Popup 独立入口边界。"} +{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"保留会话复制和状态提示的可见行为与错误契约。"} +{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"保留 bridge identity 刷新和 MAIN IIFE 契约。"} diff --git a/.trellis/tasks/09-14-centralize-dom-interactions/implement.md b/.trellis/tasks/09-14-centralize-dom-interactions/implement.md new file mode 100644 index 0000000..517e4e4 --- /dev/null +++ b/.trellis/tasks/09-14-centralize-dom-interactions/implement.md @@ -0,0 +1,33 @@ +# 实施计划 + +## 1. 建立 DOM 适配目录 + +- 新建 `src/onetalk/main-page/dom/`,按设计中的五类 DOM 责任迁移实现。 +- 在移动前,对每个被改动的导出符号执行 GitNexus upstream impact;风险为 HIGH 或 CRITICAL 时先报告并复核范围。 +- 保持函数名与返回值稳定;只在 `page-context.ts` 和 `image-send.ts` 保留领域编排,改为调用 DOM adapter。 + +## 2. 更新组合与测试导入 + +- 更新 `page-script-entry.ts`、`page-bridge/main.ts`、`page-context.ts`、`image-send.ts` 的内部导入,确保没有残留的非 observer 直接 `document.querySelector*`、元素创建/挂载或 document click 订阅。 +- 更新 `onetalk-action-status-tooltip`、`onetalk-conversation-id-copy`、`onetalk-page-context`、`onetalk-send-page`、`onetalk-page-bridge`、`onetalk-image-send` 和 `onetalk-file-send` 的必要导入与 fixture。 +- 不修改 `main-page/buyer-fact-observer/`、`main-page/contact-observer/` 或其测试。 + +## 3. 引入并使用 Testing Library + +- 在 `apps/chrome-extension/package.json` 的 `devDependencies` 添加 `@testing-library/dom`、`@testing-library/user-event` 和 `jsdom`。 +- 新增最小测试 helper,负责创建/销毁真实 document 和恢复全局状态;不替换所有现有 fixture。 +- 让复制控件测试通过 Testing Library 按 role/name 定位按钮,并用 `userEvent.click` 验证 Clipboard 调用与成功/失败反馈。 + +## 4. 验证与审查 + +- 运行受影响的 focused tests,单次 Node test 命令不超过 60 秒。 +- 运行 `pnpm --filter @trade-message-center/chrome-extension typecheck`、扩展 build 和完整扩展 test。 +- 搜索非 observer 的 MAIN-page source,确认 DOM API 只在 `main-page/dom/` 和明确保留的 Popup 中出现。 +- 执行 GitNexus `detect_changes()`,确认只影响 DOM adapter、调用方、测试和依赖清单;审查没有改变 bridge、发送或观察语义。 + +## Risk points + +- `readSelectedConversationIds` 供 page bridge、买家事实和联系人资料路径调用;只抽取 DOM 读取,不得改其零/多选结果。 +- 复制控件自身使用 MutationObserver 来适配 SPA 重绘,但其文件不是被冻结的 observer 目录,仍随控件迁移。 +- React file uploader 查找依赖宿主内部 fiber,迁移必须保持 null/多候选和发送失败语义。 +- Node 内置 test runner 没有浏览器 DOM;新测试 runtime 必须局部化,不能污染其它测试的全局对象。 diff --git a/.trellis/tasks/09-14-centralize-dom-interactions/prd.md b/.trellis/tasks/09-14-centralize-dom-interactions/prd.md new file mode 100644 index 0000000..cb688cf --- /dev/null +++ b/.trellis/tasks/09-14-centralize-dom-interactions/prd.md @@ -0,0 +1,36 @@ +# 集中非观测 DOM 交互 + +## Goal + +把 OneTalk 页面中非观测用途的直接 DOM 访问收敛到明确的 DOM 边界,并使用 `@testing-library/dom` 与 `@testing-library/user-event` 对该边界的用户交互进行验证。这样新增页面动作不再复制手写 fixture 事件序列,同时不改变现有发送、身份判断或桥接行为。 + +## Confirmed facts + +- OneTalk 页面脚本在 `MAIN` world、`document_start` 注入,并且必须构建为自包含 IIFE。 +- 当前没有 `element.click()`、`dispatchEvent()`、`InputEvent` 或 `user-event`;DOM 使用主要是读取页面状态,或注入扩展自有提示和复制控件。 +- 非 observer 的 OneTalk DOM 触点位于:`action-status-tooltip.ts`、`conversation-id-copy.ts`、`page-context.ts`、`image-send.ts` 和 `page-bridge/main.ts`。 +- `main-page/buyer-fact-observer/` 与 `main-page/contact-observer/`(包括其中的 `MutationObserver` 和 DOM 读取)不在本任务范围内,保持原路径与行为不变。 +- 扩展尚未引入 Testing Library;运行时只有浏览器原生 DOM,测试使用 Node 内置 test runner 和手写页面 fixture。 +- Popup 是扩展自有页面,不纳入本任务;`popup/popup.ts` 保持原路径和行为。 + +## Requirements + +- R1:把本任务范围内 OneTalk 页面 DOM 查询、页面自有控件创建/更新,以及未来动作的公共入口整理到一个专属目录;业务编排、协议解码和页面桥不因目录整理而获得新的 DOM 权限。 +- R2:引入 `@testing-library/dom` 与 `@testing-library/user-event` 作为 DOM 边界的测试工具,并用真实 DOM fixture 验证受控页面按钮的定位和点击。它们不进入生产 MAIN-world bundle,也不被误当成能生成 trusted event 的运行时自动化方案。 +- R3:保留当前的 OneTalk SDK 发送和媒体上传策略;本任务不把发送流程改成 DOM 点击,也不引入 `chrome.debugger`、CDP 或任意 selector/任意脚本的远程执行能力。 +- R4:维持 `channelAccountId + conversationId` 的精确页面身份判断、MAIN/ISOLATED/SW 桥接契约,以及现有页面控件的可见行为。 +- R5:新增依赖作为扩展测试依赖,并提供 Node test runner 可运行的浏览器 DOM 环境;生产 MAIN-world IIFE 不得引入测试运行时、暴露原始 payload、凭据或开放执行面。 + +## Acceptance criteria + +- [x] AC1:所有纳入范围的 OneTalk 页面直接 DOM 访问均由新目录拥有;调用方只通过其语义 API 使用页面 DOM。 +- [x] AC2:`buyer-fact-observer/` 与 `contact-observer/` 的生产代码保持未修改。 +- [x] AC3:至少一项受控页面按钮交互通过 `@testing-library/dom` 和 `@testing-library/user-event` 在真实 DOM fixture 中验证;生产入口不打包这些测试依赖。 +- [x] AC4:会话 ID 复制控件、动作状态提示、当前会话识别、图片/文件上传器定位、桥接 identity 刷新与重构前行为一致。 +- [x] AC5:相关 focused tests、`pnpm --filter @trade-message-center/chrome-extension typecheck` 和扩展构建通过;MAIN-world 入口没有产生 code-splitting 或顶层 import 问题。 + +## Out of scope + +- 被动 DOM 观测:`main-page/buyer-fact-observer/`、`main-page/contact-observer/` 及其 `MutationObserver`。 +- OneTalk 页面实际用户动作的新增业务功能、发送语义、协议、授权、Bridge 合约和服务端改动。 +- Playwright、`chrome.debugger`、CDP 或 Native Messaging 自动化。 diff --git a/.trellis/tasks/09-14-centralize-dom-interactions/task.json b/.trellis/tasks/09-14-centralize-dom-interactions/task.json new file mode 100644 index 0000000..331d810 --- /dev/null +++ b/.trellis/tasks/09-14-centralize-dom-interactions/task.json @@ -0,0 +1,26 @@ +{ + "id": "centralize-dom-interactions", + "name": "centralize-dom-interactions", + "title": "集中非观测 DOM 交互", + "description": "将 Chrome 扩展中 observer 目录以外的页面 DOM 访问收敛到明确边界,并采用 Testing Library。", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "ybf", + "assignee": "ybf", + "createdAt": "2026-09-14", + "completedAt": null, + "branch": "09-14-centralize-dom-interactions", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json index 9999cfd..d4e9626 100644 --- a/apps/chrome-extension/package.json +++ b/apps/chrome-extension/package.json @@ -17,7 +17,10 @@ "@trade-message-center/onetalk-contract": "workspace:*" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/user-event": "^14.6.7", "@types/node": "^22.10.2", + "jsdom": "^30.0.1", "typescript": "^5.7.3", "vite": "^6.0.7" } diff --git a/apps/chrome-extension/src/onetalk/main-page/action-status-tooltip.ts b/apps/chrome-extension/src/onetalk/main-page/action-status-tooltip.ts index 45b1ba0..1454f5d 100644 --- a/apps/chrome-extension/src/onetalk/main-page/action-status-tooltip.ts +++ b/apps/chrome-extension/src/onetalk/main-page/action-status-tooltip.ts @@ -1,192 +1,7 @@ -// 管理 OneTalk 页面进行中动作提示 - -import { isObjectRecord } from "../../lib/guards.ts"; - -export type TooltipColor = "neutral" | "info" | "warning" | "error"; - -export type OneTalkActionStatusTooltip = { - start: (id: string, text: string, color?: TooltipColor) => void; - update: (id: string, text: string, color?: TooltipColor) => boolean; - close: (id: string) => boolean; -}; - -type OneTalkActionStatusTooltipWindow = Pick & { - __tradeMessageCenterOneTalk?: unknown; -}; - -type ActionStatus = { - color: TooltipColor; - row: HTMLDivElement; -}; - -const STATUS_CONTAINER_ATTRIBUTE = "data-tmc-action-status-tooltip"; -const STATUS_ROW_ATTRIBUTE = "data-tmc-action-status-row"; -const STATUS_COLOR_ATTRIBUTE = "data-tmc-action-status-color"; -const DEFAULT_TOOLTIP_COLOR: TooltipColor = "neutral"; -const tooltipRefreshes = new WeakMap void>(); - -const TOOLTIP_COLORS: Readonly< - Record -> = { - neutral: { background: "#f5f5f5", border: "#d9d9d9", text: "#595959" }, - info: { background: "#e6f4ff", border: "#91caff", text: "#1677ff" }, - warning: { background: "#fffbe6", border: "#ffe58f", text: "#d48806" }, - error: { background: "#fff2f0", border: "#ffccc7", text: "#cf1322" }, -}; - -const isTooltipColor = (value: unknown): value is TooltipColor => { - return typeof value === "string" && Object.hasOwn(TOOLTIP_COLORS, value); -}; - -const requireTooltipColor = (value: unknown): TooltipColor => { - if (isTooltipColor(value)) return value; - throw new TypeError("onetalk_action_status_tooltip_color_invalid"); -}; - -const statusRowStyles = (color: TooltipColor): string => { - const theme = TOOLTIP_COLORS[color]; - return [ - "box-sizing: border-box", - "width: 100%", - "padding: 8px 12px", - "border: 1px solid " + theme.border, - "border-radius: 6px", - "background: " + theme.background, - "color: " + theme.text, - "font-size: 13px", - "line-height: 20px", - "font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", - "box-shadow: 0 2px 8px rgb(0 0 0 / 12%)", - ].join(";"); -}; - -const updateStatusRow = (status: ActionStatus, text: string, color: TooltipColor): void => { - status.color = color; - status.row.textContent = text; - status.row.setAttribute(STATUS_COLOR_ATTRIBUTE, color); - status.row.style.cssText = statusRowStyles(color); -}; - -const createStatusRow = ( - document: Document, - id: string, - text: string, - color: TooltipColor, -): ActionStatus => { - const row = document.createElement("div"); - row.setAttribute(STATUS_ROW_ATTRIBUTE, ""); - row.dataset.tmcActionStatusId = id; - const status = { color, row }; - updateStatusRow(status, text, color); - return status; -}; - -const createStatusContainer = (document: Document): HTMLDivElement => { - const container = document.createElement("div"); - container.setAttribute(STATUS_CONTAINER_ATTRIBUTE, ""); - container.setAttribute("aria-live", "polite"); - container.setAttribute("role", "status"); - container.style.cssText = [ - "position: fixed", - "top: 0", - "left: 50%", - "transform: translateX(-50%)", - "z-index: 2147483647", - "display: flex", - "flex-direction: column", - "gap: 8px", - "width: min(600px, calc(100vw - 96px))", - "pointer-events: none", - ].join(";"); - return container; -}; - -const findStatusContainer = (document: Document): HTMLDivElement | null => { - return document.querySelector(`div[${STATUS_CONTAINER_ATTRIBUTE}]`); -}; - -const ensureStatusContainer = ( - document: Document, - statuses: ReadonlyMap, -): HTMLDivElement => { - const existing = findStatusContainer(document); - const container = existing ?? createStatusContainer(document); - if (!existing) { - if (!document.body) - throw new Error("onetalk_action_status_tooltip_document_body_unavailable"); - document.body.append(container); - } - for (const status of statuses.values()) container.append(status.row); - return container; -}; - -const isTooltipFacade = (value: unknown): value is OneTalkActionStatusTooltip => { - return ( - isObjectRecord(value) && - typeof value.start === "function" && - typeof value.update === "function" && - typeof value.close === "function" - ); -}; - -const createTooltipFacade = ( - pageWindow: OneTalkActionStatusTooltipWindow, -): OneTalkActionStatusTooltip => { - const statuses = new Map(); - const refresh = (): void => { - if (statuses.size > 0) ensureStatusContainer(pageWindow.document, statuses); - }; - tooltipRefreshes.set(pageWindow, refresh); - const start = (id: string, text: string, color: TooltipColor = DEFAULT_TOOLTIP_COLOR): void => { - if (statuses.has(id)) { - refresh(); - return; - } - const resolvedColor = requireTooltipColor(color); - const status = createStatusRow(pageWindow.document, id, text, resolvedColor); - statuses.set(id, status); - const container = ensureStatusContainer(pageWindow.document, statuses); - if (status.row.parentElement !== container) container.append(status.row); - }; - const update = (id: string, text: string, color?: TooltipColor): boolean => { - const status = statuses.get(id); - if (!status) return false; - const resolvedColor = color === undefined ? status.color : requireTooltipColor(color); - updateStatusRow(status, text, resolvedColor); - ensureStatusContainer(pageWindow.document, statuses); - return true; - }; - const close = (id: string): boolean => { - const status = statuses.get(id); - if (!status) return false; - statuses.delete(id); - status.row.remove(); - if (statuses.size === 0) findStatusContainer(pageWindow.document)?.remove(); - return true; - }; - return { close, start, update }; -}; - -/** 读取已安装的页面动作提示 facade。 */ -export const readOneTalkActionStatusTooltip = ( - pageWindow: OneTalkActionStatusTooltipWindow, -): OneTalkActionStatusTooltip | null => { - const namespace = pageWindow.__tradeMessageCenterOneTalk; - return isObjectRecord(namespace) && isTooltipFacade(namespace.tooltip) - ? namespace.tooltip - : null; -}; - -/** 将进行中动作提示注册到 OneTalk 页面命名空间。 */ -export const installOneTalkActionStatusTooltip = ( - pageWindow: OneTalkActionStatusTooltipWindow, -): void => { - const existing = pageWindow.__tradeMessageCenterOneTalk; - const namespace: Record = isObjectRecord(existing) ? existing : {}; - if (isTooltipFacade(namespace.tooltip)) { - tooltipRefreshes.get(pageWindow)?.(); - return; - } - namespace.tooltip = createTooltipFacade(pageWindow); - pageWindow.__tradeMessageCenterOneTalk = namespace; -}; +// 兼容现有控件消费者;DOM 实现在专属适配目录中。 +export { + installOneTalkActionStatusTooltip, + readOneTalkActionStatusTooltip, + type OneTalkActionStatusTooltip, + type TooltipColor, +} from "./dom/action-status-tooltip.ts"; diff --git a/apps/chrome-extension/src/onetalk/main-page/conversation-id-copy.ts b/apps/chrome-extension/src/onetalk/main-page/conversation-id-copy.ts index 4c7c79f..a04a145 100644 --- a/apps/chrome-extension/src/onetalk/main-page/conversation-id-copy.ts +++ b/apps/chrome-extension/src/onetalk/main-page/conversation-id-copy.ts @@ -1,147 +1,2 @@ -// 在当前会话标题旁提供复制会话 ID 控件 - -import { readCurrentConversationId } from "./page-context.ts"; -import type { OneTalkPageWindow } from "./model.ts"; - -type OneTalkConversationIdCopyWindow = OneTalkPageWindow & - Pick & { - MutationObserver?: typeof MutationObserver; - }; - -const COPY_BUTTON_ATTRIBUTE = "data-tmc-conversation-id-copy"; -const HEADER_MAX_BOTTOM_PX = 72; -const BUTTON_LABEL = "复制会话 Id"; -const COPIED_LABEL = "已复制"; -const COPY_FAILED_LABEL = "复制失败"; -const BUTTON_RESTORE_DELAY_MS = 1_500; - -const selectedConversationElement = (document: Document): Element | null => { - const selected = document.querySelectorAll(".contact-item-container.selected[data-cid]"); - return selected.length === 1 ? selected[0] : null; -}; - -const headerTitleElement = (document: Document): Element | null => { - const selected = selectedConversationElement(document); - if (!selected) return null; - const selectedRect = selected.getBoundingClientRect(); - const maxLeft = document.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY; - const candidates = Array.from(document.querySelectorAll("body *")).filter((element) => { - if (element.children.length > 0 || !element.textContent?.trim()) return false; - const rect = element.getBoundingClientRect(); - return ( - rect.width > 0 && - rect.height > 0 && - rect.top >= 0 && - rect.bottom <= HEADER_MAX_BOTTOM_PX && - rect.left >= selectedRect.right && - rect.left < maxLeft * 0.75 - ); - }); - return ( - candidates.sort( - (left, right) => - left.getBoundingClientRect().left - right.getBoundingClientRect().left || - left.getBoundingClientRect().top - right.getBoundingClientRect().top, - )[0] ?? null - ); -}; - -const updateButtonLabel = ( - pageWindow: OneTalkConversationIdCopyWindow, - button: HTMLButtonElement, - label: string, -): void => { - button.textContent = label; - button.setAttribute("aria-label", `${label}会话 Id`); - pageWindow.clearTimeout(Number(button.dataset.restoreTimer)); - button.dataset.restoreTimer = String( - pageWindow.setTimeout(() => { - button.textContent = BUTTON_LABEL; - button.setAttribute("aria-label", BUTTON_LABEL); - }, BUTTON_RESTORE_DELAY_MS), - ); -}; - -const createCopyButton = ( - pageWindow: OneTalkConversationIdCopyWindow, - conversationId: string, -): HTMLButtonElement => { - const button = pageWindow.document.createElement("button"); - button.type = "button"; - button.setAttribute(COPY_BUTTON_ATTRIBUTE, ""); - button.setAttribute("aria-label", BUTTON_LABEL); - button.title = BUTTON_LABEL; - button.textContent = BUTTON_LABEL; - button.dataset.conversationId = conversationId; - button.style.cssText = [ - "margin-left: 8px", - "padding: 2px 7px", - "display: inline-flex", - "align-items: center", - "justify-content: center", - "border: 1px solid #d8dce5", - "border-radius: 4px", - "background: #fff", - "color: #315efb", - "font-size: 12px", - "line-height: 18px", - "text-align: center", - "cursor: pointer", - "vertical-align: middle", - ].join(";"); - button.addEventListener("click", () => { - const currentId = button.dataset.conversationId; - if (!currentId) return; - const clipboard = pageWindow.navigator.clipboard; - if (!clipboard) { - updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL); - return; - } - void clipboard - .writeText(currentId) - .then(() => updateButtonLabel(pageWindow, button, COPIED_LABEL)) - .catch(() => updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL)); - }); - return button; -}; - -const updateCopyControl = (pageWindow: OneTalkConversationIdCopyWindow): void => { - const existing = pageWindow.document.querySelector( - `button[${COPY_BUTTON_ATTRIBUTE}]`, - ); - const conversationId = readCurrentConversationId(pageWindow); - if (!conversationId) { - existing?.remove(); - return; - } - if (existing) { - existing.dataset.conversationId = conversationId; - return; - } - const title = headerTitleElement(pageWindow.document); - if (!title) return; - title.append(createCopyButton(pageWindow, conversationId)); -}; - -/** 将当前会话 ID 的复制控件安装到 OneTalk 页面标题旁。 */ -export const installOneTalkConversationIdCopyControl = ( - pageWindow: OneTalkConversationIdCopyWindow, -): void => { - let refreshScheduled = false; - const refresh = (): void => { - refreshScheduled = false; - updateCopyControl(pageWindow); - }; - const scheduleRefresh = (): void => { - if (refreshScheduled) return; - refreshScheduled = true; - pageWindow.setTimeout(refresh, 0); - }; - const observer = pageWindow.MutationObserver - ? new pageWindow.MutationObserver(scheduleRefresh) - : null; - if (observer && pageWindow.document.documentElement) { - observer.observe(pageWindow.document.documentElement, { childList: true, subtree: true }); - } - refresh(); -}; +// 兼容现有控件消费者;DOM 实现在专属适配目录中。 +export { installOneTalkConversationIdCopyControl } from "./dom/conversation-id-copy.ts"; diff --git a/apps/chrome-extension/src/onetalk/main-page/dom/action-status-tooltip.ts b/apps/chrome-extension/src/onetalk/main-page/dom/action-status-tooltip.ts new file mode 100644 index 0000000..c8b842e --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/dom/action-status-tooltip.ts @@ -0,0 +1,192 @@ +// 管理 OneTalk 页面进行中动作提示的 DOM 边界。 + +import { isObjectRecord } from "../../../lib/guards.ts"; + +export type TooltipColor = "neutral" | "info" | "warning" | "error"; + +export type OneTalkActionStatusTooltip = { + start: (id: string, text: string, color?: TooltipColor) => void; + update: (id: string, text: string, color?: TooltipColor) => boolean; + close: (id: string) => boolean; +}; + +type OneTalkActionStatusTooltipWindow = Pick & { + __tradeMessageCenterOneTalk?: unknown; +}; + +type ActionStatus = { + color: TooltipColor; + row: HTMLDivElement; +}; + +const STATUS_CONTAINER_ATTRIBUTE = "data-tmc-action-status-tooltip"; +const STATUS_ROW_ATTRIBUTE = "data-tmc-action-status-row"; +const STATUS_COLOR_ATTRIBUTE = "data-tmc-action-status-color"; +const DEFAULT_TOOLTIP_COLOR: TooltipColor = "neutral"; +const tooltipRefreshes = new WeakMap void>(); + +const TOOLTIP_COLORS: Readonly< + Record +> = { + neutral: { background: "#f5f5f5", border: "#d9d9d9", text: "#595959" }, + info: { background: "#e6f4ff", border: "#91caff", text: "#1677ff" }, + warning: { background: "#fffbe6", border: "#ffe58f", text: "#d48806" }, + error: { background: "#fff2f0", border: "#ffccc7", text: "#cf1322" }, +}; + +const isTooltipColor = (value: unknown): value is TooltipColor => { + return typeof value === "string" && Object.hasOwn(TOOLTIP_COLORS, value); +}; + +const requireTooltipColor = (value: unknown): TooltipColor => { + if (isTooltipColor(value)) return value; + throw new TypeError("onetalk_action_status_tooltip_color_invalid"); +}; + +const statusRowStyles = (color: TooltipColor): string => { + const theme = TOOLTIP_COLORS[color]; + return [ + "box-sizing: border-box", + "width: 100%", + "padding: 8px 12px", + "border: 1px solid " + theme.border, + "border-radius: 6px", + "background: " + theme.background, + "color: " + theme.text, + "font-size: 13px", + "line-height: 20px", + "font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + "box-shadow: 0 2px 8px rgb(0 0 0 / 12%)", + ].join(";"); +}; + +const updateStatusRow = (status: ActionStatus, text: string, color: TooltipColor): void => { + status.color = color; + status.row.textContent = text; + status.row.setAttribute(STATUS_COLOR_ATTRIBUTE, color); + status.row.style.cssText = statusRowStyles(color); +}; + +const createStatusRow = ( + document: Document, + id: string, + text: string, + color: TooltipColor, +): ActionStatus => { + const row = document.createElement("div"); + row.setAttribute(STATUS_ROW_ATTRIBUTE, ""); + row.dataset.tmcActionStatusId = id; + const status = { color, row }; + updateStatusRow(status, text, color); + return status; +}; + +const createStatusContainer = (document: Document): HTMLDivElement => { + const container = document.createElement("div"); + container.setAttribute(STATUS_CONTAINER_ATTRIBUTE, ""); + container.setAttribute("aria-live", "polite"); + container.setAttribute("role", "status"); + container.style.cssText = [ + "position: fixed", + "top: 0", + "left: 50%", + "transform: translateX(-50%)", + "z-index: 2147483647", + "display: flex", + "flex-direction: column", + "gap: 8px", + "width: min(600px, calc(100vw - 96px))", + "pointer-events: none", + ].join(";"); + return container; +}; + +const findStatusContainer = (document: Document): HTMLDivElement | null => { + return document.querySelector(`div[${STATUS_CONTAINER_ATTRIBUTE}]`); +}; + +const ensureStatusContainer = ( + document: Document, + statuses: ReadonlyMap, +): HTMLDivElement => { + const existing = findStatusContainer(document); + const container = existing ?? createStatusContainer(document); + if (!existing) { + if (!document.body) + throw new Error("onetalk_action_status_tooltip_document_body_unavailable"); + document.body.append(container); + } + for (const status of statuses.values()) container.append(status.row); + return container; +}; + +const isTooltipFacade = (value: unknown): value is OneTalkActionStatusTooltip => { + return ( + isObjectRecord(value) && + typeof value.start === "function" && + typeof value.update === "function" && + typeof value.close === "function" + ); +}; + +const createTooltipFacade = ( + pageWindow: OneTalkActionStatusTooltipWindow, +): OneTalkActionStatusTooltip => { + const statuses = new Map(); + const refresh = (): void => { + if (statuses.size > 0) ensureStatusContainer(pageWindow.document, statuses); + }; + tooltipRefreshes.set(pageWindow, refresh); + const start = (id: string, text: string, color: TooltipColor = DEFAULT_TOOLTIP_COLOR): void => { + if (statuses.has(id)) { + refresh(); + return; + } + const resolvedColor = requireTooltipColor(color); + const status = createStatusRow(pageWindow.document, id, text, resolvedColor); + statuses.set(id, status); + const container = ensureStatusContainer(pageWindow.document, statuses); + if (status.row.parentElement !== container) container.append(status.row); + }; + const update = (id: string, text: string, color?: TooltipColor): boolean => { + const status = statuses.get(id); + if (!status) return false; + const resolvedColor = color === undefined ? status.color : requireTooltipColor(color); + updateStatusRow(status, text, resolvedColor); + ensureStatusContainer(pageWindow.document, statuses); + return true; + }; + const close = (id: string): boolean => { + const status = statuses.get(id); + if (!status) return false; + statuses.delete(id); + status.row.remove(); + if (statuses.size === 0) findStatusContainer(pageWindow.document)?.remove(); + return true; + }; + return { close, start, update }; +}; + +/** 读取已安装的页面动作提示 facade。 */ +export const readOneTalkActionStatusTooltip = ( + pageWindow: OneTalkActionStatusTooltipWindow, +): OneTalkActionStatusTooltip | null => { + const namespace = pageWindow.__tradeMessageCenterOneTalk; + return isObjectRecord(namespace) && isTooltipFacade(namespace.tooltip) + ? namespace.tooltip + : null; +}; + +/** 将进行中动作提示注册到 OneTalk 页面命名空间。 */ +export const installOneTalkActionStatusTooltip = ( + pageWindow: OneTalkActionStatusTooltipWindow, +): void => { + const existing = pageWindow.__tradeMessageCenterOneTalk; + const namespace: Record = isObjectRecord(existing) ? existing : {}; + if (isTooltipFacade(namespace.tooltip)) { + tooltipRefreshes.get(pageWindow)?.(); + return; + } + namespace.tooltip = createTooltipFacade(pageWindow); + pageWindow.__tradeMessageCenterOneTalk = namespace; +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/dom/conversation-id-copy.ts b/apps/chrome-extension/src/onetalk/main-page/dom/conversation-id-copy.ts new file mode 100644 index 0000000..d8b5474 --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/dom/conversation-id-copy.ts @@ -0,0 +1,147 @@ +// 安装 OneTalk 会话 ID 复制控件。 + +import { readCurrentConversationId } from "../page-context.ts"; +import type { OneTalkPageWindow } from "../model.ts"; + +type OneTalkConversationIdCopyWindow = OneTalkPageWindow & + Pick & { + MutationObserver?: typeof MutationObserver; + }; + +const COPY_BUTTON_ATTRIBUTE = "data-tmc-conversation-id-copy"; +const HEADER_MAX_BOTTOM_PX = 72; +const BUTTON_LABEL = "复制会话 Id"; +const COPIED_LABEL = "已复制"; +const COPY_FAILED_LABEL = "复制失败"; +const BUTTON_RESTORE_DELAY_MS = 1_500; + +const selectedConversationElement = (document: Document): Element | null => { + const selected = document.querySelectorAll(".contact-item-container.selected[data-cid]"); + return selected.length === 1 ? selected[0] : null; +}; + +const headerTitleElement = (document: Document): Element | null => { + const selected = selectedConversationElement(document); + if (!selected) return null; + const selectedRect = selected.getBoundingClientRect(); + const maxLeft = document.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY; + const candidates = Array.from(document.querySelectorAll("body *")).filter((element) => { + if (element.children.length > 0 || !element.textContent?.trim()) return false; + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0 && + rect.height > 0 && + rect.top >= 0 && + rect.bottom <= HEADER_MAX_BOTTOM_PX && + rect.left >= selectedRect.right && + rect.left < maxLeft * 0.75 + ); + }); + return ( + candidates.sort( + (left, right) => + left.getBoundingClientRect().left - right.getBoundingClientRect().left || + left.getBoundingClientRect().top - right.getBoundingClientRect().top, + )[0] ?? null + ); +}; + +const updateButtonLabel = ( + pageWindow: OneTalkConversationIdCopyWindow, + button: HTMLButtonElement, + label: string, +): void => { + button.textContent = label; + button.setAttribute("aria-label", `${label}会话 Id`); + pageWindow.clearTimeout(Number(button.dataset.restoreTimer)); + button.dataset.restoreTimer = String( + pageWindow.setTimeout(() => { + button.textContent = BUTTON_LABEL; + button.setAttribute("aria-label", BUTTON_LABEL); + }, BUTTON_RESTORE_DELAY_MS), + ); +}; + +const createCopyButton = ( + pageWindow: OneTalkConversationIdCopyWindow, + conversationId: string, +): HTMLButtonElement => { + const button = pageWindow.document.createElement("button"); + button.type = "button"; + button.setAttribute(COPY_BUTTON_ATTRIBUTE, ""); + button.setAttribute("aria-label", BUTTON_LABEL); + button.title = BUTTON_LABEL; + button.textContent = BUTTON_LABEL; + button.dataset.conversationId = conversationId; + button.style.cssText = [ + "margin-left: 8px", + "padding: 2px 7px", + "display: inline-flex", + "align-items: center", + "justify-content: center", + "border: 1px solid #d8dce5", + "border-radius: 4px", + "background: #fff", + "color: #315efb", + "font-size: 12px", + "line-height: 18px", + "text-align: center", + "cursor: pointer", + "vertical-align: middle", + ].join(";"); + button.addEventListener("click", () => { + const currentId = button.dataset.conversationId; + if (!currentId) return; + const clipboard = pageWindow.navigator.clipboard; + if (!clipboard) { + updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL); + return; + } + void clipboard + .writeText(currentId) + .then(() => updateButtonLabel(pageWindow, button, COPIED_LABEL)) + .catch(() => updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL)); + }); + return button; +}; + +const updateCopyControl = (pageWindow: OneTalkConversationIdCopyWindow): void => { + const existing = pageWindow.document.querySelector( + `button[${COPY_BUTTON_ATTRIBUTE}]`, + ); + const conversationId = readCurrentConversationId(pageWindow); + if (!conversationId) { + existing?.remove(); + return; + } + if (existing) { + existing.dataset.conversationId = conversationId; + return; + } + const title = headerTitleElement(pageWindow.document); + if (!title) return; + title.append(createCopyButton(pageWindow, conversationId)); +}; + +/** 将当前会话 ID 的复制控件安装到 OneTalk 页面标题旁。 */ +export const installOneTalkConversationIdCopyControl = ( + pageWindow: OneTalkConversationIdCopyWindow, +): void => { + let refreshScheduled = false; + const refresh = (): void => { + refreshScheduled = false; + updateCopyControl(pageWindow); + }; + const scheduleRefresh = (): void => { + if (refreshScheduled) return; + refreshScheduled = true; + pageWindow.setTimeout(refresh, 0); + }; + const observer = pageWindow.MutationObserver + ? new pageWindow.MutationObserver(scheduleRefresh) + : null; + if (observer && pageWindow.document.documentElement) { + observer.observe(pageWindow.document.documentElement, { childList: true, subtree: true }); + } + refresh(); +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/dom/react-file-uploader.ts b/apps/chrome-extension/src/onetalk/main-page/dom/react-file-uploader.ts new file mode 100644 index 0000000..5f70f88 --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/dom/react-file-uploader.ts @@ -0,0 +1,53 @@ +// 定位 OneTalk React 文件上传器。 + +import { isObjectRecord } from "../../../lib/guards.ts"; + +import type { OneTalkPageWindow } from "../model.ts"; + +export type OneTalkImageUploader = { + owner: Record; + sendFileToOss: (input: Record) => unknown; + sendFile: (...args: unknown[]) => unknown; +}; + +/** 查找页面 React file input 对应的上传器。 */ +export const findReactFileUploader = ( + pageWindow: OneTalkPageWindow, +): OneTalkImageUploader | null => { + const inputs = Array.from(pageWindow.document?.querySelectorAll("input[type=file]") ?? []); + for (const input of inputs) { + for ( + let node: (Element & { parentElement: Element | null }) | null = input; + node; + node = node.parentElement + ) { + const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber")); + const fiber = fiberKey ? (node as unknown as Record)[fiberKey] : null; + for ( + let current: { stateNode?: unknown; return?: unknown } | null = isObjectRecord( + fiber, + ) + ? fiber + : null; + current; + current = isObjectRecord(current.return) ? current.return : null + ) { + const state = current.stateNode; + if ( + isObjectRecord(state) && + typeof state.sendFileToOss === "function" && + typeof state.sendFile === "function" + ) { + return { + owner: state, + sendFileToOss: state.sendFileToOss as ( + input: Record, + ) => unknown, + sendFile: state.sendFile as (...args: unknown[]) => unknown, + }; + } + } + } + } + return null; +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/dom/selection-events.ts b/apps/chrome-extension/src/onetalk/main-page/dom/selection-events.ts new file mode 100644 index 0000000..4be60ac --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/dom/selection-events.ts @@ -0,0 +1,9 @@ +// 订阅 OneTalk 当前会话选择点击。 + +/** 订阅当前页面的点击事件以刷新会话身份。 */ +export const subscribeToDocumentClicks = ( + document: Pick | undefined, + onClick: () => void, +): void => { + document?.addEventListener("click", onClick); +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/dom/selection.ts b/apps/chrome-extension/src/onetalk/main-page/dom/selection.ts new file mode 100644 index 0000000..76275ec --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/dom/selection.ts @@ -0,0 +1,17 @@ +// 读取 OneTalk SPA 当前选中会话的 DOM 边界。 + +import type { OneTalkPageWindow } from "../model.ts"; + +/** 读取页面中当前选中会话的非空 ID 列表。 */ +export const readSelectedConversationIdsFromDom = (pageWindow: OneTalkPageWindow): string[] => { + if (!pageWindow.document) return []; + try { + return Array.from( + pageWindow.document.querySelectorAll(".contact-item-container.selected[data-cid]"), + ) + .map((element) => element.getAttribute("data-cid")?.trim() ?? "") + .filter((value) => value.length > 0); + } catch { + return []; + } +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/image-send.ts b/apps/chrome-extension/src/onetalk/main-page/image-send.ts index dde2984..f46526a 100644 --- a/apps/chrome-extension/src/onetalk/main-page/image-send.ts +++ b/apps/chrome-extension/src/onetalk/main-page/image-send.ts @@ -5,6 +5,7 @@ import type { OneTalkOutboundContent } from "@trade-message-center/onetalk-contr import { isObjectRecord } from "../../lib/guards.ts"; import { traceOneTalkImageSend } from "../diagnostics/image-send-trace.ts"; import type { PageCommandResult } from "../page-bridge/model.ts"; +import { findReactFileUploader, type OneTalkImageUploader } from "./dom/react-file-uploader.ts"; import { readChannelAccountId } from "./page-context.ts"; import type { OneTalkPageWindow } from "./model.ts"; import { @@ -17,11 +18,7 @@ type MediaSendObservationCorrelator = Pick< "executeImage" | "executeFile" >; -export type OneTalkImageUploader = { - owner: Record; - sendFileToOss: (input: Record) => unknown; - sendFile: (...args: unknown[]) => unknown; -}; +export type { OneTalkImageUploader } from "./dom/react-file-uploader.ts"; type FinalImageMetadata = { sizeBytes: number; @@ -149,45 +146,6 @@ const isCurrentImageTarget = ( ); }; -const uploaderFromReactFileInput = (pageWindow: OneTalkPageWindow): OneTalkImageUploader | null => { - const inputs = Array.from(pageWindow.document?.querySelectorAll("input[type=file]") ?? []); - for (const input of inputs) { - for ( - let node: (Element & { parentElement: Element | null }) | null = input; - node; - node = node.parentElement - ) { - const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber")); - const fiber = fiberKey ? (node as unknown as Record)[fiberKey] : null; - for ( - let current: { stateNode?: unknown; return?: unknown } | null = isObjectRecord( - fiber, - ) - ? fiber - : null; - current; - current = isObjectRecord(current.return) ? current.return : null - ) { - const state = current.stateNode; - if ( - isObjectRecord(state) && - typeof state.sendFileToOss === "function" && - typeof state.sendFile === "function" - ) { - return { - owner: state, - sendFileToOss: state.sendFileToOss as ( - input: Record, - ) => unknown, - sendFile: state.sendFile as (...args: unknown[]) => unknown, - }; - } - } - } - } - return null; -}; - const metadataFrom = (value: unknown): FinalImageMetadata | null => { if (!isObjectRecord(value)) return null; const sizeBytes = value.nodeSize ?? value.size; @@ -415,7 +373,7 @@ const sendOneTalkMedia = async (input: { input.runtime?.cancel ?? ((timer: ImageSendTimer) => globalThis.clearTimeout(timer)), }; const context = imageTargetContext(input.pageWindow, input.conversationId); - const uploader = (input.findUploader ?? uploaderFromReactFileInput)(input.pageWindow); + const uploader = (input.findUploader ?? findReactFileUploader)(input.pageWindow); if (!context) { traceOneTalkImageSend(input.requestId, { stage: "target_context_resolved", diff --git a/apps/chrome-extension/src/onetalk/main-page/page-context.ts b/apps/chrome-extension/src/onetalk/main-page/page-context.ts index c7c8535..22f09e1 100644 --- a/apps/chrome-extension/src/onetalk/main-page/page-context.ts +++ b/apps/chrome-extension/src/onetalk/main-page/page-context.ts @@ -2,6 +2,7 @@ import { isObjectRecord } from "../../lib/guards.ts"; +import { readSelectedConversationIdsFromDom } from "./dom/selection.ts"; import type { OneTalkPageWindow } from "./model.ts"; export type OneTalkConversationSelection = @@ -143,14 +144,5 @@ export const readConversationSelection = ( }; export const readSelectedConversationIds = (pageWindow: OneTalkPageWindow): string[] => { - if (!pageWindow.document) return []; - try { - return Array.from( - pageWindow.document.querySelectorAll(".contact-item-container.selected[data-cid]"), - ) - .map((element) => element.getAttribute("data-cid")?.trim() ?? "") - .filter((value) => value.length > 0); - } catch { - return []; - } + return readSelectedConversationIdsFromDom(pageWindow); }; diff --git a/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts b/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts index 08819b7..99f488e 100644 --- a/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts +++ b/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts @@ -1,10 +1,10 @@ // 启动 OneTalk 页面监听与当前会话同步能力 -import { installOneTalkConversationIdCopyControl } from "./conversation-id-copy.ts"; +import { installOneTalkConversationIdCopyControl } from "./dom/conversation-id-copy.ts"; import { installOneTalkActionStatusTooltip, readOneTalkActionStatusTooltip, -} from "./action-status-tooltip.ts"; +} from "./dom/action-status-tooltip.ts"; import { ConnectionStatusTooltip } from "./connection-status-tooltip.ts"; import { HistoryBootstrapProgressTooltip } from "./current-conversation-history/bootstrap-progress-tooltip.ts"; import { createSendObservationCorrelator } from "./message-observer/send-observation.ts"; diff --git a/apps/chrome-extension/src/onetalk/page-bridge/main.ts b/apps/chrome-extension/src/onetalk/page-bridge/main.ts index c87d7d4..056c390 100644 --- a/apps/chrome-extension/src/onetalk/page-bridge/main.ts +++ b/apps/chrome-extension/src/onetalk/page-bridge/main.ts @@ -1,6 +1,8 @@ // 连接 OneTalk MAIN 页面与可信桥接契约 import { readChannelAccountId, readConversationSelection } from "../main-page/page-context.ts"; +import { subscribeToDocumentClicks } from "../main-page/dom/selection-events.ts"; +import type { OneTalkPageWindow } from "../main-page/model.ts"; import { isPlainRecord } from "@trade-message-center/onetalk-contract"; import type { HistoryPageProgress } from "../main-page/current-conversation-history/model.ts"; import type { OneTalkObservedMessageSink } from "../main-page/message-observer/model.ts"; @@ -44,6 +46,8 @@ export type OneTalkPageHelloRetryOptions = { maxAttempts?: number; }; +type OneTalkMainPageBridgeWindow = OneTalkPageBridgeWindow & OneTalkPageWindow; + const PAGE_HELLO_RETRY_DELAY_MS = 250; const PAGE_HELLO_MAX_ATTEMPTS = 40; @@ -73,7 +77,7 @@ const postPageMessage = ( } }; -const postPageHello = (pageWindow: OneTalkPageBridgeWindow, origin: string): boolean => { +const postPageHello = (pageWindow: OneTalkMainPageBridgeWindow, origin: string): boolean => { const channelAccountId = readChannelAccountId(pageWindow); if (!channelAccountId) return false; const selection = readConversationSelection(pageWindow); @@ -244,7 +248,7 @@ export const createOneTalkPageBuyerFactsObservedSink = ( /** 安装 MAIN 页面注册与可注入命令消费者。 */ export const installOneTalkMainPageBridge = ( - pageWindow: OneTalkPageBridgeWindow, + pageWindow: OneTalkMainPageBridgeWindow, onCommand?: OneTalkPageCommandHandler, retryOptions: OneTalkPageHelloRetryOptions = {}, onConnectionStatus?: OneTalkPageConnectionStatusHandler, @@ -299,7 +303,7 @@ export const installOneTalkMainPageBridge = ( }; pageWindow.addEventListener("popstate", refreshIdentity); pageWindow.addEventListener("hashchange", refreshIdentity); - pageWindow.document?.addEventListener("click", refreshIdentity); + subscribeToDocumentClicks(pageWindow.document, refreshIdentity); attemptHello(); } catch { // Bridge setup failures must not affect OneTalk page execution. diff --git a/apps/chrome-extension/src/onetalk/page-bridge/model.ts b/apps/chrome-extension/src/onetalk/page-bridge/model.ts index c83b617..638292d 100644 --- a/apps/chrome-extension/src/onetalk/page-bridge/model.ts +++ b/apps/chrome-extension/src/onetalk/page-bridge/model.ts @@ -123,7 +123,7 @@ export type OneTalkPageMessage = export type OneTalkPageBridgeWindow = { location: Pick; - document?: Pick; + document?: Pick; postMessage(message: OneTalkPageMessage, targetOrigin: string): void; addEventListener(type: "message", listener: (event: MessageEvent) => void): void; addEventListener(type: "pagehide" | "popstate" | "hashchange", listener: () => void): void; diff --git a/apps/chrome-extension/test/dom-fixture.js b/apps/chrome-extension/test/dom-fixture.js new file mode 100644 index 0000000..2831194 --- /dev/null +++ b/apps/chrome-extension/test/dom-fixture.js @@ -0,0 +1,15 @@ +// 创建可隔离的 OneTalk 页面 DOM 测试夹具。 + +import { JSDOM } from "jsdom"; + +/** 创建并返回带清理回调的真实 DOM 测试夹具。 */ +export const createDomFixture = (html = "") => { + const dom = new JSDOM(`${html}`, { + url: "https://onetalk.alibaba.com/message/default.htm", + }); + return { + document: dom.window.document, + window: dom.window, + cleanup: () => dom.window.close(), + }; +}; diff --git a/apps/chrome-extension/test/onetalk-action-status-tooltip.test.js b/apps/chrome-extension/test/onetalk-action-status-tooltip.test.js index cf3fefc..263f172 100644 --- a/apps/chrome-extension/test/onetalk-action-status-tooltip.test.js +++ b/apps/chrome-extension/test/onetalk-action-status-tooltip.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { installOneTalkActionStatusTooltip } from "../src/onetalk/main-page/action-status-tooltip.ts"; +import { installOneTalkActionStatusTooltip } from "../src/onetalk/main-page/dom/action-status-tooltip.ts"; import { ConnectionStatusTooltip } from "../src/onetalk/main-page/connection-status-tooltip.ts"; import { HistoryBootstrapProgressTooltip } from "../src/onetalk/main-page/current-conversation-history/bootstrap-progress-tooltip.ts"; diff --git a/apps/chrome-extension/test/onetalk-conversation-id-copy.test.js b/apps/chrome-extension/test/onetalk-conversation-id-copy.test.js index 85cbaae..2bb8ea0 100644 --- a/apps/chrome-extension/test/onetalk-conversation-id-copy.test.js +++ b/apps/chrome-extension/test/onetalk-conversation-id-copy.test.js @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { installOneTalkConversationIdCopyControl } from "../src/onetalk/main-page/conversation-id-copy.ts"; + +import { getByRole } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; + +import { installOneTalkConversationIdCopyControl } from "../src/onetalk/main-page/dom/conversation-id-copy.ts"; +import { createDomFixture } from "./dom-fixture.js"; const rect = (left, top, width, height) => ({ left, @@ -11,126 +16,73 @@ const rect = (left, top, width, height) => ({ bottom: top + height, }); -class FakeElement { - constructor({ conversationId, elementRect, textContent = "" } = {}) { - this.conversationId = conversationId; - this.elementRect = elementRect ?? rect(0, 0, 0, 0); - this.textContent = textContent; - this.children = []; - this.dataset = {}; - this.listeners = new Map(); - this.style = { cssText: "" }; - this.attributes = new Map(); - } - - getAttribute(name) { - return name === "data-cid" - ? (this.conversationId ?? null) - : (this.attributes.get(name) ?? null); - } - - setAttribute(name, value) { - this.attributes.set(name, value); - } - - getBoundingClientRect() { - return this.elementRect; - } - - addEventListener(type, listener) { - this.listeners.set(type, listener); - } - - append(element) { - this.inserted = element; - element.isConnected = true; - } - - remove() { - this.isConnected = false; - } -} - const createPage = () => { - const selected = new FakeElement({ - conversationId: "conversation-1", - elementRect: rect(80, 80, 340, 60), - }); - const headerTitle = new FakeElement({ - elementRect: rect(436, 16, 112, 24), - textContent: "Elliot Izzard", - }); - const navigationTitle = new FakeElement({ - elementRect: rect(1_500, 16, 160, 24), - textContent: "OKKI销售助手", - }); - const document = { - documentElement: {}, - defaultView: { innerWidth: 2_048 }, - button: null, - querySelectorAll(selector) { - if (selector === ".contact-item-container.selected[data-cid]") return [selected]; - if (selector === "body *") return [headerTitle, navigationTitle]; - return []; - }, - querySelector(selector) { - return selector === "button[data-tmc-conversation-id-copy]" ? this.button : null; - }, - createElement() { - const button = new FakeElement(); - button.type = ""; - Object.defineProperty(button, "isConnected", { value: false, writable: true }); - return button; - }, - }; + const fixture = createDomFixture(` +
+ Elliot Izzard + OKKI销售助手 + `); + const selected = fixture.document.querySelector(".contact-item-container"); + const headerTitle = fixture.document.querySelector("[data-testid=conversation-title]"); + const navigationTitle = fixture.document.querySelector("span:last-child"); + selected.getBoundingClientRect = () => rect(80, 80, 340, 60); + headerTitle.getBoundingClientRect = () => rect(436, 16, 112, 24); + navigationTitle.getBoundingClientRect = () => rect(1_500, 16, 160, 24); + const clipboardWrites = []; + const navigator = { clipboard: { writeText: async (value) => clipboardWrites.push(value) } }; const pageWindow = { - document, - location: { href: "https://onetalk.alibaba.com/message/default.htm" }, - navigator: { clipboard: { writeText: async (value) => clipboardWrites.push(value) } }, - setTimeout: () => 1, - clearTimeout: () => {}, + document: fixture.document, + location: fixture.window.location, + navigator, + setTimeout: fixture.window.setTimeout.bind(fixture.window), + clearTimeout: fixture.window.clearTimeout.bind(fixture.window), }; - return { clipboardWrites, document, headerTitle, pageWindow, selected }; + return { ...fixture, clipboardWrites, pageWindow }; }; -test("installs a copy control beside the current conversation title", async () => { - const { clipboardWrites, document, headerTitle, pageWindow } = createPage(); +test("installs a copy control beside the current conversation title and copies through user-event", async (t) => { + const { cleanup, clipboardWrites, document, pageWindow } = createPage(); + t.after(cleanup); installOneTalkConversationIdCopyControl(pageWindow); - const button = headerTitle.inserted; - assert.ok(button); - assert.equal(button.textContent, "复制会话 Id"); + const button = getByRole(document.body, "button", { name: "复制会话 Id" }); assert.equal(button.dataset.conversationId, "conversation-1"); - assert.equal(button.getAttribute("aria-label"), "复制会话 Id"); assert.match(button.style.cssText, /font-size: 12px/u); assert.match(button.style.cssText, /justify-content: center/u); - button.listeners.get("click")(); + + const user = userEvent.setup({ document }); + await user.click(button); await Promise.resolve(); + assert.deepEqual(clipboardWrites, ["conversation-1"]); assert.equal(button.textContent, "已复制"); }); -test("does not install a control without one unambiguous selected conversation", () => { - const { document, pageWindow } = createPage(); - document.querySelectorAll = () => []; +test("does not install a control without one unambiguous selected conversation", (t) => { + const { cleanup, document, pageWindow } = createPage(); + t.after(cleanup); + document.querySelector(".contact-item-container").className = "contact-item-container"; installOneTalkConversationIdCopyControl(pageWindow); - assert.equal(document.button, null); + assert.equal(document.querySelector("button[data-tmc-conversation-id-copy]"), null); }); -test("shows a failure state when the page cannot write to the clipboard", async () => { - const { headerTitle, pageWindow } = createPage(); +test("shows a failure state when the page cannot write to the clipboard", async (t) => { + const { cleanup, document, pageWindow } = createPage(); + t.after(cleanup); pageWindow.navigator.clipboard.writeText = async () => { throw new Error("clipboard_denied"); }; - installOneTalkConversationIdCopyControl(pageWindow); - headerTitle.inserted.listeners.get("click")(); + const button = getByRole(document.body, "button", { name: "复制会话 Id" }); + const user = userEvent.setup({ document }); + await user.click(button); await Promise.resolve(); await Promise.resolve(); - assert.equal(headerTitle.inserted.textContent, "复制失败"); + + assert.equal(button.textContent, "复制失败"); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fda8431..6c7b1ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,9 +27,18 @@ importers: specifier: workspace:* version: link:../../packages/onetalk-contract devDependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.1) '@types/node': specifier: ^22.10.2 version: 22.20.1 + jsdom: + specifier: ^30.0.1 + version: 30.0.1 typescript: specifier: ^5.7.3 version: 5.7.3 @@ -89,6 +98,66 @@ importers: packages: + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.13': + resolution: {integrity: sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -694,6 +763,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} @@ -985,6 +1063,19 @@ packages: cpu: [x64] os: [win32] + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1005,6 +1096,17 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1012,6 +1114,9 @@ packages: avvio@9.3.0: resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1019,10 +1124,24 @@ packages: resolution: {integrity: sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==} engines: {node: '>=18'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -1125,6 +1244,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -1193,6 +1316,10 @@ packages: get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -1205,6 +1332,21 @@ packages: resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} engines: {node: '>= 10'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} @@ -1219,6 +1361,17 @@ packages: engines: {node: '>=22.22.1'} hasBin: true + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1244,6 +1397,9 @@ packages: vite-plus: optional: true + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1269,15 +1425,26 @@ packages: resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} engines: {node: '>=12'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-warning@4.0.0: resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} process-warning@5.1.0: resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1323,6 +1490,10 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -1362,6 +1533,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -1378,10 +1552,25 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + toad-cache@3.7.4: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tsx@4.23.12: resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} @@ -1395,6 +1584,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1438,6 +1631,26 @@ packages: yaml: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.1: + resolution: {integrity: sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==} + engines: {node: ^22.14.0 || >=24.0.0} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -1453,6 +1666,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -1460,6 +1680,59 @@ packages: snapshots: + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.13(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -1769,6 +2042,8 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@exodus/bytes@1.15.1': {} + '@fastify/ajv-compiler@4.0.6': dependencies: ajv: 8.20.0 @@ -1938,6 +2213,23 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.5': optional: true + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/estree@1.0.9': {} '@types/node@22.20.1': @@ -1957,6 +2249,14 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + atomic-sleep@1.0.0: {} avvio@9.3.0: @@ -1964,12 +2264,32 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + buffer-from@1.1.2: {} cookie@1.0.1: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + dequal@2.0.3: {} + dom-accessibility-api@0.5.16: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -1992,6 +2312,8 @@ snapshots: dependencies: once: 1.4.0 + entities@8.1.0: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -2165,12 +2487,48 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + husky@9.1.7: {} inherits@2.0.4: {} ipaddr.js@2.5.0: {} + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.13(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-schema-ref-resolver@3.0.0: dependencies: dequal: 2.0.3 @@ -2191,6 +2549,12 @@ snapshots: optionalDependencies: yaml: 2.9.0 + lru-cache@11.5.2: {} + + lz-string@1.5.0: {} + + mdn-data@2.27.1: {} + nanoid@3.3.18: {} on-exit-leak-free@2.1.2: {} @@ -2223,6 +2587,10 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.64.0 '@oxfmt/binding-win32-x64-msvc': 0.64.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -2255,12 +2623,22 @@ snapshots: postgres@3.4.9: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + process-warning@4.0.0: {} process-warning@5.1.0: {} + punycode@2.3.1: {} + quick-format-unescaped@4.0.4: {} + react-is@17.0.2: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -2321,6 +2699,10 @@ snapshots: safe-stable-stringify@2.5.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + secure-json-parse@4.1.0: {} semver@7.8.5: {} @@ -2350,6 +2732,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + symbol-tree@3.2.4: {} + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -2363,8 +2747,22 @@ snapshots: tinypool@2.1.0: {} + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + toad-cache@3.7.4: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tsx@4.23.12: dependencies: esbuild: 0.28.2 @@ -2375,6 +2773,8 @@ snapshots: undici-types@6.21.0: {} + undici@8.10.2: {} + util-deprecate@1.0.2: {} vite@6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0): @@ -2391,9 +2791,37 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + wrappy@1.0.2: {} ws@8.21.3: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.9.0: optional: true