8000 feat(web): 消息操作按钮的复制确认、按压动效,代码块复制统一 (#1497) · felinics/Memoh@db4cf74 · GitHub
[go: up one dir, main page]

Skip to content

Commit db4cf74

Browse files
authored
feat(web): 消息操作按钮的复制确认、按压动效,代码块复制统一 (#1497)
- 新增 CopyActionButton:图标先退场、短暂留空再入场,复原同样过渡;对勾保持 到指针离开;动画期间忽略重复点击;复制失败提示 toast。消息操作行与代码块 共用它 - 消息操作行:显示对勾期间不弹 tooltip;按下时背景与图标一起缩放到 0.92; hover 颜色 60ms;行淡入 180ms ease-out;重试点击后逆时针旋转一圈 - 修复历史消息「更多」菜单打开后操作行淡出、按钮消失的问题 - 删除不再使用的 chat.actions.copied 文案 Refs: MEMOH-74
1 parent ae2475c commit db4cf74

7 files changed

Lines changed: 272 additions & 82 deletions

File tree

‎apps/web/src/i18n/locales/en.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,7 +843,6 @@
843843
"showLess": "Show less",
844844
"actions": {
845845
"copy": "Copy",
846-
"copied": "Copied",
847846
"edit": "Edit",
848847
"share": "Share",
849848
"retry": "Try again",

‎apps/web/src/i18n/locales/ja.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,6 @@
827827
"thinkingDone": "思考完了",
828828
"actions": {
829829
"copy": "コピー",
830-
"copied": "コピーされました",
831830
"edit": "編集",
832831
"share": "共有",
833832
"retry": "もう一度試す",

‎apps/web/src/i18n/locales/zh.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,7 +843,6 @@
843843
"showLess": "收起",
844844
"actions": {
845845
"copy": "复制",
846-
"copied": "已复制",
847846
"edit": "编辑",
848847
"share": "分享",
849848
"retry": "重试",

‎apps/web/src/pages/home/components/chat-code-block.vue‎

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,31 +12,18 @@
1212
:lang="language || 'text'"
1313
class="overflow-x-auto py-1.5 text-[13px] leading-[1.8]"
1414
/>
15-
<Button
16-
variant="ghost"
17-
size="icon-sm"
15+
<CopyActionButton
16+
:text="code"
17+
icon-class="size-3.5"
1818
class="shrink-0 text-muted-foreground focus-visible:ring-0 hover:text-foreground"
19-
:aria-label="t('common.copy', 'Copy')"
20-
@click="copy"
21-
>
22-
<Check
23-
v-if="copied"
24-
class="size-3.5"
25-
/>
26-
<Copy
27-
v-else
28-
class="size-3.5"
29-
/>
30-
</Button>
19+
/>
3120
</div>
3221
</template>
3322

3423
<script setup lang="ts">
35-
import { computed, ref } from 'vue'
36-
import { Check, Copy } from 'lucide-vue-next'
37-
import { Button, toast, useClipboard } from '@felinic/ui'
38-
import { useI18n } from 'vue-i18n'
24+
import { computed } from 'vue'
3925
import CodeBlock from './code-block.vue'
26+
import CopyActionButton from './copy-action-button.vue'
4027
4128
interface CodeFenceNode {
4229
type: string
@@ -47,22 +34,6 @@ interface CodeFenceNode {
4734
}
4835
4936
const props = defineProps<{ node: CodeFenceNode }>()
50-
const { t } = useI18n()
51-
const { copyText } = useClipboard()
52-
5337
const code = computed(() => props.node.code ?? props.node.raw ?? '')
5438
const language = computed(() => (props.node.language ?? '').trim().toLowerCase())
55-
56-
const copied = ref(false)
57-
let resetTimer: ReturnType<typeof setTimeout> | null = null
58-
async function copy() {
59-
const ok = await copyText(code.value)
60-
if (!ok) {
61-
toast.error(t('common.copyFailed'))
62-
return
63-
}
64-
copied.value = true
65-
if (resetTimer) clearTimeout(resetTimer)
66-
resetTimer = setTimeout(() => { copied.value = false }, 1500)
67-
}
6839
</script>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<!-- Icon button that copies `text` and confirms with a drawn check. Motion follows
2+
Arkloop's CopyIconButton (c9ca6979e): the glyph shrinks out (60ms), the next
3+
one stays hidden for 75ms and then grows in (75ms scale / 50ms fade); the
4+
return to the copy glyph is the same swap. The check is held while the
5+
pointer rests on the button and returns only after it leaves; clicks are
6+
ignored until the copy glyph is back.
7+
`busy` (v-model) covers that whole span, so a caller that wraps the button
8+
in a tooltip can keep it closed while the check is the confirmation. The
9+
root is the Button itself, so it can sit under a TooltipTrigger as-child. -->
10+
<template>
11+
<Button
12+
type="button"
13+
variant="ghost"
14+
size="icon-sm"
15+
:aria-label="copied ? t('common.copied') : t('common.copy')"
16+
@click="handleCopy"
17+
@pointerenter="hovered = true"
18+
@pointerleave="handlePointerLeave"
19+
>
20+
<!-- Two layers: a caller may scale this outer span (e.g. a press), while
21+
the inner keyed span owns the swap transition, so neither overrides
22+
the other's `transition`. -->
23+
<span class="flex items-center justify-center">
24+
<Transition
25+
name="copy-glyph"
26+
mode="out-in"
27+
@after-enter="handleGlyphEntered"
28+
>
29+
<span
30+
:key="copied ? 'check' : 'copy'"
31+
class="flex items-center justify-center"
32+
>
33+
<CopyAnimatedCheckIcon
34+
v-if="copied"
35+
:class="iconClass"
36+
:stroke-width="1.75"
37+
/>
38+
<!-- Mirrored so the stacked squares read top-left over bottom-right. -->
39+
<CopyConnectedIcon
40+
v-else
41+
:class="[iconClass, '-scale-x-100']"
42+
:stroke-width="1.75"
43+
/>
44+
</span>
45+
</Transition>
46+
</span>
47+
</Button>
48+
</template>
49+
50+
<script setup lang="ts">
51+
import { onBeforeUnmount, ref } from 'vue'
52+
import { useI18n } from 'vue-i18n'
53+
import { Button, toast, useClipboard } from '@felinic/ui'
54+
import CopyConnectedIcon from './copy-connected-icon.vue'
55+
import CopyAnimatedCheckIcon from './copy-animated-check-icon.vue'
56+
57+
const props = withDefaults(defineProps<{
58+
text: string
59+
iconClass?: string
60+
}>(), { iconClass: 'size-[18px]' })
61+
62+
// From the click until the copy glyph has fully re-entered.
63+
const busy = defineModel<boolean>('busy', { default: false })
64+
65+
const { t } = useI18n()
66+
const { copyText } = useClipboard()
67+
68+
const copied = ref(false)
69+
const hovered = ref(false)
70+
// The hold ran out while the pointer was still on the button.
71+
let returnPending = false
72+
let holdTimer: ReturnType<typeof setTimeout> | null = null
73+
74+
async function handleCopy() {
75+
if (busy.value) return
76+
busy.value = true
77+
if (!await copyText(props.text)) {
78+
busy.value = false
79+
toast.error(t('common.copyFailed'))
80+
return
81+
}
82+
copied.value = true
83+
}
84+
85+
function handleGlyphEntered() {
86+
if (!copied.value) {
87+
busy.value = false
88+
return
89+
}
90+
// The 1.5s hold counts from the moment the check is fully in.
91+
holdTimer = setTimeout(() => {
92+
holdTimer = null
93+
if (hovered.value) returnPending = true
94+
else copied.value = false
95+
}, 1500)
96+
}
97+
98+
function handlePointerLeave() {
99+
hovered.value = false
100+
if (!returnPending) return
101+
returnPending = false
102+
copied.value = false
103+
}
104+
105+
onBeforeUnmount(() => {
106+
if (holdTimer) clearTimeout(holdTimer)
107+
})
108+
</script>
109+
110+
<style scoped>
111+
.copy-glyph-leave-active {
112+
transition:
113+
scale 60ms ease-in,
114+
opacity 60ms ease-in;
115+
}
116+
117+
/* The 75ms delay is the hidden gap before the incoming glyph grows in; the
118+
check's stroke is already drawing during it. */
119+
.copy-glyph-enter-active {
120+
transition:
121+
scale 75ms ease-out 75ms,
122+
opacity 50ms ease-out 75ms;
123+
}
124+
125+
.copy-glyph-enter-from,
126+
.copy-glyph-leave-to {
127+
scale: 0.5;
128+
opacity: 0;
129+
}
130+
131+
@media (prefers-reduced-motion: reduce) {
132+
.copy-glyph-enter-active,
133+
.copy-glyph-leave-active {
134+
transition: none;
135+
}
136+
}
137+
</style>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<!-- Copy confirmation check, ported from Arkloop
2+
src/apps/web/src/components/AnimatedCheck.tsx (c9ca6979e). Kept separate from
3+
the shared check-draw-icon on purpose: the copy button's timing is tuned
4+
around this exact draw. The path is lucide's check (length ≈ 21.3) but the
5+
dash is 30, so the first ~30% of the 150ms draw shows nothing; that hidden
6+
stretch overlaps the copy button's invisible "entering" phase, and the stroke
7+
finishes as the glyph finishes growing back. Do not normalize it with
8+
pathLength — that removes the delay the copy timing relies on. -->
9+
<template>
10+
<svg
11+
class="copy-animated-check"
12+
xmlns="http://www.w3.org/2000/svg"
13+
viewBox="0 0 24 24"
14+
fill="none"
15+
stroke="currentColor"
16+
:stroke-width="strokeWidth"
17+
stroke-linecap="round"
18+
stroke-linejoin="round"
19+
aria-hidden="true"
20+
>
21+
<path
22+
d="M4 13l4 4L20 7"
23+
stroke-dasharray="30"
24+
stroke-dashoffset="30"
25+
/>
26+
</svg>
27+
</template>
28+
29+
<script setup lang="ts">
30+
withDefaults(defineProps<{ strokeWidth?: number }>(), { strokeWidth: 2 })
31+
</script>
32+
33+
<style scoped>
34+
.copy-animated-check path {
35+
animation: copy-draw-check 0.15s ease-out forwards;
36+
}
37+
38+
@keyframes copy-draw-check {
39+
from {
40+
stroke-dashoffset: 30;
41+
}
42+
to {
43+
stroke-dashoffset: 0;
44+
}
45+
}
46+
47+
@media (prefers-reduced-motion: reduce) {
48+
.copy-animated-check path {
49+
animation: none;
50+
stroke-dashoffset: 0;
51+
}
52+
}
53+
</style>

0 commit comments

Comments
 (0)
0