fix: 复制按钮在局域网页面下静默失败
明文 HTTP 的局域网页面不是安全上下文,navigator.clipboard 不存在,
旧代码落到 else 分支直接报告成功却什么都没复制;而且 .then(done, done)
连 Promise 被拒(权限拒绝)也当成功——两种情况都会谎报「已复制」。
现在:
- 只在真的写入剪贴板后才报成功;clipboard API 被拒也继续回退
- 回退到 document.execCommand('copy')(明文 HTTP 下仍然可用)
- 两者都不可用时选中链接并提示手动 Ctrl/⌘+C,不再谎报
- URL 改为可选中的只读输入框(原先是没有省略号就无法看全的 div)
新增 7 项回归测试覆盖:clipboard 缺失时确实走 execCommand、可用时走
API 且不重复复制、被拒时回退、两者都无时不抛异常。旧代码在该测试下失败。
README 中英双语排障表补充该现象与 HTTPS 说明。
This commit is contained in:
@@ -118,6 +118,7 @@ adaptive choice, remove the last three entries of `cordis.patch.yml` (the
|
||||
| A bookmark stops working after a restart | The launch token changes on every boot. Devices that already logged in keep their cookie; otherwise use a fresh link. |
|
||||
| The panel reports no non-internal IPv4 address | This machine currently has no usable LAN IPv4, only loopback. |
|
||||
| Scanning does nothing | Some cameras read dark-background codes poorly; the panel always draws the code on a white card. Raise the screen brightness. |
|
||||
| "Copy" does nothing / nothing reaches the clipboard | A plain-HTTP LAN page is **not a secure context**, so `navigator.clipboard` does not exist there. The plugin falls back to `document.execCommand('copy')`; when a browser blocks both it **selects the link and asks for Ctrl/⌘+C** instead of falsely reporting success. The real clipboard API needs HTTPS. |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ DSH 官方把「持久化设置」限制在回环页面:客户端由 `location
|
||||
| 重启后旧书签失效 | launch token 每次启动都会变。已登录过的设备靠 cookie 仍然有效;否则重新用新链接进入。 |
|
||||
| 面板显示"未找到非内部 IPv4 地址" | 这台机器当前没有可用的局域网 IPv4(只有回环)。 |
|
||||
| 扫码无反应 | 部分机型对深色背景上的二维码识别差;面板里的二维码固定渲染在白底卡片上。适当调高屏幕亮度。 |
|
||||
| 点「复制」没反应 / 剪贴板里没内容 | 明文 HTTP 的局域网页面**不是安全上下文**,`navigator.clipboard` 在该页面不存在。插件会退回 `document.execCommand('copy')`;若浏览器两条路都禁止,就**选中链接并提示手动 Ctrl/⌘+C**,不再谎报「已复制」。想用正规剪贴板 API 需 HTTPS。 |
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -551,6 +551,8 @@ window.__ModuleLoader__.load({
|
||||
scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
selected: '已选中',
|
||||
manualHint: '此浏览器不允许页面自动写入剪贴板(明文 HTTP 下 clipboard API 不可用),已为你选中链接,请按 Ctrl/⌘+C 复制。',
|
||||
open: '打开',
|
||||
warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。',
|
||||
disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。',
|
||||
@@ -570,6 +572,8 @@ window.__ModuleLoader__.load({
|
||||
scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
selected: 'Selected',
|
||||
manualHint: 'This browser does not let the page write to the clipboard (the clipboard API needs a secure context, which plain HTTP over the LAN is not). The link is selected — press Ctrl/⌘+C to copy.',
|
||||
open: 'Open',
|
||||
warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.',
|
||||
disabled: 'The LAN listener is configured off in this profile.',
|
||||
@@ -622,7 +626,8 @@ window.__ModuleLoader__.load({
|
||||
.dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); }
|
||||
.dla-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.dla-ip { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
||||
.dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; min-width: 0; padding: 0; border: none; background: transparent; color: var(--dsw-alias-label-primary); text-overflow: ellipsis; }
|
||||
.dla-url:focus { outline: none; }
|
||||
.dla-btn { flex: none; font: inherit; font-size: 12px; line-height: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; }
|
||||
.dla-btn:hover { border-color: var(--dsw-alias-border-l2); }
|
||||
.dla-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
|
||||
@@ -634,6 +639,71 @@ window.__ModuleLoader__.load({
|
||||
.dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); }
|
||||
`
|
||||
|
||||
/**
|
||||
* Copy `text` to the clipboard, reporting what actually happened.
|
||||
*
|
||||
* The async Clipboard API only exists in a secure context, so a page served
|
||||
* over plain HTTP on a LAN address does not have it. The legacy
|
||||
* `document.execCommand('copy')` path still works there, so it is the real
|
||||
* fallback rather than a silent success.
|
||||
* @param text - the string to copy.
|
||||
* @param input - an already-rendered selectable element holding the same text, when available.
|
||||
* @returns `'copied'` only when the clipboard was written; otherwise `'manual'`.
|
||||
*/
|
||||
async function copyText(text, input) {
|
||||
const clipboard = globalThis.navigator?.clipboard
|
||||
if (clipboard !== undefined && typeof clipboard.writeText === 'function') {
|
||||
try {
|
||||
await clipboard.writeText(text)
|
||||
return 'copied'
|
||||
} catch {
|
||||
/* permission denied or unusable: fall through to the legacy path */
|
||||
}
|
||||
}
|
||||
return selectAndCopy(input, text) ? 'copied' : 'manual'
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy synchronous copy: select the rendered element and run the deprecated
|
||||
* but still widely supported `execCommand('copy')`.
|
||||
*
|
||||
* A transient off-screen textarea covers the case where no element was
|
||||
* supplied; it is removed in the same tick and only ever exists inside this
|
||||
* function.
|
||||
* @param input - the selectable element, when the caller has one.
|
||||
* @param text - the text to place on the clipboard.
|
||||
* @returns whether the copy command reported success.
|
||||
*/
|
||||
function selectAndCopy(input, text) {
|
||||
const doc = globalThis.document
|
||||
if (doc === undefined) return false
|
||||
if (input !== undefined && input !== null && typeof input.select === 'function') {
|
||||
try {
|
||||
if (typeof input.focus === 'function') input.focus()
|
||||
input.select()
|
||||
if (typeof input.setSelectionRange === 'function') input.setSelectionRange(0, text.length)
|
||||
if (typeof doc.execCommand === 'function' && doc.execCommand('copy') === true) return true
|
||||
} catch {
|
||||
/* fall through to the textarea path */
|
||||
}
|
||||
}
|
||||
try {
|
||||
const area = doc.createElement('textarea')
|
||||
area.value = text
|
||||
area.setAttribute('readonly', '')
|
||||
area.style.position = 'fixed'
|
||||
area.style.top = '-1000px'
|
||||
area.style.opacity = '0'
|
||||
doc.body.appendChild(area)
|
||||
area.select()
|
||||
const ok = typeof doc.execCommand === 'function' && doc.execCommand('copy') === true
|
||||
doc.body.removeChild(area)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a QR matrix as a crisp SVG.
|
||||
* @param symbol - `{ size, modules }` from the encoder.
|
||||
@@ -682,7 +752,9 @@ window.__ModuleLoader__.load({
|
||||
const h = React.createElement
|
||||
const [state, setState] = React.useState({ status: 'loading' })
|
||||
const [selected, setSelected] = React.useState(0)
|
||||
const [copied, setCopied] = React.useState('')
|
||||
const [copyState, setCopyState] = React.useState({ url: '', status: 'idle' })
|
||||
const inputRefs = React.useRef(undefined)
|
||||
if (inputRefs.current === undefined) inputRefs.current = new Map()
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true
|
||||
@@ -754,16 +826,13 @@ window.__ModuleLoader__.load({
|
||||
return h('div', { className: 'dla-root' }, children)
|
||||
}
|
||||
|
||||
const copy = (url) => {
|
||||
const done = () => {
|
||||
setCopied(url)
|
||||
globalThis.setTimeout(() => setCopied(''), 1500)
|
||||
}
|
||||
if (globalThis.navigator?.clipboard?.writeText !== undefined) {
|
||||
globalThis.navigator.clipboard.writeText(url).then(done, done)
|
||||
} else {
|
||||
done()
|
||||
const copy = (entry) => {
|
||||
const input = inputRefs.current.get(entry.address)
|
||||
const settle = (status) => {
|
||||
setCopyState({ url: entry.tokenUrl, status })
|
||||
globalThis.setTimeout(() => setCopyState({ url: '', status: 'idle' }), 2500)
|
||||
}
|
||||
copyText(entry.tokenUrl, input).then(settle, () => settle('manual'))
|
||||
}
|
||||
|
||||
const rows = lan.map((entry, index) =>
|
||||
@@ -779,7 +848,23 @@ window.__ModuleLoader__.load({
|
||||
'div',
|
||||
{ className: 'dla-row-main' },
|
||||
h('div', { className: 'dla-ip' }, entry.address),
|
||||
h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl),
|
||||
h('input', {
|
||||
className: 'dla-url',
|
||||
type: 'text',
|
||||
readOnly: true,
|
||||
spellCheck: false,
|
||||
value: entry.tokenUrl,
|
||||
title: entry.tokenUrl,
|
||||
ref: (element) => {
|
||||
if (element === null) inputRefs.current.delete(entry.address)
|
||||
else inputRefs.current.set(entry.address, element)
|
||||
},
|
||||
onFocus: (event) => {
|
||||
setSelected(index)
|
||||
event.currentTarget.select()
|
||||
},
|
||||
onClick: (event) => event.stopPropagation(),
|
||||
}),
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
@@ -788,10 +873,14 @@ window.__ModuleLoader__.load({
|
||||
className: 'dla-btn',
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
copy(entry.tokenUrl)
|
||||
copy(entry)
|
||||
},
|
||||
},
|
||||
copied === entry.tokenUrl ? t('copied') : t('copy'),
|
||||
copyState.url !== entry.tokenUrl
|
||||
? t('copy')
|
||||
: copyState.status === 'copied'
|
||||
? t('copied')
|
||||
: t('selected'),
|
||||
),
|
||||
h(
|
||||
'a',
|
||||
@@ -818,6 +907,7 @@ window.__ModuleLoader__.load({
|
||||
h('div', { className: 'dla-section' }, t('addresses')),
|
||||
...rows,
|
||||
h('div', { className: 'dla-hint' }, t('scanHint')),
|
||||
copyState.status === 'manual' ? h('div', { className: 'dla-warn' }, t('manualHint')) : null,
|
||||
),
|
||||
symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address),
|
||||
),
|
||||
|
||||
@@ -52,6 +52,7 @@ const React = {
|
||||
return [typeof initial === 'function' ? initial() : initial, () => {}]
|
||||
},
|
||||
useEffect: () => {},
|
||||
useRef: (initial) => ({ current: initial }),
|
||||
}
|
||||
const requireStub = (name) => {
|
||||
if (name === 'react') return React
|
||||
@@ -159,6 +160,85 @@ const failedJson = JSON.stringify(failed)
|
||||
check('bind failure is surfaced in the panel', failedJson.includes('EADDRINUSE'))
|
||||
check('bind failure names the target', failedJson.includes('0.0.0.0:3081'))
|
||||
|
||||
// ── clipboard: a real copy or an honest "you must copy it yourself" ─────────
|
||||
/**
|
||||
* Collect every element in a rendered tree whose className matches.
|
||||
* @param node - a React-element-shaped node from the fake createElement.
|
||||
* @param className - the class to match.
|
||||
* @returns the matching nodes.
|
||||
*/
|
||||
function findByClass(node, className) {
|
||||
const found = []
|
||||
const walk = (current) => {
|
||||
if (current === null || typeof current !== 'object') return
|
||||
if (Array.isArray(current)) { current.forEach(walk); return }
|
||||
if (current.props?.className === className) found.push(current)
|
||||
for (const child of current.children ?? []) walk(child)
|
||||
}
|
||||
walk(node)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Render the panel with one LAN address and return its copy button. */
|
||||
function renderCopyButton() {
|
||||
stateFixture = {
|
||||
status: 'ready',
|
||||
data: {
|
||||
enabled: true,
|
||||
listening: true,
|
||||
listen: { host: '0.0.0.0', port: 3082 },
|
||||
target: { host: '127.0.0.1', port: 3080 },
|
||||
lan: [{ address: '192.168.1.5', url: 'http://192.168.1.5:3082/', tokenUrl: 'http://192.168.1.5:3082/?token=tok' }],
|
||||
},
|
||||
}
|
||||
useStateCalls = 0
|
||||
const tree = registrations[0].component({})
|
||||
const buttons = findByClass(tree, 'dla-btn').filter((node) => node.type === 'button')
|
||||
return { tree, button: buttons[0], url: 'http://192.168.1.5:3082/?token=tok' }
|
||||
}
|
||||
|
||||
// The URL is rendered as a selectable read-only input, not an ellipsised div.
|
||||
const rendered = renderCopyButton()
|
||||
const inputs = findByClass(rendered.tree, 'dla-url').filter((node) => node.type === 'input')
|
||||
check('the url renders as a read-only input', inputs.length === 1 && inputs[0].props.readOnly === true)
|
||||
check('the input carries the tokenized url', inputs[0]?.props?.value === rendered.url, inputs[0]?.props?.value)
|
||||
|
||||
// Plain HTTP over the LAN has no navigator.clipboard; the legacy path must run
|
||||
// and must only claim success when execCommand actually reported it.
|
||||
let execCalls = 0
|
||||
sandbox.navigator = {}
|
||||
sandbox.document = {
|
||||
execCommand: (command) => { execCalls += 1; return command === 'copy' },
|
||||
createElement: () => ({ style: {}, setAttribute() {}, select() {}, remove() {} }),
|
||||
body: { appendChild() {}, removeChild() {} },
|
||||
}
|
||||
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
check('falls back to execCommand when clipboard is unavailable', execCalls > 0, `execCommand calls: ${execCalls}`)
|
||||
|
||||
// A secure context (or any host that does provide it) uses the async API.
|
||||
let written = ''
|
||||
sandbox.navigator = { clipboard: { writeText: (text) => { written = text; return Promise.resolve() } } }
|
||||
execCalls = 0
|
||||
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
check('uses the clipboard API when it exists', written === rendered.url, written)
|
||||
check('does not double-copy through the legacy path', execCalls === 0, `execCommand calls: ${execCalls}`)
|
||||
|
||||
// A rejected clipboard write must fall through rather than report success.
|
||||
written = ''
|
||||
sandbox.navigator = { clipboard: { writeText: () => Promise.reject(new Error('denied')) } }
|
||||
execCalls = 0
|
||||
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
check('a rejected clipboard write falls back', execCalls > 0, `execCommand calls: ${execCalls}`)
|
||||
|
||||
// Nothing available at all: it must not throw, and must not claim success.
|
||||
sandbox.navigator = {}
|
||||
sandbox.document = undefined
|
||||
await renderCopyButton().button.props.onClick({ stopPropagation() {}, currentTarget: { select() {} } })
|
||||
check('no clipboard and no document does not throw', true)
|
||||
|
||||
// ── the two halves agree on the route ───────────────────────────────────────
|
||||
check('client fetches the host route', source.includes(SUMMARY_PATH), SUMMARY_PATH)
|
||||
|
||||
|
||||
+104
-14
@@ -40,6 +40,8 @@ const zh = {
|
||||
scanHint: '用另一台设备扫描二维码即可打开(链接已带登录令牌)。',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
selected: '已选中',
|
||||
manualHint: '此浏览器不允许页面自动写入剪贴板(明文 HTTP 下 clipboard API 不可用),已为你选中链接,请按 Ctrl/⌘+C 复制。',
|
||||
open: '打开',
|
||||
warning: '该链接携带 launch token,等同于完整 dsh 控制权(含终端命令执行)。请只在可信局域网内分享。',
|
||||
disabled: '该 LAN 监听器在此 profile 中已被配置为关闭。',
|
||||
@@ -59,6 +61,8 @@ const en = {
|
||||
scanHint: 'Scan the QR code with another device to open it (the link already carries the login token).',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
selected: 'Selected',
|
||||
manualHint: 'This browser does not let the page write to the clipboard (the clipboard API needs a secure context, which plain HTTP over the LAN is not). The link is selected — press Ctrl/⌘+C to copy.',
|
||||
open: 'Open',
|
||||
warning: 'That link carries the launch token and grants full dsh control, shell included. Share it only on a trusted network.',
|
||||
disabled: 'The LAN listener is configured off in this profile.',
|
||||
@@ -111,7 +115,8 @@ const CSS = `
|
||||
.dla-row[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); }
|
||||
.dla-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.dla-ip { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
||||
.dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dla-url { font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; min-width: 0; padding: 0; border: none; background: transparent; color: var(--dsw-alias-label-primary); text-overflow: ellipsis; }
|
||||
.dla-url:focus { outline: none; }
|
||||
.dla-btn { flex: none; font: inherit; font-size: 12px; line-height: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; }
|
||||
.dla-btn:hover { border-color: var(--dsw-alias-border-l2); }
|
||||
.dla-btn[data-primary="true"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
|
||||
@@ -123,6 +128,71 @@ const CSS = `
|
||||
.dla-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); }
|
||||
`
|
||||
|
||||
/**
|
||||
* Copy `text` to the clipboard, reporting what actually happened.
|
||||
*
|
||||
* The async Clipboard API only exists in a secure context, so a page served
|
||||
* over plain HTTP on a LAN address does not have it. The legacy
|
||||
* `document.execCommand('copy')` path still works there, so it is the real
|
||||
* fallback rather than a silent success.
|
||||
* @param text - the string to copy.
|
||||
* @param input - an already-rendered selectable element holding the same text, when available.
|
||||
* @returns `'copied'` only when the clipboard was written; otherwise `'manual'`.
|
||||
*/
|
||||
async function copyText(text, input) {
|
||||
const clipboard = globalThis.navigator?.clipboard
|
||||
if (clipboard !== undefined && typeof clipboard.writeText === 'function') {
|
||||
try {
|
||||
await clipboard.writeText(text)
|
||||
return 'copied'
|
||||
} catch {
|
||||
/* permission denied or unusable: fall through to the legacy path */
|
||||
}
|
||||
}
|
||||
return selectAndCopy(input, text) ? 'copied' : 'manual'
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy synchronous copy: select the rendered element and run the deprecated
|
||||
* but still widely supported `execCommand('copy')`.
|
||||
*
|
||||
* A transient off-screen textarea covers the case where no element was
|
||||
* supplied; it is removed in the same tick and only ever exists inside this
|
||||
* function.
|
||||
* @param input - the selectable element, when the caller has one.
|
||||
* @param text - the text to place on the clipboard.
|
||||
* @returns whether the copy command reported success.
|
||||
*/
|
||||
function selectAndCopy(input, text) {
|
||||
const doc = globalThis.document
|
||||
if (doc === undefined) return false
|
||||
if (input !== undefined && input !== null && typeof input.select === 'function') {
|
||||
try {
|
||||
if (typeof input.focus === 'function') input.focus()
|
||||
input.select()
|
||||
if (typeof input.setSelectionRange === 'function') input.setSelectionRange(0, text.length)
|
||||
if (typeof doc.execCommand === 'function' && doc.execCommand('copy') === true) return true
|
||||
} catch {
|
||||
/* fall through to the textarea path */
|
||||
}
|
||||
}
|
||||
try {
|
||||
const area = doc.createElement('textarea')
|
||||
area.value = text
|
||||
area.setAttribute('readonly', '')
|
||||
area.style.position = 'fixed'
|
||||
area.style.top = '-1000px'
|
||||
area.style.opacity = '0'
|
||||
doc.body.appendChild(area)
|
||||
area.select()
|
||||
const ok = typeof doc.execCommand === 'function' && doc.execCommand('copy') === true
|
||||
doc.body.removeChild(area)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a QR matrix as a crisp SVG.
|
||||
* @param symbol - `{ size, modules }` from the encoder.
|
||||
@@ -171,7 +241,9 @@ function Panel() {
|
||||
const h = React.createElement
|
||||
const [state, setState] = React.useState({ status: 'loading' })
|
||||
const [selected, setSelected] = React.useState(0)
|
||||
const [copied, setCopied] = React.useState('')
|
||||
const [copyState, setCopyState] = React.useState({ url: '', status: 'idle' })
|
||||
const inputRefs = React.useRef(undefined)
|
||||
if (inputRefs.current === undefined) inputRefs.current = new Map()
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true
|
||||
@@ -243,16 +315,13 @@ function Panel() {
|
||||
return h('div', { className: 'dla-root' }, children)
|
||||
}
|
||||
|
||||
const copy = (url) => {
|
||||
const done = () => {
|
||||
setCopied(url)
|
||||
globalThis.setTimeout(() => setCopied(''), 1500)
|
||||
}
|
||||
if (globalThis.navigator?.clipboard?.writeText !== undefined) {
|
||||
globalThis.navigator.clipboard.writeText(url).then(done, done)
|
||||
} else {
|
||||
done()
|
||||
const copy = (entry) => {
|
||||
const input = inputRefs.current.get(entry.address)
|
||||
const settle = (status) => {
|
||||
setCopyState({ url: entry.tokenUrl, status })
|
||||
globalThis.setTimeout(() => setCopyState({ url: '', status: 'idle' }), 2500)
|
||||
}
|
||||
copyText(entry.tokenUrl, input).then(settle, () => settle('manual'))
|
||||
}
|
||||
|
||||
const rows = lan.map((entry, index) =>
|
||||
@@ -268,7 +337,23 @@ function Panel() {
|
||||
'div',
|
||||
{ className: 'dla-row-main' },
|
||||
h('div', { className: 'dla-ip' }, entry.address),
|
||||
h('div', { className: 'dla-url', title: entry.tokenUrl }, entry.tokenUrl),
|
||||
h('input', {
|
||||
className: 'dla-url',
|
||||
type: 'text',
|
||||
readOnly: true,
|
||||
spellCheck: false,
|
||||
value: entry.tokenUrl,
|
||||
title: entry.tokenUrl,
|
||||
ref: (element) => {
|
||||
if (element === null) inputRefs.current.delete(entry.address)
|
||||
else inputRefs.current.set(entry.address, element)
|
||||
},
|
||||
onFocus: (event) => {
|
||||
setSelected(index)
|
||||
event.currentTarget.select()
|
||||
},
|
||||
onClick: (event) => event.stopPropagation(),
|
||||
}),
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
@@ -277,10 +362,14 @@ function Panel() {
|
||||
className: 'dla-btn',
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
copy(entry.tokenUrl)
|
||||
copy(entry)
|
||||
},
|
||||
},
|
||||
copied === entry.tokenUrl ? t('copied') : t('copy'),
|
||||
copyState.url !== entry.tokenUrl
|
||||
? t('copy')
|
||||
: copyState.status === 'copied'
|
||||
? t('copied')
|
||||
: t('selected'),
|
||||
),
|
||||
h(
|
||||
'a',
|
||||
@@ -307,6 +396,7 @@ function Panel() {
|
||||
h('div', { className: 'dla-section' }, t('addresses')),
|
||||
...rows,
|
||||
h('div', { className: 'dla-hint' }, t('scanHint')),
|
||||
copyState.status === 'manual' ? h('div', { className: 'dla-warn' }, t('manualHint')) : null,
|
||||
),
|
||||
symbol === undefined ? null : qrElement(React, symbol, t('qr', { address: active.address }), active.address),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user