@workerdeck/ui 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/build/index.d.mts +927 -0
- package/build/index.mjs +5236 -0
- package/build/index.mjs.map +1 -0
- package/package.json +73 -0
- package/src/components/agent/Composer.tsx +139 -0
- package/src/components/agent/Conversation.tsx +59 -0
- package/src/components/agent/FileCard.tsx +50 -0
- package/src/components/agent/Loader.tsx +21 -0
- package/src/components/agent/Message.tsx +44 -0
- package/src/components/agent/ModelSelect.tsx +87 -0
- package/src/components/agent/PermissionModeSelect.tsx +94 -0
- package/src/components/agent/PermissionPrompt.tsx +52 -0
- package/src/components/agent/QuestionPrompt.tsx +193 -0
- package/src/components/agent/Reasoning.tsx +58 -0
- package/src/components/agent/Response.tsx +31 -0
- package/src/components/agent/SessionList.tsx +93 -0
- package/src/components/agent/SessionPanel.tsx +149 -0
- package/src/components/agent/StatusBar.tsx +140 -0
- package/src/components/agent/ToolCallCard.tsx +94 -0
- package/src/components/agent/Transcript.tsx +114 -0
- package/src/components/agent/status.ts +16 -0
- package/src/components/prompt-area/animated-placeholder.tsx +42 -0
- package/src/components/prompt-area/clipboard-helpers.ts +206 -0
- package/src/components/prompt-area/cursor-helpers.ts +244 -0
- package/src/components/prompt-area/dom-helpers.ts +721 -0
- package/src/components/prompt-area/file-strip.tsx +250 -0
- package/src/components/prompt-area/html-to-markdown.ts +278 -0
- package/src/components/prompt-area/image-strip.tsx +49 -0
- package/src/components/prompt-area/index.ts +23 -0
- package/src/components/prompt-area/prompt-area-engine.ts +705 -0
- package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
- package/src/components/prompt-area/prompt-area.tsx +375 -0
- package/src/components/prompt-area/remove-button.tsx +37 -0
- package/src/components/prompt-area/segment-helpers.ts +62 -0
- package/src/components/prompt-area/trigger-popover.tsx +139 -0
- package/src/components/prompt-area/trigger-presets.ts +143 -0
- package/src/components/prompt-area/types.ts +360 -0
- package/src/components/prompt-area/use-markdown-mode.ts +113 -0
- package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
- package/src/components/prompt-area/use-prompt-area.ts +1507 -0
- package/src/components/prompt-area/use-trigger-search.ts +115 -0
- package/src/components/ui/AlertDialog.tsx +56 -0
- package/src/components/ui/Badge.tsx +42 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Card.tsx +29 -0
- package/src/components/ui/CodeBlock.tsx +31 -0
- package/src/components/ui/CopyButton.tsx +28 -0
- package/src/components/ui/Input.tsx +20 -0
- package/src/components/ui/ProgressRing.tsx +49 -0
- package/src/components/ui/Select.tsx +80 -0
- package/src/components/ui/Sonner.tsx +22 -0
- package/src/components/ui/Spinner.tsx +6 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +34 -0
- package/src/index.ts +99 -0
- package/src/lib/format.ts +67 -0
- package/src/lib/utils.ts +33 -0
- package/src/styles/theme.css +413 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
3
|
+
import { ChevronDown, Clock, Wrench } from 'lucide-react'
|
|
4
|
+
import { Badge } from '../ui/Badge.tsx'
|
|
5
|
+
import { CodeBlock } from '../ui/CodeBlock.tsx'
|
|
6
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
7
|
+
import { cn } from '../../lib/utils.ts'
|
|
8
|
+
import { toolInputPreview } from '../../lib/format.ts'
|
|
9
|
+
|
|
10
|
+
export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
11
|
+
|
|
12
|
+
const RESULT_PREVIEW_CHARS = 2000
|
|
13
|
+
|
|
14
|
+
export interface ToolCallCardProps {
|
|
15
|
+
item: ToolCallItem
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Badge per execution state. `pending`/`deferred` mean the work is happening
|
|
20
|
+
* somewhere else (this tab's sandbox, a queue) — distinct from the model still
|
|
21
|
+
* running the call itself. */
|
|
22
|
+
const STATE_BADGE = {
|
|
23
|
+
running: { label: 'Running', variant: 'info', busy: true },
|
|
24
|
+
pending: { label: 'Executing', variant: 'info', busy: true },
|
|
25
|
+
deferred: { label: 'Deferred', variant: 'accent', busy: false },
|
|
26
|
+
settled: { label: 'Done', variant: 'success', busy: false },
|
|
27
|
+
failed: { label: 'Error', variant: 'danger', busy: false },
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
export function ToolCallCard({ item, className }: ToolCallCardProps) {
|
|
31
|
+
const [open, setOpen] = useState(false)
|
|
32
|
+
const [fullResult, setFullResult] = useState(false)
|
|
33
|
+
const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
|
|
34
|
+
const badge = STATE_BADGE[status]
|
|
35
|
+
const isError = status === 'failed' || item.result?.isError === true
|
|
36
|
+
|
|
37
|
+
const resultText = item.result?.text ?? ''
|
|
38
|
+
const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
|
|
39
|
+
const shownResult = truncated ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
data-slot='tool-call'
|
|
44
|
+
data-state={status}
|
|
45
|
+
className={cn('w-full overflow-hidden rounded-lg border border-border bg-surface', className)}>
|
|
46
|
+
<button
|
|
47
|
+
type='button'
|
|
48
|
+
onClick={() => setOpen((v) => !v)}
|
|
49
|
+
className={cn(
|
|
50
|
+
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors outline-none',
|
|
51
|
+
'hover:bg-surface-hover focus-visible:bg-surface-hover',
|
|
52
|
+
)}>
|
|
53
|
+
<Wrench className='size-3.5 shrink-0 text-fg-3' />
|
|
54
|
+
<span className='shrink-0 font-mono text-body-sm font-medium text-fg-1'>{item.name}</span>
|
|
55
|
+
<span className='min-w-0 flex-1 truncate font-mono text-label text-fg-4'>
|
|
56
|
+
{toolInputPreview(item.input)}
|
|
57
|
+
</span>
|
|
58
|
+
<Badge variant={badge.variant} dot={!badge.busy} className='shrink-0 gap-1'>
|
|
59
|
+
{badge.busy ? <Spinner className='size-3 text-current' /> : null}
|
|
60
|
+
{status === 'deferred' ? <Clock className='size-3 text-current' /> : null}
|
|
61
|
+
{badge.label}
|
|
62
|
+
</Badge>
|
|
63
|
+
<ChevronDown
|
|
64
|
+
className={cn('size-3.5 shrink-0 text-fg-4 transition-transform', open && 'rotate-180')}
|
|
65
|
+
/>
|
|
66
|
+
</button>
|
|
67
|
+
{open ? (
|
|
68
|
+
<div className='flex flex-col gap-2 border-t border-border p-2.5'>
|
|
69
|
+
<CodeBlock code={JSON.stringify(item.input, null, 2)} label='Parameters' />
|
|
70
|
+
{item.logs?.length ? (
|
|
71
|
+
<CodeBlock code={item.logs.join('\n')} label='Logs' />
|
|
72
|
+
) : null}
|
|
73
|
+
{item.result !== undefined ? (
|
|
74
|
+
<div>
|
|
75
|
+
<CodeBlock
|
|
76
|
+
code={shownResult || '(empty result)'}
|
|
77
|
+
label={isError ? 'Error' : 'Result'}
|
|
78
|
+
className={cn(isError && 'border-danger/40 [&_pre]:text-danger')}
|
|
79
|
+
/>
|
|
80
|
+
{truncated ? (
|
|
81
|
+
<button
|
|
82
|
+
type='button'
|
|
83
|
+
className='mt-1 text-label text-fg-3 underline-offset-2 hover:underline'
|
|
84
|
+
onClick={() => setFullResult(true)}>
|
|
85
|
+
Show all {resultText.length.toLocaleString()} chars
|
|
86
|
+
</button>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
</div>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
import { formatCost, formatDuration } from '../../lib/format.ts'
|
|
4
|
+
import { Conversation, ConversationContent, ConversationScrollButton } from './Conversation.tsx'
|
|
5
|
+
import { FileCard } from './FileCard.tsx'
|
|
6
|
+
import { Loader } from './Loader.tsx'
|
|
7
|
+
import { Message, MessageContent } from './Message.tsx'
|
|
8
|
+
import { Reasoning } from './Reasoning.tsx'
|
|
9
|
+
import { Response } from './Response.tsx'
|
|
10
|
+
import { ToolCallCard } from './ToolCallCard.tsx'
|
|
11
|
+
|
|
12
|
+
function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
|
|
13
|
+
return (
|
|
14
|
+
<div data-slot='turn-result' className='flex items-center gap-2 py-1'>
|
|
15
|
+
<div className='h-px flex-1 bg-border' />
|
|
16
|
+
<span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
|
|
17
|
+
{item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} ·{' '}
|
|
18
|
+
{formatCost(item.totalCostUsd)}
|
|
19
|
+
</span>
|
|
20
|
+
<div className='h-px flex-1 bg-border' />
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
data-slot='notice'
|
|
29
|
+
className={cn(
|
|
30
|
+
'rounded-md border px-3 py-2 text-body-sm',
|
|
31
|
+
item.level === 'error'
|
|
32
|
+
? 'border-transparent bg-danger-bg text-danger'
|
|
33
|
+
: 'border-border bg-surface text-fg-3',
|
|
34
|
+
)}>
|
|
35
|
+
{item.text}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function TranscriptItemView({
|
|
41
|
+
item,
|
|
42
|
+
fileUrl,
|
|
43
|
+
}: {
|
|
44
|
+
item: TranscriptItem
|
|
45
|
+
fileUrl?: (path: string) => string
|
|
46
|
+
}) {
|
|
47
|
+
switch (item.kind) {
|
|
48
|
+
case 'user':
|
|
49
|
+
return (
|
|
50
|
+
<Message from='user'>
|
|
51
|
+
<MessageContent>{item.text}</MessageContent>
|
|
52
|
+
</Message>
|
|
53
|
+
)
|
|
54
|
+
case 'assistant_text':
|
|
55
|
+
return (
|
|
56
|
+
<Message from='assistant'>
|
|
57
|
+
<MessageContent>
|
|
58
|
+
<Response streaming={item.streaming}>{item.text}</Response>
|
|
59
|
+
</MessageContent>
|
|
60
|
+
</Message>
|
|
61
|
+
)
|
|
62
|
+
case 'thinking':
|
|
63
|
+
return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
|
|
64
|
+
case 'tool_call':
|
|
65
|
+
return <ToolCallCard item={item} />
|
|
66
|
+
case 'turn_result':
|
|
67
|
+
return <TurnResultRow item={item} />
|
|
68
|
+
case 'notice':
|
|
69
|
+
return <NoticeRow item={item} />
|
|
70
|
+
case 'file_delivered':
|
|
71
|
+
return <FileCard item={item} href={fileUrl?.(item.path)} />
|
|
72
|
+
default:
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Should the "waiting for output" loader show? Only while running with no in-flight
|
|
78
|
+
* streamed content at the tail of the transcript. */
|
|
79
|
+
function showLoader(state: TranscriptState): boolean {
|
|
80
|
+
if (state.status !== 'running' && state.status !== 'starting') return false
|
|
81
|
+
const last = state.items.at(-1)
|
|
82
|
+
if (!last) return true
|
|
83
|
+
if (last.kind === 'assistant_text' && last.streaming) return false
|
|
84
|
+
if (last.kind === 'thinking' && last.id === 'streaming-thinking') return false
|
|
85
|
+
return last.kind !== 'turn_result' || state.status === 'running'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TranscriptProps {
|
|
89
|
+
state: TranscriptState
|
|
90
|
+
/** Builds the download URL for a delivered file (see FileCard). Typically
|
|
91
|
+
* `(path) => client.sessionFileUrl(sessionId, path)`. */
|
|
92
|
+
fileUrl?: (path: string) => string
|
|
93
|
+
className?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function Transcript({ state, fileUrl, className }: TranscriptProps) {
|
|
97
|
+
return (
|
|
98
|
+
<Conversation className={className}>
|
|
99
|
+
<ConversationContent>
|
|
100
|
+
{state.items.length === 0 && state.status !== 'starting' ? (
|
|
101
|
+
<div className='py-12 text-center text-body-sm text-fg-4'>No messages yet.</div>
|
|
102
|
+
) : (
|
|
103
|
+
state.items.map((item) => (
|
|
104
|
+
<TranscriptItemView key={`${item.kind}:${item.id}`} item={item} fileUrl={fileUrl} />
|
|
105
|
+
))
|
|
106
|
+
)}
|
|
107
|
+
{showLoader(state) ? (
|
|
108
|
+
<Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
|
|
109
|
+
) : null}
|
|
110
|
+
</ConversationContent>
|
|
111
|
+
<ConversationScrollButton />
|
|
112
|
+
</Conversation>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SessionStatus } from '@workerdeck/protocol'
|
|
2
|
+
import type { BadgeProps } from '../ui/Badge.tsx'
|
|
3
|
+
|
|
4
|
+
export const STATUS_META: Record<
|
|
5
|
+
SessionStatus,
|
|
6
|
+
{ label: string; variant: NonNullable<BadgeProps['variant']>; busy: boolean }
|
|
7
|
+
> = {
|
|
8
|
+
starting: { label: 'Starting', variant: 'info', busy: true },
|
|
9
|
+
running: { label: 'Running', variant: 'info', busy: true },
|
|
10
|
+
awaiting_approval: { label: 'Needs approval', variant: 'warning', busy: true },
|
|
11
|
+
idle: { label: 'Idle', variant: 'success', busy: false },
|
|
12
|
+
// Waiting on a deferred execution: the run is alive but nothing is burning.
|
|
13
|
+
parked: { label: 'Parked', variant: 'accent', busy: false },
|
|
14
|
+
failed: { label: 'Failed', variant: 'danger', busy: false },
|
|
15
|
+
closed: { label: 'Closed', variant: 'neutral', busy: false },
|
|
16
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
type AnimatedPlaceholderProps = {
|
|
6
|
+
texts: string[]
|
|
7
|
+
interval?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cross-fading placeholder that rotates through `texts`.
|
|
12
|
+
*
|
|
13
|
+
* Each text is keyed so React remounts it on change, replaying the
|
|
14
|
+
* `tw-animate-css` enter animation (slide down + fade in). No animation
|
|
15
|
+
* library is required.
|
|
16
|
+
*/
|
|
17
|
+
export function AnimatedPlaceholder({ texts, interval = 3000 }: AnimatedPlaceholderProps) {
|
|
18
|
+
const [index, setIndex] = useState(0)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (texts.length <= 1) return
|
|
22
|
+
|
|
23
|
+
const id = setInterval(() => {
|
|
24
|
+
setIndex((prev) => (prev + 1) % texts.length)
|
|
25
|
+
}, interval)
|
|
26
|
+
|
|
27
|
+
return () => clearInterval(id)
|
|
28
|
+
}, [texts.length, interval])
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className="pointer-events-none absolute top-0 left-0 overflow-hidden select-none"
|
|
33
|
+
style={{ color: 'var(--prompt-area-placeholder, var(--muted-foreground))' }}
|
|
34
|
+
aria-hidden="true">
|
|
35
|
+
<div
|
|
36
|
+
key={index}
|
|
37
|
+
className="animate-in fade-in-0 slide-in-from-top-4 duration-300 ease-in-out">
|
|
38
|
+
{texts[index]}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clipboard-related DOM utilities for the PromptArea component.
|
|
3
|
+
* Handles serializing selections and inserting pasted segments at the cursor.
|
|
4
|
+
*
|
|
5
|
+
* Not merged into dom-helpers.ts to avoid overcrowding that file with
|
|
6
|
+
* clipboard I/O concerns alongside its DOM traversal and chip-accessor helpers.
|
|
7
|
+
*/
|
|
8
|
+
import type { Segment, ChipSegment } from './types.ts'
|
|
9
|
+
import {
|
|
10
|
+
chipNodeToSegment,
|
|
11
|
+
getChipDisplay,
|
|
12
|
+
getChipTrigger,
|
|
13
|
+
getSelectionRange,
|
|
14
|
+
isChipElement,
|
|
15
|
+
isHTMLElement,
|
|
16
|
+
} from './dom-helpers.ts'
|
|
17
|
+
import { mergeAdjacentTextSegments } from './prompt-area-engine.ts'
|
|
18
|
+
import { getTextLengthInRange } from './cursor-helpers.ts'
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Visitor callbacks for {@link walkFragmentNodes}. Each fires for the
|
|
26
|
+
* corresponding node kind encountered during a depth-first walk.
|
|
27
|
+
*/
|
|
28
|
+
type FragmentVisitor = {
|
|
29
|
+
/** A text node — receives its text content (may be empty). */
|
|
30
|
+
onText: (text: string) => void
|
|
31
|
+
/** A chip element (has `data-chip-trigger`). */
|
|
32
|
+
onChip: (node: HTMLElement) => void
|
|
33
|
+
/** A `<br>` line break. */
|
|
34
|
+
onBreak: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Depth-first walk of a selection fragment, classifying each node as text,
|
|
39
|
+
* chip, or line break and recursing into any other element (decoration spans,
|
|
40
|
+
* anchors, browser-inserted wrappers).
|
|
41
|
+
*
|
|
42
|
+
* Both fragment serializers share this single traversal so they cannot drift
|
|
43
|
+
* on which nodes count as chips/breaks or how nested decorations are unwrapped.
|
|
44
|
+
*/
|
|
45
|
+
function walkFragmentNodes(fragment: DocumentFragment, visitor: FragmentVisitor): void {
|
|
46
|
+
const walk = (node: Node): void => {
|
|
47
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
48
|
+
visitor.onText(node.textContent ?? '')
|
|
49
|
+
} else if (isChipElement(node)) {
|
|
50
|
+
visitor.onChip(node)
|
|
51
|
+
} else if (isHTMLElement(node) && node.tagName === 'BR') {
|
|
52
|
+
visitor.onBreak()
|
|
53
|
+
} else {
|
|
54
|
+
node.childNodes.forEach(walk)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fragment.childNodes.forEach(walk)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Serializes a DocumentFragment (from selection) to plain text,
|
|
63
|
+
* converting chip elements to their `trigger + displayText` form.
|
|
64
|
+
*/
|
|
65
|
+
export function serializeFragmentToPlainText(fragment: DocumentFragment): string {
|
|
66
|
+
let text = ''
|
|
67
|
+
|
|
68
|
+
walkFragmentNodes(fragment, {
|
|
69
|
+
onText: (value) => {
|
|
70
|
+
text += value
|
|
71
|
+
},
|
|
72
|
+
onChip: (node) => {
|
|
73
|
+
text += (getChipTrigger(node) ?? '') + (getChipDisplay(node) ?? '')
|
|
74
|
+
},
|
|
75
|
+
onBreak: () => {
|
|
76
|
+
text += '\n'
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return text
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Serializes a DocumentFragment to an array of Segment objects,
|
|
85
|
+
* preserving chip data for internal copy/paste.
|
|
86
|
+
*/
|
|
87
|
+
export function serializeFragmentToSegments(fragment: DocumentFragment): Segment[] {
|
|
88
|
+
const segments: Segment[] = []
|
|
89
|
+
|
|
90
|
+
walkFragmentNodes(fragment, {
|
|
91
|
+
onText: (value) => {
|
|
92
|
+
if (value) segments.push({ type: 'text', text: value })
|
|
93
|
+
},
|
|
94
|
+
onChip: (node) => {
|
|
95
|
+
const chip = chipNodeToSegment(node)
|
|
96
|
+
if (chip) segments.push(chip)
|
|
97
|
+
},
|
|
98
|
+
onBreak: () => {
|
|
99
|
+
segments.push({ type: 'text', text: '\n' })
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return segments
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parses segment JSON from the clipboard. Returns null if invalid.
|
|
108
|
+
*/
|
|
109
|
+
export function parseSegmentsFromClipboard(json: string): Segment[] | null {
|
|
110
|
+
try {
|
|
111
|
+
const parsed: unknown = JSON.parse(json)
|
|
112
|
+
if (!Array.isArray(parsed)) return null
|
|
113
|
+
|
|
114
|
+
const segments: Segment[] = []
|
|
115
|
+
for (const item of parsed) {
|
|
116
|
+
if (!isRecord(item)) return null
|
|
117
|
+
|
|
118
|
+
if (item.type === 'text' && typeof item.text === 'string') {
|
|
119
|
+
segments.push({ type: 'text', text: item.text })
|
|
120
|
+
} else if (
|
|
121
|
+
item.type === 'chip' &&
|
|
122
|
+
typeof item.trigger === 'string' &&
|
|
123
|
+
typeof item.value === 'string' &&
|
|
124
|
+
typeof item.displayText === 'string'
|
|
125
|
+
) {
|
|
126
|
+
const chip: ChipSegment = {
|
|
127
|
+
type: 'chip',
|
|
128
|
+
trigger: item.trigger,
|
|
129
|
+
value: item.value,
|
|
130
|
+
displayText: item.displayText,
|
|
131
|
+
...(item.data !== undefined ? { data: item.data } : {}),
|
|
132
|
+
...(item.autoResolved ? { autoResolved: true } : {}),
|
|
133
|
+
}
|
|
134
|
+
segments.push(chip)
|
|
135
|
+
} else {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return segments
|
|
141
|
+
} catch {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Inserts pasted segments at the current cursor position within existing segments.
|
|
148
|
+
* Splits any text segment that straddles the cursor so the pasted content lands
|
|
149
|
+
* exactly at the cursor and nothing before or after is lost.
|
|
150
|
+
*/
|
|
151
|
+
export function insertSegmentsAtCursor(
|
|
152
|
+
currentSegments: Segment[],
|
|
153
|
+
pastedSegments: Segment[],
|
|
154
|
+
editor: HTMLElement,
|
|
155
|
+
): Segment[] {
|
|
156
|
+
const range = getSelectionRange()
|
|
157
|
+
if (!range) return [...currentSegments, ...pastedSegments]
|
|
158
|
+
|
|
159
|
+
const preRange = document.createRange()
|
|
160
|
+
preRange.selectNodeContents(editor)
|
|
161
|
+
preRange.setEnd(range.startContainer, range.startOffset)
|
|
162
|
+
const cursorOffset = getTextLengthInRange(preRange)
|
|
163
|
+
|
|
164
|
+
const result: Segment[] = []
|
|
165
|
+
let offset = 0
|
|
166
|
+
let inserted = false
|
|
167
|
+
|
|
168
|
+
const insertOnce = (): void => {
|
|
169
|
+
if (!inserted) {
|
|
170
|
+
result.push(...pastedSegments)
|
|
171
|
+
inserted = true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const seg of currentSegments) {
|
|
176
|
+
if (seg.type === 'chip') {
|
|
177
|
+
const chipLen = seg.trigger.length + seg.displayText.length
|
|
178
|
+
if (offset >= cursorOffset) insertOnce()
|
|
179
|
+
result.push(seg)
|
|
180
|
+
offset += chipLen
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const segEnd = offset + seg.text.length
|
|
185
|
+
if (segEnd <= cursorOffset) {
|
|
186
|
+
// Entirely before the cursor
|
|
187
|
+
result.push(seg)
|
|
188
|
+
} else if (offset >= cursorOffset) {
|
|
189
|
+
// Entirely after the cursor
|
|
190
|
+
insertOnce()
|
|
191
|
+
result.push(seg)
|
|
192
|
+
} else {
|
|
193
|
+
// Cursor falls inside this text segment — split it.
|
|
194
|
+
const splitAt = cursorOffset - offset
|
|
195
|
+
const before = seg.text.slice(0, splitAt)
|
|
196
|
+
const after = seg.text.slice(splitAt)
|
|
197
|
+
if (before) result.push({ type: 'text', text: before })
|
|
198
|
+
insertOnce()
|
|
199
|
+
if (after) result.push({ type: 'text', text: after })
|
|
200
|
+
}
|
|
201
|
+
offset = segEnd
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
insertOnce()
|
|
205
|
+
return mergeAdjacentTextSegments(result)
|
|
206
|
+
}
|