Repository navigation
feat: 新增 CodePanel 代码块与 CopyButton 复制按钮 #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| <script setup lang="ts"> | ||
| // CodePanel — a read-only block of code with its copy button: the chat code | ||
| // fence, a command the user must paste into a terminal, a config snippet. | ||
| // Lifted from the host's chat code block (apps/web/src/pages/home/components/ | ||
| // chat-code-block.vue, now a caller of this) so every code surface shares one | ||
| // frame. It owns ONLY the frame — the menu-shell radius (same rung as | ||
| // SettingsSection / SurfaceCard, so a code block next to a settings card lines | ||
| // up), fill, the shared card edge rule (surfaceEdgeClass: no edge in dark | ||
| // mode unless `bordered`), inset, mono type and the copy button. Highlighting is the | ||
| // caller's: pass rendered markup through the default slot; without one the | ||
| // `code` text is shown as-is. | ||
| // | ||
| // Layout is a flex row — the code sits in its own column and the copy button | ||
| // is a stable trailing sibling (top-aligned), so the block stays | ||
| // fit-to-content without a reserved gap and the button does not jitter while | ||
| // streaming content grows the width. | ||
| import { surfaceEdgeClass } from '#/lib/surface' | ||
| import { CopyButton } from '../copy-button' | ||
|
|
||
| withDefaults(defineProps<{ | ||
| /** The exact text the copy button writes to the clipboard. */ | ||
| code: string | ||
| /** Copy button accessible name at rest (caller i18n). */ | ||
| copyLabel: string | ||
| /** Copy button accessible name while the check is shown (caller i18n). */ | ||
| copiedLabel: string | ||
| /** Toast shown when the clipboard write fails (caller i18n). */ | ||
| copyFailedMessage: string | ||
| /** | ||
| * `fit` hugs the code (chat fences, capped at the container width); | ||
| * `full` fills the row (a command in a dialog). | ||
| */ | ||
| width?: 'fit' | 'full' | ||
| /** | ||
| * Wrap long lines instead of scrolling them. Use it for a one-line shell | ||
| * command that must be read in full: `wrap-anywhere` lets an unbroken | ||
| * token (a key, a URL) break so it never widens the host. Wrapped rows are | ||
| * one logical line, so they take relaxed leading instead of the 1.8 used | ||
| * to separate real code lines. | ||
| */ | ||
| wrap?: boolean | ||
| /** Keep the edge in dark mode when nested inside another card-colored surface (a dialog body). */ | ||
| bordered?: boolean | ||
| }>(), { | ||
| width: 'full', | ||
| wrap: false, | ||
| bordered: false, | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <div | ||
| data-slot="code-panel" | ||
| class="flex max-w-full items-start gap-1.5 overflow-hidden rounded-menu-shell bg-card py-1 pl-3.5 pr-1.5" | ||
| :class="[surfaceEdgeClass(bordered), width === 'fit' ? 'w-fit' : 'w-full']" | ||
| > | ||
| <div | ||
| class="min-w-0 py-1.5 font-mono text-label text-foreground" | ||
| :class="[width === 'full' && 'flex-1', wrap ? 'whitespace-pre-wrap wrap-anywhere leading-relaxed' : 'overflow-x-auto leading-[1.8]']" | ||
| > | ||
| <slot> | ||
| <pre | ||
| class="m-0 font-mono" | ||
| :class="wrap ? 'whitespace-pre-wrap wrap-anywhere' : 'whitespace-pre'" | ||
| ><code>{{ code }}</code></pre> | ||
| </slot> | ||
| </div> | ||
| <CopyButton | ||
| :text="code" | ||
| :label="copyLabel" | ||
| :copied-label="copiedLabel" | ||
| :failed-message="copyFailedMessage" | ||
| icon-class="size-3.5" | ||
| class="shrink-0 text-muted-foreground hover:text-foreground" | ||
| /> | ||
| </div> | ||
| </template> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default as CodePanel } from './CodePanel.vue' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| <!-- Copy confirmation check (moved here from apps/web/src/pages/home/components/copy-animated-check-icon.vue), ported from Arkloop | ||
| src/apps/web/src/components/AnimatedCheck.tsx (c9ca6979e). Kept separate from | ||
| the shared check-draw-icon on purpose: the copy button's timing is tuned | ||
| around this exact draw. The path is lucide's check (length ≈ 21.3) but the | ||
| dash is 30, so the first ~30% of the 150ms draw shows nothing; that hidden | ||
| stretch overlaps the copy button's invisible "entering" phase, and the stroke | ||
| finishes as the glyph finishes growing back. Do not normalize it with | ||
| pathLength — that removes the delay the copy timing relies on. --> | ||
| <template> | ||
| <svg | ||
| class="copy-animated-check" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| :stroke-width="strokeWidth" | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| aria-hidden="true" | ||
| > | ||
| <path | ||
| d="M4 13l4 4L20 7" | ||
| stroke-dasharray="30" | ||
| stroke-dashoffset="30" | ||
| /> | ||
| </svg> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| withDefaults(defineProps<{ strokeWidth?: number }>(), { strokeWidth: 2 }) | ||
| </script> | ||
|
|
||
| <style scoped> | ||
| .copy-animated-check path { | ||
| animation: copy-draw-check 0.15s ease-out forwards; | ||
| } | ||
|
|
||
| @keyframes copy-draw-check { | ||
| from { | ||
| stroke-dashoffset: 30; | ||
| } | ||
| to { | ||
| stroke-dashoffset: 0; | ||
| } | ||
| } | ||
|
|
||
| @media (prefers-reduced-motion: reduce) { | ||
| .copy-animated-check path { | ||
| animation: none; | ||
| stroke-dashoffset: 0; | ||
| } | ||
| } | ||
| </style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| <!-- Icon button that copies `text` and confirms with a drawn check. Motion follows | ||
| Arkloop's CopyIconButton (c9ca6979e): the glyph shrinks out (60ms), the next | ||
| one stays hidden for 75ms and then grows in (75ms scale / 50ms fade); the | ||
| return to the copy glyph is the same swap. The check is held while the | ||
| pointer rests on the button and returns only after it leaves; clicks are | ||
| ignored until the copy glyph is back. | ||
| `busy` (v-model) covers that whole span, so a caller that wraps the button | ||
| in a tooltip can keep it closed while the check is the confirmation. The | ||
| root is the Button itself, so it can sit under a TooltipTrigger as-child. | ||
| Lifted from the host (apps/web/src/pages/home/components/copy-action-button.vue, | ||
| now an i18n adapter over this). Library adaptations: copy comes in through | ||
| `label` / `copiedLabel` / `failedMessage` props (the library holds no i18n), | ||
| imports are relative, and the default icon size is the size-4.5 scale step | ||
| instead of size-[18px]. --> | ||
| <template> | ||
| <Button | ||
| type="button" | ||
| variant="ghost" | ||
| size="icon-sm" | ||
| :aria-label="copied ? copiedLabel : label" | ||
| @click="handleCopy" | ||
| @pointerenter="hovered = true" | ||
| @pointerleave="handlePointerLeave" | ||
| > | ||
| <!-- Two layers: a caller may scale this outer span (e.g. a press), while | ||
| the inner keyed span owns the swap transition, so neither overrides | ||
| the other's `transition`. --> | ||
| <span class="flex items-center justify-center"> | ||
| <Transition | ||
| name="copy-glyph" | ||
| mode="out-in" | ||
| @after-enter="handleGlyphEntered" | ||
| > | ||
| <span | ||
| :key="copied ? 'check' : 'copy'" | ||
| class="flex items-center justify-center" | ||
| > | ||
| <CopyAnimatedCheckIcon | ||
| v-if="copied" | ||
| :class="iconClass" | ||
| :stroke-width="1.75" | ||
| /> | ||
| <!-- Mirrored so the stacked squares read top-left over bottom-right. --> | ||
| <CopyConnectedIcon | ||
| v-else | ||
| :class="[iconClass, '-scale-x-100']" | ||
| :stroke-width="1.75" | ||
| /> | ||
| </span> | ||
| </Transition> | ||
| </span> | ||
| </Button> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import { onBeforeUnmount, ref } from 'vue' | ||
| import { Button } from '../button' | ||
| import { CopyConnectedIcon } from '../copy-connected-icon' | ||
| import { toast } from '../sonner' | ||
| import { useClipboard } from '../../lib/clipboard' | ||
| import CopyAnimatedCheckIcon from './CopyAnimatedCheckIcon.vue' | ||
|
|
||
| const props = withDefaults(defineProps<{ | ||
| text: string | ||
| /** Accessible name at rest (caller i18n). */ | ||
| label: string | ||
| /** Accessible name while the check is shown (caller i18n). */ | ||
| copiedLabel: string | ||
| /** Toast shown when the clipboard write fails (caller i18n). */ | ||
| failedMessage: string | ||
| iconClass?: string | ||
| }>(), { iconClass: 'size-4.5' }) | ||
|
|
||
| // From the click until the copy glyph has fully re-entered. | ||
| const busy = defineModel<boolean>('busy', { default: false }) | ||
|
|
||
| const { copyText } = useClipboard() | ||
|
|
||
| const copied = ref(false) | ||
| const hovered = ref(false) | ||
| // The hold ran out while the pointer was still on the button. | ||
| let returnPending = false | ||
| let holdTimer: ReturnType<typeof setTimeout> | null = null | ||
|
|
||
| async function handleCopy() { | ||
| if (busy.value) return | ||
| busy.value = true | ||
| if (!await copyText(props.text)) { | ||
| busy.value = false | ||
| toast.error(props.failedMessage) | ||
| return | ||
| } | ||
| copied.value = true | ||
| } | ||
|
|
||
| function handleGlyphEntered() { | ||
| if (!copied.value) { | ||
| busy.value = false | ||
| return | ||
| } | ||
| // The 1.5s hold counts from the moment the check is fully in. | ||
| holdTimer = setTimeout(() => { | ||
| holdTimer = null | ||
| if (hovered.value) returnPending = true | ||
| else copied.value = false | ||
| }, 1500) | ||
| } | ||
|
|
||
| function handlePointerLeave() { | ||
| hovered.value = false | ||
| if (!returnPending) return | ||
| returnPending = false | ||
| copied.value = false | ||
| } | ||
|
|
||
| onBeforeUnmount(() => { | ||
| if (holdTimer) clearTimeout(holdTimer) | ||
| }) | ||
| </script> | ||
|
|
||
| <style scoped> | ||
| .copy-glyph-leave-active { | ||
| transition: | ||
| scale 60ms ease-in, | ||
| opacity 60ms ease-in; | ||
| } | ||
|
|
||
| /* The 75ms delay is the hidden gap before the incoming glyph grows in; the | ||
| check's stroke is already drawing during it. */ | ||
| .copy-glyph-enter-active { | ||
| transition: | ||
| scale 75ms ease-out 75ms, | ||
| opacity 50ms ease-out 75ms; | ||
| } | ||
|
|
||
| .copy-glyph-enter-from, | ||
| .copy-glyph-leave-to { | ||
| scale: 0.5; | ||
| opacity: 0; | ||
| } | ||
|
|
||
| @media (prefers-reduced-motion: reduce) { | ||
| .copy-glyph-enter-active, | ||
| .copy-glyph-leave-active { | ||
| transition: none; | ||
| } | ||
| } | ||
| </style> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default as CopyButton } from './CopyButton.vue' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The generic 32px
icon-smbutton defaults its glyph tosize-4.5(18px), which is outside the shared 16/14/12px icon ladder, while the public free-texticonClassprop lets every caller invent another size;CodePanelalready has to override it to 14px. Replace this escape hatch with an enumerated size mapped to the supported rungs and use the standard 16px default.AGENTS.md reference: AGENTS.md:L590-L597
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这条不改。
iconClass有意保留:同一个复制按钮在不同位置是不同的视觉层级,消息操作栏和旁边的重试、更多按钮统一为 18px,代码块里是 14px,不应该强制成一个枚举档位。默认值size-4.5等于宿主 #1497 原有的size-[18px],本 PR 只是原样搬入,不改变任何调用方的尺寸。