@kitn.ai/ui 0.14.1
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 +394 -0
- package/dist/bash-InADTalH.js +1 -0
- package/dist/core-AYMC6_lb.js +12 -0
- package/dist/css-M7EaDHN_.js +1 -0
- package/dist/custom-elements.json +4739 -0
- package/dist/engine-javascript-vq0WuIJl.js +141 -0
- package/dist/github-dark-dimmed-DUshB20C.js +1 -0
- package/dist/github-light-JYsPkUQd.js +1 -0
- package/dist/host-embed.d.ts +33 -0
- package/dist/html-CPZ3oZQ7.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/javascript-C25yR2R2.js +1 -0
- package/dist/json-DxJze_jm.js +1 -0
- package/dist/kitn-chat-provider.d.ts +2 -0
- package/dist/kitn-chat-provider.es.js +240 -0
- package/dist/kitn-chat.es.js +77 -0
- package/dist/llms/llms-full.txt +1018 -0
- package/dist/llms/llms.txt +104 -0
- package/dist/origin.d.ts +5 -0
- package/dist/provider-runtime.d.ts +27 -0
- package/dist/schemas/card-envelope.schema.json +14 -0
- package/dist/schemas/card-event.schema.json +12 -0
- package/dist/schemas/choice.schema.json +70 -0
- package/dist/schemas/confirm.schema.json +65 -0
- package/dist/schemas/embed.schema.json +65 -0
- package/dist/schemas/form.result.schema.json +7 -0
- package/dist/schemas/form.schema.json +33 -0
- package/dist/schemas/link.schema.json +56 -0
- package/dist/schemas/tasks.result.schema.json +16 -0
- package/dist/schemas/tasks.schema.json +78 -0
- package/dist/svelte--5p79yCD.js +1 -0
- package/dist/theme.tokens.css +176 -0
- package/dist/tsx-B8rCNbgL.js +1 -0
- package/dist/typescript-RycA9KXf.js +1 -0
- package/dist/validate.d.ts +4 -0
- package/dist/version.d.ts +4 -0
- package/dist/vue-BmIZj4XD.js +1 -0
- package/dist/wire.d.ts +53 -0
- package/frameworks/react/index.tsx +948 -0
- package/frameworks/react/runtime.tsx +94 -0
- package/llms-full.txt +1018 -0
- package/llms.txt +104 -0
- package/package.json +141 -0
- package/src/components/artifact.stories.tsx +138 -0
- package/src/components/artifact.tsx +743 -0
- package/src/components/attachments.stories.tsx +303 -0
- package/src/components/attachments.test.tsx +64 -0
- package/src/components/attachments.tsx +399 -0
- package/src/components/card-fallback.tsx +28 -0
- package/src/components/card-renderer.tsx +52 -0
- package/src/components/card.tsx +110 -0
- package/src/components/chain-of-thought.stories.tsx +211 -0
- package/src/components/chain-of-thought.tsx +139 -0
- package/src/components/chat-container.stories.tsx +187 -0
- package/src/components/chat-container.tsx +82 -0
- package/src/components/chat-scope-picker.tsx +47 -0
- package/src/components/chat-thread.test.tsx +58 -0
- package/src/components/chat-thread.tsx +249 -0
- package/src/components/checkpoint.stories.tsx +102 -0
- package/src/components/checkpoint.tsx +88 -0
- package/src/components/choice-card.tsx +569 -0
- package/src/components/code-block.stories.tsx +150 -0
- package/src/components/code-block.tsx +105 -0
- package/src/components/component-meta.json +3735 -0
- package/src/components/confirm-card.stories.tsx +74 -0
- package/src/components/confirm-card.tsx +315 -0
- package/src/components/context.stories.tsx +229 -0
- package/src/components/context.test.tsx +133 -0
- package/src/components/context.tsx +363 -0
- package/src/components/conversation-item.stories.tsx +125 -0
- package/src/components/conversation-item.tsx +16 -0
- package/src/components/conversation-list.stories.tsx +133 -0
- package/src/components/conversation-list.tsx +101 -0
- package/src/components/default-input.test.tsx +111 -0
- package/src/components/embed.tsx +196 -0
- package/src/components/empty.stories.tsx +434 -0
- package/src/components/empty.tsx +166 -0
- package/src/components/feedback-bar.stories.tsx +138 -0
- package/src/components/feedback-bar.test.tsx +103 -0
- package/src/components/feedback-bar.tsx +166 -0
- package/src/components/file-tree.stories.tsx +73 -0
- package/src/components/file-tree.tsx +383 -0
- package/src/components/file-upload.stories.tsx +156 -0
- package/src/components/file-upload.tsx +161 -0
- package/src/components/form-widgets.tsx +461 -0
- package/src/components/form.tsx +873 -0
- package/src/components/image.stories.tsx +89 -0
- package/src/components/image.tsx +67 -0
- package/src/components/link-preview.tsx +194 -0
- package/src/components/loader.stories.tsx +181 -0
- package/src/components/loader.tsx +333 -0
- package/src/components/markdown.stories.tsx +180 -0
- package/src/components/markdown.tsx +81 -0
- package/src/components/message-action-bar.test.tsx +128 -0
- package/src/components/message-narrow.stories.tsx +329 -0
- package/src/components/message-skills.stories.tsx +211 -0
- package/src/components/message-skills.tsx +36 -0
- package/src/components/message.stories.tsx +281 -0
- package/src/components/message.tsx +227 -0
- package/src/components/model-switcher.stories.tsx +123 -0
- package/src/components/model-switcher.test.tsx +65 -0
- package/src/components/model-switcher.tsx +87 -0
- package/src/components/prompt-input.stories.tsx +222 -0
- package/src/components/prompt-input.tsx +211 -0
- package/src/components/prompt-suggestion.stories.tsx +142 -0
- package/src/components/prompt-suggestion.tsx +115 -0
- package/src/components/reasoning.stories.tsx +140 -0
- package/src/components/reasoning.tsx +156 -0
- package/src/components/resizable.test.tsx +226 -0
- package/src/components/response-stream.tsx +103 -0
- package/src/components/scroll-button.stories.tsx +100 -0
- package/src/components/scroll-button.tsx +34 -0
- package/src/components/slash-command.stories.tsx +163 -0
- package/src/components/slash-command.tsx +232 -0
- package/src/components/source-list.test.tsx +152 -0
- package/src/components/source.stories.tsx +124 -0
- package/src/components/source.tsx +129 -0
- package/src/components/tasks-card.stories.tsx +78 -0
- package/src/components/tasks-card.tsx +432 -0
- package/src/components/text-shimmer.stories.tsx +87 -0
- package/src/components/text-shimmer.tsx +37 -0
- package/src/components/thinking-bar.stories.tsx +87 -0
- package/src/components/thinking-bar.tsx +50 -0
- package/src/components/tool.stories.tsx +153 -0
- package/src/components/tool.tsx +184 -0
- package/src/components/use-card-resolution.ts +28 -0
- package/src/components/voice-input.stories.tsx +83 -0
- package/src/components/voice-input.tsx +107 -0
- package/src/elements/artifact.stories.tsx +506 -0
- package/src/elements/artifact.tsx +137 -0
- package/src/elements/attachments.stories.tsx +190 -0
- package/src/elements/attachments.tsx +136 -0
- package/src/elements/card.stories.tsx +113 -0
- package/src/elements/card.tsx +40 -0
- package/src/elements/cards.stories.tsx +59 -0
- package/src/elements/cards.tsx +97 -0
- package/src/elements/catalog.stories.tsx +491 -0
- package/src/elements/chain-of-thought.declarative.test.tsx +153 -0
- package/src/elements/chain-of-thought.stories.tsx +113 -0
- package/src/elements/chain-of-thought.tsx +92 -0
- package/src/elements/chat-scope-picker.stories.tsx +73 -0
- package/src/elements/chat-scope-picker.tsx +36 -0
- package/src/elements/chat-types.ts +42 -0
- package/src/elements/chat-workspace.stories.tsx +288 -0
- package/src/elements/chat-workspace.tsx +161 -0
- package/src/elements/chat.stories.tsx +151 -0
- package/src/elements/chat.tsx +80 -0
- package/src/elements/checkpoint.stories.tsx +72 -0
- package/src/elements/checkpoint.tsx +47 -0
- package/src/elements/choice.stories.tsx +240 -0
- package/src/elements/choice.tsx +48 -0
- package/src/elements/code-block.stories.tsx +83 -0
- package/src/elements/code-block.tsx +42 -0
- package/src/elements/compiled.css +2 -0
- package/src/elements/composed-shell.stories.tsx +316 -0
- package/src/elements/confirm-card.stories.tsx +227 -0
- package/src/elements/confirm-card.tsx +51 -0
- package/src/elements/context-meter.stories.tsx +151 -0
- package/src/elements/context-meter.tsx +121 -0
- package/src/elements/conversation-list.declarative.test.tsx +137 -0
- package/src/elements/conversation-list.stories.tsx +243 -0
- package/src/elements/conversation-list.tsx +80 -0
- package/src/elements/css.ts +5 -0
- package/src/elements/default-input.tsx +247 -0
- package/src/elements/define.tsx +171 -0
- package/src/elements/element-meta.json +3314 -0
- package/src/elements/element-types.d.ts +710 -0
- package/src/elements/embed.stories.tsx +213 -0
- package/src/elements/embed.tsx +35 -0
- package/src/elements/empty.stories.tsx +111 -0
- package/src/elements/empty.tsx +29 -0
- package/src/elements/feedback-bar.stories.tsx +114 -0
- package/src/elements/feedback-bar.tsx +61 -0
- package/src/elements/file-tree.stories.tsx +134 -0
- package/src/elements/file-tree.tsx +52 -0
- package/src/elements/file-upload.stories.tsx +82 -0
- package/src/elements/file-upload.tsx +44 -0
- package/src/elements/form.stories.tsx +248 -0
- package/src/elements/form.tsx +43 -0
- package/src/elements/framework-usage.json +563 -0
- package/src/elements/image.stories.tsx +71 -0
- package/src/elements/image.tsx +32 -0
- package/src/elements/link-preview.stories.tsx +209 -0
- package/src/elements/link-preview.tsx +34 -0
- package/src/elements/loader.stories.tsx +88 -0
- package/src/elements/loader.tsx +25 -0
- package/src/elements/markdown.stories.tsx +76 -0
- package/src/elements/markdown.tsx +38 -0
- package/src/elements/message-skills.declarative.test.tsx +104 -0
- package/src/elements/message-skills.stories.tsx +114 -0
- package/src/elements/message-skills.tsx +85 -0
- package/src/elements/message.stories.tsx +218 -0
- package/src/elements/message.tsx +162 -0
- package/src/elements/model-switcher.declarative.test.tsx +130 -0
- package/src/elements/model-switcher.stories.tsx +198 -0
- package/src/elements/model-switcher.tsx +93 -0
- package/src/elements/popover.stories.tsx +80 -0
- package/src/elements/popover.tsx +59 -0
- package/src/elements/prompt-input-slash-command.test.tsx +128 -0
- package/src/elements/prompt-input-stoppable.test.tsx +88 -0
- package/src/elements/prompt-input-toolbar-actions.test.tsx +120 -0
- package/src/elements/prompt-input.stories.tsx +331 -0
- package/src/elements/prompt-input.tsx +185 -0
- package/src/elements/prompt-suggestions.declarative.test.tsx +164 -0
- package/src/elements/prompt-suggestions.stories.tsx +213 -0
- package/src/elements/prompt-suggestions.tsx +94 -0
- package/src/elements/reasoning.stories.tsx +77 -0
- package/src/elements/reasoning.tsx +50 -0
- package/src/elements/register.ts +56 -0
- package/src/elements/remote.stories.tsx +314 -0
- package/src/elements/remote.tsx +221 -0
- package/src/elements/resizable.d.ts +27 -0
- package/src/elements/resizable.stories.tsx +426 -0
- package/src/elements/resizable.tsx +553 -0
- package/src/elements/response-stream.stories.tsx +79 -0
- package/src/elements/response-stream.tsx +40 -0
- package/src/elements/scroll-button.stories.tsx +202 -0
- package/src/elements/scroll-button.test.tsx +148 -0
- package/src/elements/scroll-button.tsx +126 -0
- package/src/elements/source-list.stories.tsx +143 -0
- package/src/elements/source.stories.tsx +88 -0
- package/src/elements/source.tsx +120 -0
- package/src/elements/styles.css +199 -0
- package/src/elements/switch.stories.tsx +56 -0
- package/src/elements/switch.tsx +43 -0
- package/src/elements/tasks.stories.tsx +237 -0
- package/src/elements/tasks.tsx +46 -0
- package/src/elements/text-shimmer.stories.tsx +64 -0
- package/src/elements/text-shimmer.tsx +28 -0
- package/src/elements/thinking-bar.stories.tsx +72 -0
- package/src/elements/thinking-bar.tsx +34 -0
- package/src/elements/tool.stories.tsx +89 -0
- package/src/elements/tool.tsx +23 -0
- package/src/elements/voice-input.stories.tsx +88 -0
- package/src/elements/voice-input.tsx +41 -0
- package/src/index.ts +253 -0
- package/src/primitives/card-contract.ts +71 -0
- package/src/primitives/card-host.tsx +35 -0
- package/src/primitives/card-registry.tsx +67 -0
- package/src/primitives/card-resolution.ts +36 -0
- package/src/primitives/card-routing.ts +79 -0
- package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
- package/src/primitives/card-schemas/card-event.schema.json +12 -0
- package/src/primitives/card-schemas/choice.schema.json +70 -0
- package/src/primitives/card-schemas/confirm.schema.json +65 -0
- package/src/primitives/card-schemas/embed.schema.json +65 -0
- package/src/primitives/card-schemas/form.result.schema.json +7 -0
- package/src/primitives/card-schemas/form.schema.json +33 -0
- package/src/primitives/card-schemas/link.schema.json +56 -0
- package/src/primitives/card-schemas/tasks.result.schema.json +16 -0
- package/src/primitives/card-schemas/tasks.schema.json +78 -0
- package/src/primitives/card-validate.ts +95 -0
- package/src/primitives/chat-config.tsx +76 -0
- package/src/primitives/controllable.test.ts +47 -0
- package/src/primitives/controllable.ts +21 -0
- package/src/primitives/embed-providers.ts +254 -0
- package/src/primitives/highlighter.ts +157 -0
- package/src/primitives/link-preview.ts +87 -0
- package/src/primitives/pdf-preview.ts +121 -0
- package/src/primitives/use-auto-resize.ts +31 -0
- package/src/primitives/use-resize-observer.ts +12 -0
- package/src/primitives/use-stick-to-bottom.ts +43 -0
- package/src/primitives/use-text-stream.ts +112 -0
- package/src/primitives/use-voice-recorder.ts +50 -0
- package/src/remote/host-embed.ts +345 -0
- package/src/remote/index.ts +2 -0
- package/src/remote/origin.ts +30 -0
- package/src/remote/provider-runtime.ts +259 -0
- package/src/remote/provider.ts +2 -0
- package/src/remote/validate.ts +22 -0
- package/src/remote/version.ts +12 -0
- package/src/remote/wire.ts +48 -0
- package/src/stories/chat-panel-layout.stories.tsx +145 -0
- package/src/stories/chat-scene.tsx +571 -0
- package/src/stories/checkpoint-restore.stories.tsx +256 -0
- package/src/stories/context-usage.stories.tsx +212 -0
- package/src/stories/conversation-with-reasoning.stories.tsx +182 -0
- package/src/stories/conversation-with-sources.stories.tsx +471 -0
- package/src/stories/docs/Accessibility.mdx +119 -0
- package/src/stories/docs/ChildElements.mdx +269 -0
- package/src/stories/docs/ForAIAgents.mdx +162 -0
- package/src/stories/docs/GettingStarted.mdx +65 -0
- package/src/stories/docs/Installation.mdx +83 -0
- package/src/stories/docs/Introduction.mdx +50 -0
- package/src/stories/docs/SolidJsAdvanced.mdx +17 -0
- package/src/stories/docs/Theming.mdx +85 -0
- package/src/stories/docs/element-controls.ts +121 -0
- package/src/stories/docs/frameworks/Angular.mdx +245 -0
- package/src/stories/docs/frameworks/Html.mdx +227 -0
- package/src/stories/docs/frameworks/Overview.mdx +47 -0
- package/src/stories/docs/frameworks/React.mdx +227 -0
- package/src/stories/docs/frameworks/Solid.mdx +173 -0
- package/src/stories/docs/frameworks/Svelte.mdx +194 -0
- package/src/stories/docs/frameworks/Vue.mdx +238 -0
- package/src/stories/docs/generative-ui-overview.mdx +186 -0
- package/src/stories/docs/recipes/SpeechToText.mdx +7 -0
- package/src/stories/docs/recipes/Streaming.mdx +68 -0
- package/src/stories/docs/recipes/TextToSpeech.mdx +35 -0
- package/src/stories/docs/theme-editor/canvas.tsx +32 -0
- package/src/stories/docs/theme-editor/inspector.tsx +66 -0
- package/src/stories/docs/theme-editor/presets.test.ts +32 -0
- package/src/stories/docs/theme-editor/presets.ts +64 -0
- package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
- package/src/stories/docs/theme-editor/theme-css.ts +15 -0
- package/src/stories/docs/theme-editor/theme-editor.tsx +146 -0
- package/src/stories/docs/theme-tokens.tsx +174 -0
- package/src/stories/examples/ChoosingComponents.mdx +106 -0
- package/src/stories/examples/sample-data.ts +79 -0
- package/src/stories/examples/usage/checkpoint-restore.ts +156 -0
- package/src/stories/examples/usage/context-usage.ts +591 -0
- package/src/stories/examples/usage/conversation-with-reasoning.ts +224 -0
- package/src/stories/examples/usage/conversation-with-sources.ts +589 -0
- package/src/stories/examples/usage/empty-state.ts +144 -0
- package/src/stories/examples/usage/full-chat-app.ts +277 -0
- package/src/stories/examples/usage/index.ts +63 -0
- package/src/stories/examples/usage/message-actions.ts +704 -0
- package/src/stories/examples/usage/pattern-centered-conversation.ts +446 -0
- package/src/stories/examples/usage/pattern-chat-panel-layout.ts +442 -0
- package/src/stories/examples/usage/pattern-docked-widget.ts +479 -0
- package/src/stories/examples/usage/prompt-input-variants.ts +1356 -0
- package/src/stories/examples/usage/streaming-response.ts +687 -0
- package/src/stories/examples/usage/types.ts +33 -0
- package/src/stories/full-chat.stories.tsx +18 -0
- package/src/stories/message-actions.stories.tsx +230 -0
- package/src/stories/pattern-centered-conversation.stories.tsx +93 -0
- package/src/stories/pattern-docked-widget.stories.tsx +93 -0
- package/src/stories/pattern-empty-state.stories.tsx +76 -0
- package/src/stories/prompt-input-variants.stories.tsx +456 -0
- package/src/stories/streaming-response.stories.tsx +438 -0
- package/src/stories/theme-editor.stories.tsx +16 -0
- package/src/stories/token-reference.stories.tsx +18 -0
- package/src/stories/typography.stories.tsx +78 -0
- package/src/types.ts +47 -0
- package/src/ui/action-icons.ts +52 -0
- package/src/ui/avatar.stories.tsx +103 -0
- package/src/ui/avatar.tsx +23 -0
- package/src/ui/badge.stories.tsx +86 -0
- package/src/ui/badge.tsx +21 -0
- package/src/ui/button.stories.tsx +145 -0
- package/src/ui/button.tsx +38 -0
- package/src/ui/collapsible.stories.tsx +69 -0
- package/src/ui/collapsible.tsx +125 -0
- package/src/ui/dropdown.stories.tsx +59 -0
- package/src/ui/dropdown.tsx +196 -0
- package/src/ui/hover-card.stories.tsx +77 -0
- package/src/ui/hover-card.test.tsx +30 -0
- package/src/ui/hover-card.tsx +172 -0
- package/src/ui/overlay.stories.tsx +115 -0
- package/src/ui/overlay.tsx +158 -0
- package/src/ui/popover.stories.tsx +81 -0
- package/src/ui/popover.test.tsx +99 -0
- package/src/ui/popover.tsx +92 -0
- package/src/ui/resizable.stories.tsx +236 -0
- package/src/ui/resizable.tsx +576 -0
- package/src/ui/scroll-area.stories.tsx +50 -0
- package/src/ui/scroll-area.tsx +15 -0
- package/src/ui/separator.stories.tsx +81 -0
- package/src/ui/separator.tsx +10 -0
- package/src/ui/skeleton.stories.tsx +337 -0
- package/src/ui/skeleton.tsx +16 -0
- package/src/ui/switch.stories.tsx +51 -0
- package/src/ui/switch.test.tsx +59 -0
- package/src/ui/switch.tsx +62 -0
- package/src/ui/textarea.stories.tsx +76 -0
- package/src/ui/textarea.tsx +21 -0
- package/src/ui/tooltip.stories.tsx +74 -0
- package/src/ui/tooltip.tsx +68 -0
- package/src/utils/cn.ts +24 -0
- package/theme.css +187 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type JSX,
|
|
3
|
+
splitProps,
|
|
4
|
+
mergeProps,
|
|
5
|
+
createSignal,
|
|
6
|
+
createEffect,
|
|
7
|
+
createMemo,
|
|
8
|
+
on,
|
|
9
|
+
onMount,
|
|
10
|
+
onCleanup,
|
|
11
|
+
Show,
|
|
12
|
+
} from 'solid-js';
|
|
13
|
+
import { cn } from '../utils/cn';
|
|
14
|
+
import { Button } from '../ui/button';
|
|
15
|
+
import { CodeBlock, CodeBlockCode } from './code-block';
|
|
16
|
+
import { FileTree, type FileTreeFile } from './file-tree';
|
|
17
|
+
import { Loader } from './loader';
|
|
18
|
+
import { isPdfPreviewEnabled, renderPdfInto } from '../primitives/pdf-preview';
|
|
19
|
+
import {
|
|
20
|
+
ArrowLeft,
|
|
21
|
+
ArrowRight,
|
|
22
|
+
RotateCw,
|
|
23
|
+
House,
|
|
24
|
+
Eye,
|
|
25
|
+
Code as CodeIcon,
|
|
26
|
+
FileText,
|
|
27
|
+
ExternalLink,
|
|
28
|
+
Download,
|
|
29
|
+
Maximize2,
|
|
30
|
+
Minimize2,
|
|
31
|
+
} from 'lucide-solid';
|
|
32
|
+
|
|
33
|
+
export type ArtifactTab = 'preview' | 'code';
|
|
34
|
+
|
|
35
|
+
/** A file the artifact can preview + show source for. */
|
|
36
|
+
export type ArtifactFile = FileTreeFile;
|
|
37
|
+
|
|
38
|
+
export interface ArtifactProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
|
|
39
|
+
/** URL the preview iframe frames. */
|
|
40
|
+
src?: string;
|
|
41
|
+
/** Files for the Code tab's tree (+ each file's preview `url`). */
|
|
42
|
+
files?: ArtifactFile[];
|
|
43
|
+
/** Active tab. Default `preview`. */
|
|
44
|
+
tab?: ArtifactTab;
|
|
45
|
+
/** Selected file path (syncs tree highlight + Code source + preview). */
|
|
46
|
+
activeFile?: string;
|
|
47
|
+
/** iframe `sandbox` override. Default `allow-scripts allow-forms`. */
|
|
48
|
+
sandbox?: string;
|
|
49
|
+
/** Accessible iframe title. */
|
|
50
|
+
iframeTitle?: string;
|
|
51
|
+
/** Fired when the preview navigates (back/forward/reload/path-edit/file-click). */
|
|
52
|
+
onNavigate?: (url: string) => void;
|
|
53
|
+
/** Fired when the Preview|Code tab changes. */
|
|
54
|
+
onTabChange?: (tab: ArtifactTab) => void;
|
|
55
|
+
/** Fired when a file is selected in the tree. */
|
|
56
|
+
onFileSelect?: (path: string) => void;
|
|
57
|
+
// view-state
|
|
58
|
+
/** Controlled maximize view-state (drives the expand/restore button). */
|
|
59
|
+
maximized?: boolean;
|
|
60
|
+
/** Fired when the expand/restore button toggles the maximize view-state. */
|
|
61
|
+
onMaximizeChange?: (maximized: boolean) => void;
|
|
62
|
+
// toolbar composition — existing five default SHOWN (no-* flags invert in the facade)
|
|
63
|
+
/** Show the back/forward nav buttons. Default `true`. */
|
|
64
|
+
showNav?: boolean;
|
|
65
|
+
/** Show the reload button. Default `true`. */
|
|
66
|
+
showReload?: boolean;
|
|
67
|
+
/** Show the home button. Default `true`. */
|
|
68
|
+
showHome?: boolean;
|
|
69
|
+
/** Show the editable path/address field. Default `true`. */
|
|
70
|
+
showPathField?: boolean;
|
|
71
|
+
/** Show the Preview|Code tab toggle. Default `true`. */
|
|
72
|
+
showTabs?: boolean;
|
|
73
|
+
// new affordances — OPT-IN (default hidden; see resolved decision #2)
|
|
74
|
+
/** Show the expand-to-fill button. Default `false` (opt-in). */
|
|
75
|
+
expandable?: boolean;
|
|
76
|
+
/** Show the open-in-new-tab button. Default `false` (opt-in). */
|
|
77
|
+
openInTab?: boolean;
|
|
78
|
+
// chrome
|
|
79
|
+
/** Standalone chrome: rounded + bordered (default in-panel = square, borderless). */
|
|
80
|
+
standalone?: boolean;
|
|
81
|
+
/** Make the path field read-only (visible, nav-tracking, non-editable). */
|
|
82
|
+
readonlyPath?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DEFAULT_SANDBOX = 'allow-scripts allow-forms';
|
|
86
|
+
|
|
87
|
+
/** Resolve a file's preview URL: explicit `url`, else `<src-origin> + /path`. */
|
|
88
|
+
function resolveFileUrl(file: ArtifactFile, src: string | undefined): string {
|
|
89
|
+
if (file.url) return file.url;
|
|
90
|
+
if (!src) return file.path;
|
|
91
|
+
try {
|
|
92
|
+
return new URL(file.path, src).href;
|
|
93
|
+
} catch {
|
|
94
|
+
return file.path;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when `url` should render as a PDF: a matching `files` entry is typed
|
|
99
|
+
* `'pdf'`, or the URL path (query/hash stripped) ends in `.pdf`. */
|
|
100
|
+
export function isPdfUrl(url: string, files: ArtifactFile[]): boolean {
|
|
101
|
+
if (!url) return false;
|
|
102
|
+
const match = files.find((f) => f.url === url || f.path === url);
|
|
103
|
+
if (match?.type === 'pdf') return true;
|
|
104
|
+
const path = url.split(/[?#]/)[0];
|
|
105
|
+
return /\.pdf$/i.test(path);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `Artifact` — a framed, switchable generated-artifact viewer. A functional nav
|
|
110
|
+
* toolbar (back · forward · reload · home + editable path field + Preview|Code
|
|
111
|
+
* toggle) over a sandboxed `<iframe>` (Preview) or a file-tree + `<kai-code-block>`
|
|
112
|
+
* (Code). The component self-navigates the iframe and emits `kai-navigate` /
|
|
113
|
+
* `kai-tab-change` / `kai-file-select` so a consumer can observe/sync.
|
|
114
|
+
*/
|
|
115
|
+
export function Artifact(props: ArtifactProps): JSX.Element {
|
|
116
|
+
const merged = mergeProps(
|
|
117
|
+
{
|
|
118
|
+
files: [] as ArtifactFile[],
|
|
119
|
+
tab: 'preview' as ArtifactTab,
|
|
120
|
+
sandbox: DEFAULT_SANDBOX,
|
|
121
|
+
showNav: true,
|
|
122
|
+
showReload: true,
|
|
123
|
+
showHome: true,
|
|
124
|
+
showPathField: true,
|
|
125
|
+
showTabs: true,
|
|
126
|
+
expandable: false,
|
|
127
|
+
openInTab: false,
|
|
128
|
+
standalone: false,
|
|
129
|
+
readonlyPath: false,
|
|
130
|
+
maximized: false,
|
|
131
|
+
},
|
|
132
|
+
props,
|
|
133
|
+
);
|
|
134
|
+
const [local, rest] = splitProps(merged, [
|
|
135
|
+
'src',
|
|
136
|
+
'files',
|
|
137
|
+
'tab',
|
|
138
|
+
'activeFile',
|
|
139
|
+
'sandbox',
|
|
140
|
+
'iframeTitle',
|
|
141
|
+
'onNavigate',
|
|
142
|
+
'onTabChange',
|
|
143
|
+
'onFileSelect',
|
|
144
|
+
'maximized',
|
|
145
|
+
'onMaximizeChange',
|
|
146
|
+
'showNav',
|
|
147
|
+
'showReload',
|
|
148
|
+
'showHome',
|
|
149
|
+
'showPathField',
|
|
150
|
+
'showTabs',
|
|
151
|
+
'expandable',
|
|
152
|
+
'openInTab',
|
|
153
|
+
'standalone',
|
|
154
|
+
'readonlyPath',
|
|
155
|
+
'class',
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
// A sandboxed iframe WITHOUT `allow-same-origin` makes the framed document's
|
|
159
|
+
// `contentWindow.history`/`location` opaque (a deliberate security property —
|
|
160
|
+
// see the spec). So the component keeps its OWN history stack for the
|
|
161
|
+
// navigations it initiates (path-edit, file-click, home, src changes) and
|
|
162
|
+
// back/forward/reload operate on that. (In-frame relative-link clicks navigate
|
|
163
|
+
// the iframe itself but can't be observed cross-origin — a known, accepted
|
|
164
|
+
// sandbox limitation; opt into `allow-same-origin` if the consumer trusts the
|
|
165
|
+
// artifact and wants those tracked.)
|
|
166
|
+
const [history, setHistory] = createSignal<string[]>(local.src ? [local.src] : []);
|
|
167
|
+
const [cursor, setCursor] = createSignal(local.src ? 0 : -1);
|
|
168
|
+
const currentUrl = () => history()[cursor()] ?? '';
|
|
169
|
+
const canBack = () => cursor() > 0;
|
|
170
|
+
const canForward = () => cursor() < history().length - 1;
|
|
171
|
+
|
|
172
|
+
const [tab, setTab] = createSignal<ArtifactTab>(local.tab);
|
|
173
|
+
const [activeFile, setActiveFile] = createSignal<string | undefined>(local.activeFile);
|
|
174
|
+
const [reloadKey, setReloadKey] = createSignal(0);
|
|
175
|
+
|
|
176
|
+
// Maximize view-state — controlled by `local.maximized`, toggled by the button.
|
|
177
|
+
const [maximized, setMaximized] = createSignal<boolean>(local.maximized ?? false);
|
|
178
|
+
createEffect(() => setMaximized(local.maximized ?? false));
|
|
179
|
+
const toggleMaximize = () => {
|
|
180
|
+
const next = !maximized();
|
|
181
|
+
setMaximized(next);
|
|
182
|
+
local.onMaximizeChange?.(next);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Open-in-tab — only enabled when there's a concrete url to open.
|
|
186
|
+
const canOpenInTab = createMemo(() => {
|
|
187
|
+
const u = currentUrl();
|
|
188
|
+
return !!u && u !== 'about:blank';
|
|
189
|
+
});
|
|
190
|
+
const openInNewTab = () => {
|
|
191
|
+
if (!canOpenInTab()) return;
|
|
192
|
+
window.open(currentUrl(), '_blank', 'noopener,noreferrer');
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// The expand button is suppressed in standalone (no enclosing resizable).
|
|
196
|
+
const showExpand = createMemo(() => local.expandable && !local.standalone);
|
|
197
|
+
// Omit the whole toolbar when nothing is shown.
|
|
198
|
+
const showAnyToolbar = createMemo(
|
|
199
|
+
() =>
|
|
200
|
+
local.showNav ||
|
|
201
|
+
local.showReload ||
|
|
202
|
+
local.showHome ||
|
|
203
|
+
local.showPathField ||
|
|
204
|
+
local.showTabs ||
|
|
205
|
+
showExpand() ||
|
|
206
|
+
local.openInTab,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
let iframeEl: HTMLIFrameElement | undefined;
|
|
210
|
+
|
|
211
|
+
// Controlled syncing: when the consumer changes the props, follow them.
|
|
212
|
+
createEffect(() => setTab(local.tab));
|
|
213
|
+
createEffect(() => setActiveFile(local.activeFile));
|
|
214
|
+
// `src` change → navigate. Use `on(local.src, …)` so the effect tracks ONLY
|
|
215
|
+
// the `src` prop — NOT `currentUrl()` — otherwise navigating away (file click,
|
|
216
|
+
// path edit) would re-trigger it and snap the iframe back to `src`. Skip the
|
|
217
|
+
// initial run: history is already seeded with `src` and the iframe binds it.
|
|
218
|
+
createEffect(
|
|
219
|
+
on(
|
|
220
|
+
() => local.src,
|
|
221
|
+
(next) => {
|
|
222
|
+
if (next && next !== currentUrl()) navigate(next);
|
|
223
|
+
},
|
|
224
|
+
{ defer: true },
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const fileFor = (path: string | undefined): ArtifactFile | undefined =>
|
|
229
|
+
path === undefined ? undefined : local.files.find((f) => f.path === path);
|
|
230
|
+
|
|
231
|
+
const activeFileObj = createMemo(() => fileFor(activeFile()));
|
|
232
|
+
// Code applies only to text-ish files that carry source.
|
|
233
|
+
const hasSource = createMemo(() => {
|
|
234
|
+
const f = activeFileObj();
|
|
235
|
+
return !!f && f.type !== 'image' && f.type !== 'pdf' && typeof f.code === 'string';
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/** Push a new entry (truncating any forward history) and load it. */
|
|
239
|
+
function navigate(url: string) {
|
|
240
|
+
if (url === currentUrl()) {
|
|
241
|
+
reload();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
setHistory((h) => [...h.slice(0, cursor() + 1), url]);
|
|
245
|
+
setCursor((c) => c + 1);
|
|
246
|
+
loadCurrent();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Point the iframe at the current cursor entry + emit `navigate`. */
|
|
250
|
+
function loadCurrent() {
|
|
251
|
+
const url = currentUrl();
|
|
252
|
+
if (iframeEl) iframeEl.src = url || 'about:blank';
|
|
253
|
+
local.onNavigate?.(url);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function selectTab(next: ArtifactTab) {
|
|
257
|
+
if (next === tab()) return;
|
|
258
|
+
setTab(next);
|
|
259
|
+
local.onTabChange?.(next);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function selectFile(path: string, file: ArtifactFile) {
|
|
263
|
+
setActiveFile(path);
|
|
264
|
+
local.onFileSelect?.(path);
|
|
265
|
+
navigate(resolveFileUrl(file, local.src));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Toolbar actions — operate on the component's own history stack (see above).
|
|
269
|
+
const goBack = () => {
|
|
270
|
+
if (!canBack()) return;
|
|
271
|
+
setCursor((c) => c - 1);
|
|
272
|
+
loadCurrent();
|
|
273
|
+
};
|
|
274
|
+
const goForward = () => {
|
|
275
|
+
if (!canForward()) return;
|
|
276
|
+
setCursor((c) => c + 1);
|
|
277
|
+
loadCurrent();
|
|
278
|
+
};
|
|
279
|
+
function reload() {
|
|
280
|
+
const url = currentUrl();
|
|
281
|
+
if (iframeEl) {
|
|
282
|
+
// Force a real reload even when the src is unchanged.
|
|
283
|
+
iframeEl.src = 'about:blank';
|
|
284
|
+
iframeEl.src = url || 'about:blank';
|
|
285
|
+
}
|
|
286
|
+
setReloadKey((k) => k + 1); // re-render the inline PDF viewer too
|
|
287
|
+
local.onNavigate?.(url);
|
|
288
|
+
}
|
|
289
|
+
const goHome = () => {
|
|
290
|
+
if (local.src) navigate(local.src);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Best-effort: if the consumer opted into `allow-same-origin`, keep the path
|
|
294
|
+
// field truthful as the framed doc navigates itself. Cross-origin (the secure
|
|
295
|
+
// default) throws and we keep our own stack.
|
|
296
|
+
const onIframeLoad = () => {
|
|
297
|
+
try {
|
|
298
|
+
const href = iframeEl?.contentWindow?.location.href;
|
|
299
|
+
if (href && href !== 'about:blank' && href !== currentUrl()) {
|
|
300
|
+
setHistory((h) => [...h.slice(0, cursor() + 1), href]);
|
|
301
|
+
setCursor((c) => c + 1);
|
|
302
|
+
local.onNavigate?.(href);
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
/* cross-origin (sandboxed without allow-same-origin): keep our own url */
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const submitPath = (e: Event) => {
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
const input = (e.currentTarget as HTMLFormElement).elements.namedItem(
|
|
312
|
+
'kai-artifact-path',
|
|
313
|
+
) as HTMLInputElement | null;
|
|
314
|
+
if (local.readonlyPath) {
|
|
315
|
+
// Submit is a no-op while read-only; keep the field reflecting currentUrl.
|
|
316
|
+
if (input) input.value = currentUrl();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (input && input.value) navigate(input.value);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<div
|
|
324
|
+
class={cn(
|
|
325
|
+
'flex h-full w-full flex-col overflow-hidden bg-card text-card-foreground',
|
|
326
|
+
local.standalone && 'rounded-xl border border-border',
|
|
327
|
+
local.class,
|
|
328
|
+
)}
|
|
329
|
+
{...rest}
|
|
330
|
+
>
|
|
331
|
+
<Show when={showAnyToolbar()}>
|
|
332
|
+
<ArtifactToolbar
|
|
333
|
+
url={currentUrl}
|
|
334
|
+
tab={tab}
|
|
335
|
+
canBack={canBack}
|
|
336
|
+
canForward={canForward}
|
|
337
|
+
canHome={() => !!local.src}
|
|
338
|
+
onBack={goBack}
|
|
339
|
+
onForward={goForward}
|
|
340
|
+
onReload={reload}
|
|
341
|
+
onHome={goHome}
|
|
342
|
+
onSubmitPath={submitPath}
|
|
343
|
+
onTab={selectTab}
|
|
344
|
+
showNav={() => local.showNav}
|
|
345
|
+
showReload={() => local.showReload}
|
|
346
|
+
showHome={() => local.showHome}
|
|
347
|
+
showPathField={() => local.showPathField}
|
|
348
|
+
showTabs={() => local.showTabs}
|
|
349
|
+
showExpand={showExpand}
|
|
350
|
+
showOpenInTab={() => local.openInTab}
|
|
351
|
+
maximized={maximized}
|
|
352
|
+
onToggleMaximize={toggleMaximize}
|
|
353
|
+
canOpenInTab={canOpenInTab}
|
|
354
|
+
onOpenInTab={openInNewTab}
|
|
355
|
+
readonlyPath={() => local.readonlyPath}
|
|
356
|
+
/>
|
|
357
|
+
</Show>
|
|
358
|
+
<div class="relative min-h-0 flex-1">
|
|
359
|
+
<Show
|
|
360
|
+
when={tab() === 'preview'}
|
|
361
|
+
fallback={
|
|
362
|
+
<ArtifactCode
|
|
363
|
+
files={local.files}
|
|
364
|
+
activeFile={activeFile}
|
|
365
|
+
activeFileObj={activeFileObj}
|
|
366
|
+
hasSource={hasSource}
|
|
367
|
+
onSelect={selectFile}
|
|
368
|
+
/>
|
|
369
|
+
}
|
|
370
|
+
>
|
|
371
|
+
<Show
|
|
372
|
+
when={isPdfUrl(currentUrl(), local.files)}
|
|
373
|
+
fallback={
|
|
374
|
+
<ArtifactPreview
|
|
375
|
+
ref={(el) => (iframeEl = el)}
|
|
376
|
+
src={currentUrl}
|
|
377
|
+
sandbox={local.sandbox}
|
|
378
|
+
title={local.iframeTitle ?? 'Artifact preview'}
|
|
379
|
+
onLoad={onIframeLoad}
|
|
380
|
+
/>
|
|
381
|
+
}
|
|
382
|
+
>
|
|
383
|
+
<ArtifactPdfPreview url={currentUrl()} reloadKey={reloadKey()} />
|
|
384
|
+
</Show>
|
|
385
|
+
</Show>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- ArtifactToolbar (internal) -------------------------------------------
|
|
392
|
+
|
|
393
|
+
interface ToolbarProps {
|
|
394
|
+
url: () => string;
|
|
395
|
+
tab: () => ArtifactTab;
|
|
396
|
+
canBack: () => boolean;
|
|
397
|
+
canForward: () => boolean;
|
|
398
|
+
canHome: () => boolean;
|
|
399
|
+
onBack: () => void;
|
|
400
|
+
onForward: () => void;
|
|
401
|
+
onReload: () => void;
|
|
402
|
+
onHome: () => void;
|
|
403
|
+
onSubmitPath: (e: Event) => void;
|
|
404
|
+
onTab: (tab: ArtifactTab) => void;
|
|
405
|
+
showNav: () => boolean;
|
|
406
|
+
showReload: () => boolean;
|
|
407
|
+
showHome: () => boolean;
|
|
408
|
+
showPathField: () => boolean;
|
|
409
|
+
showTabs: () => boolean;
|
|
410
|
+
showExpand: () => boolean;
|
|
411
|
+
showOpenInTab: () => boolean;
|
|
412
|
+
maximized: () => boolean;
|
|
413
|
+
onToggleMaximize: () => void;
|
|
414
|
+
canOpenInTab: () => boolean;
|
|
415
|
+
onOpenInTab: () => void;
|
|
416
|
+
readonlyPath: () => boolean;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function ArtifactToolbar(props: ToolbarProps): JSX.Element {
|
|
420
|
+
return (
|
|
421
|
+
<div
|
|
422
|
+
data-artifact-toolbar
|
|
423
|
+
class="flex shrink-0 items-center gap-1.5 border-b border-border bg-muted/40 px-2 py-1.5"
|
|
424
|
+
>
|
|
425
|
+
<Show when={props.showNav()}>
|
|
426
|
+
<Button
|
|
427
|
+
variant="ghost"
|
|
428
|
+
size="icon-sm"
|
|
429
|
+
aria-label="Back"
|
|
430
|
+
disabled={!props.canBack()}
|
|
431
|
+
onClick={() => props.onBack()}
|
|
432
|
+
>
|
|
433
|
+
<ArrowLeft size={16} aria-hidden="true" />
|
|
434
|
+
</Button>
|
|
435
|
+
<Button
|
|
436
|
+
variant="ghost"
|
|
437
|
+
size="icon-sm"
|
|
438
|
+
aria-label="Forward"
|
|
439
|
+
disabled={!props.canForward()}
|
|
440
|
+
onClick={() => props.onForward()}
|
|
441
|
+
>
|
|
442
|
+
<ArrowRight size={16} aria-hidden="true" />
|
|
443
|
+
</Button>
|
|
444
|
+
</Show>
|
|
445
|
+
<Show when={props.showReload()}>
|
|
446
|
+
<Button variant="ghost" size="icon-sm" aria-label="Reload" onClick={() => props.onReload()}>
|
|
447
|
+
<RotateCw size={15} aria-hidden="true" />
|
|
448
|
+
</Button>
|
|
449
|
+
</Show>
|
|
450
|
+
<Show when={props.showHome()}>
|
|
451
|
+
<Button
|
|
452
|
+
variant="ghost"
|
|
453
|
+
size="icon-sm"
|
|
454
|
+
aria-label="Home"
|
|
455
|
+
disabled={!props.canHome()}
|
|
456
|
+
onClick={() => props.onHome()}
|
|
457
|
+
>
|
|
458
|
+
<House size={15} aria-hidden="true" />
|
|
459
|
+
</Button>
|
|
460
|
+
</Show>
|
|
461
|
+
<Show when={props.showPathField()}>
|
|
462
|
+
<form class="min-w-0 flex-1" onSubmit={(e) => props.onSubmitPath(e)}>
|
|
463
|
+
<label class="sr-only" for="kai-artifact-path">
|
|
464
|
+
Address
|
|
465
|
+
</label>
|
|
466
|
+
<input
|
|
467
|
+
id="kai-artifact-path"
|
|
468
|
+
name="kai-artifact-path"
|
|
469
|
+
type="text"
|
|
470
|
+
spellcheck={false}
|
|
471
|
+
autocomplete="off"
|
|
472
|
+
readonly={props.readonlyPath() || undefined}
|
|
473
|
+
aria-readonly={props.readonlyPath() ? 'true' : undefined}
|
|
474
|
+
value={props.url()}
|
|
475
|
+
class={cn(
|
|
476
|
+
'h-7 w-full rounded-md border border-border px-2.5 text-xs text-foreground font-mono outline-none',
|
|
477
|
+
props.readonlyPath()
|
|
478
|
+
? 'bg-muted/40 cursor-default'
|
|
479
|
+
: 'bg-background focus-visible:ring-2 focus-visible:ring-ring',
|
|
480
|
+
)}
|
|
481
|
+
placeholder="Enter a path or URL…"
|
|
482
|
+
/>
|
|
483
|
+
</form>
|
|
484
|
+
</Show>
|
|
485
|
+
<Show when={!props.showPathField()}>
|
|
486
|
+
<div class="flex-1" aria-hidden="true" />
|
|
487
|
+
</Show>
|
|
488
|
+
<Show when={props.showExpand()}>
|
|
489
|
+
<Button
|
|
490
|
+
variant="ghost"
|
|
491
|
+
size="icon-sm"
|
|
492
|
+
aria-label={props.maximized() ? 'Collapse' : 'Expand'}
|
|
493
|
+
aria-expanded={props.maximized()}
|
|
494
|
+
onClick={() => props.onToggleMaximize()}
|
|
495
|
+
>
|
|
496
|
+
<Show when={props.maximized()} fallback={<Maximize2 size={15} aria-hidden="true" />}>
|
|
497
|
+
<Minimize2 size={15} aria-hidden="true" />
|
|
498
|
+
</Show>
|
|
499
|
+
</Button>
|
|
500
|
+
</Show>
|
|
501
|
+
<Show when={props.showOpenInTab()}>
|
|
502
|
+
<Button
|
|
503
|
+
variant="ghost"
|
|
504
|
+
size="icon-sm"
|
|
505
|
+
aria-label="Open in new tab"
|
|
506
|
+
disabled={!props.canOpenInTab()}
|
|
507
|
+
onClick={() => props.onOpenInTab()}
|
|
508
|
+
>
|
|
509
|
+
<ExternalLink size={15} aria-hidden="true" />
|
|
510
|
+
</Button>
|
|
511
|
+
</Show>
|
|
512
|
+
<Show when={props.showTabs()}>
|
|
513
|
+
<div
|
|
514
|
+
role="tablist"
|
|
515
|
+
aria-label="View"
|
|
516
|
+
class="flex shrink-0 items-center gap-0.5 rounded-md bg-muted p-0.5"
|
|
517
|
+
>
|
|
518
|
+
<SegmentButton
|
|
519
|
+
label="Preview"
|
|
520
|
+
icon={<Eye size={14} aria-hidden="true" />}
|
|
521
|
+
selected={props.tab() === 'preview'}
|
|
522
|
+
onClick={() => props.onTab('preview')}
|
|
523
|
+
/>
|
|
524
|
+
<SegmentButton
|
|
525
|
+
label="Code"
|
|
526
|
+
icon={<CodeIcon size={14} aria-hidden="true" />}
|
|
527
|
+
selected={props.tab() === 'code'}
|
|
528
|
+
onClick={() => props.onTab('code')}
|
|
529
|
+
/>
|
|
530
|
+
</div>
|
|
531
|
+
</Show>
|
|
532
|
+
</div>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function SegmentButton(props: {
|
|
537
|
+
label: string;
|
|
538
|
+
icon: JSX.Element;
|
|
539
|
+
selected: boolean;
|
|
540
|
+
onClick: () => void;
|
|
541
|
+
}): JSX.Element {
|
|
542
|
+
return (
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
role="tab"
|
|
546
|
+
aria-selected={props.selected}
|
|
547
|
+
class={cn(
|
|
548
|
+
'inline-flex h-6 items-center gap-1.5 rounded px-2 text-xs font-medium transition-colors outline-none',
|
|
549
|
+
'focus-visible:ring-2 focus-visible:ring-ring',
|
|
550
|
+
props.selected
|
|
551
|
+
? 'bg-background text-foreground shadow-sm'
|
|
552
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
553
|
+
)}
|
|
554
|
+
onClick={() => props.onClick()}
|
|
555
|
+
>
|
|
556
|
+
{props.icon}
|
|
557
|
+
{props.label}
|
|
558
|
+
</button>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// --- ArtifactPreview (internal) -------------------------------------------
|
|
563
|
+
|
|
564
|
+
interface PreviewProps {
|
|
565
|
+
ref: (el: HTMLIFrameElement) => void;
|
|
566
|
+
src: () => string;
|
|
567
|
+
sandbox: string;
|
|
568
|
+
title: string;
|
|
569
|
+
onLoad: () => void;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function ArtifactPreview(props: PreviewProps): JSX.Element {
|
|
573
|
+
return (
|
|
574
|
+
<iframe
|
|
575
|
+
ref={props.ref}
|
|
576
|
+
src={props.src() || 'about:blank'}
|
|
577
|
+
sandbox={props.sandbox}
|
|
578
|
+
title={props.title}
|
|
579
|
+
class="absolute inset-0 h-full w-full border-0 bg-white"
|
|
580
|
+
onLoad={() => props.onLoad()}
|
|
581
|
+
/>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// --- ArtifactCode (internal) ----------------------------------------------
|
|
586
|
+
|
|
587
|
+
interface CodeProps {
|
|
588
|
+
files: ArtifactFile[];
|
|
589
|
+
activeFile: () => string | undefined;
|
|
590
|
+
activeFileObj: () => ArtifactFile | undefined;
|
|
591
|
+
hasSource: () => boolean;
|
|
592
|
+
onSelect: (path: string, file: ArtifactFile) => void;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function ArtifactCode(props: CodeProps): JSX.Element {
|
|
596
|
+
return (
|
|
597
|
+
<div class="absolute inset-0 flex">
|
|
598
|
+
<div class="w-56 shrink-0 overflow-auto border-r border-border bg-muted/20 py-1.5 scrollbar-thin">
|
|
599
|
+
<FileTree
|
|
600
|
+
files={props.files}
|
|
601
|
+
activeFile={props.activeFile()}
|
|
602
|
+
onSelect={(path, file) => props.onSelect(path, file)}
|
|
603
|
+
/>
|
|
604
|
+
</div>
|
|
605
|
+
<div class="min-w-0 flex-1 overflow-auto scrollbar-thin">
|
|
606
|
+
<Show
|
|
607
|
+
when={props.activeFileObj()}
|
|
608
|
+
fallback={
|
|
609
|
+
<div class="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
|
610
|
+
Select a file to view its source.
|
|
611
|
+
</div>
|
|
612
|
+
}
|
|
613
|
+
>
|
|
614
|
+
<Show
|
|
615
|
+
when={props.hasSource()}
|
|
616
|
+
fallback={
|
|
617
|
+
<div class="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
|
618
|
+
No source — this file ({props.activeFileObj()!.type ?? 'binary'}) has no code view.
|
|
619
|
+
</div>
|
|
620
|
+
}
|
|
621
|
+
>
|
|
622
|
+
<CodeBlock class="h-full rounded-none border-0">
|
|
623
|
+
<CodeBlockCode
|
|
624
|
+
code={props.activeFileObj()!.code ?? ''}
|
|
625
|
+
language={props.activeFileObj()!.language}
|
|
626
|
+
/>
|
|
627
|
+
</CodeBlock>
|
|
628
|
+
</Show>
|
|
629
|
+
</Show>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// --- ArtifactPdfFallback (internal) ---------------------------------------
|
|
636
|
+
|
|
637
|
+
/** Shown when inline PDF rendering is disabled or fails (CORS / load / parse). */
|
|
638
|
+
function ArtifactPdfFallback(props: { url: string }): JSX.Element {
|
|
639
|
+
const name = () => {
|
|
640
|
+
const path = props.url.split(/[?#]/)[0];
|
|
641
|
+
return path.slice(path.lastIndexOf('/') + 1) || 'document.pdf';
|
|
642
|
+
};
|
|
643
|
+
const linkClass =
|
|
644
|
+
'inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
|
645
|
+
return (
|
|
646
|
+
<div
|
|
647
|
+
role="region"
|
|
648
|
+
aria-label="PDF preview unavailable"
|
|
649
|
+
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-card p-6 text-center"
|
|
650
|
+
>
|
|
651
|
+
<FileText size={40} class="text-muted-foreground" aria-hidden="true" />
|
|
652
|
+
<div class="text-sm font-medium text-foreground">{name()}</div>
|
|
653
|
+
<div class="text-xs text-muted-foreground">Can't preview this PDF inline.</div>
|
|
654
|
+
<div class="flex flex-wrap items-center justify-center gap-2">
|
|
655
|
+
<a class={linkClass} href={props.url} target="_blank" rel="noopener noreferrer">
|
|
656
|
+
Open in new tab
|
|
657
|
+
<ExternalLink size={13} aria-hidden="true" />
|
|
658
|
+
</a>
|
|
659
|
+
<a class={linkClass} href={props.url} download="">
|
|
660
|
+
<Download size={13} aria-hidden="true" />
|
|
661
|
+
Download
|
|
662
|
+
</a>
|
|
663
|
+
</div>
|
|
664
|
+
</div>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// --- ArtifactPdfPreview (internal) ----------------------------------------
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Renders a PDF inline via pdf.js (loaded on demand). Four states:
|
|
672
|
+
* disabled → fallback (no network); loading → spinner; success → stacked
|
|
673
|
+
* fit-width canvases; error (load/CORS/parse) → fallback card. Re-renders when
|
|
674
|
+
* the url or `reloadKey` changes, and (debounced) when the panel resizes.
|
|
675
|
+
*/
|
|
676
|
+
function ArtifactPdfPreview(props: { url: string; reloadKey: number }): JSX.Element {
|
|
677
|
+
const [state, setState] = createSignal<'loading' | 'success' | 'error'>('loading');
|
|
678
|
+
let container: HTMLDivElement | undefined;
|
|
679
|
+
let token = 0;
|
|
680
|
+
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
681
|
+
|
|
682
|
+
const renderNow = async () => {
|
|
683
|
+
if (!isPdfPreviewEnabled() || !container) {
|
|
684
|
+
setState('error');
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const mine = ++token;
|
|
688
|
+
setState('loading');
|
|
689
|
+
try {
|
|
690
|
+
const width = container.clientWidth || 600;
|
|
691
|
+
await renderPdfInto(props.url, container, width);
|
|
692
|
+
if (mine === token) setState('success');
|
|
693
|
+
} catch {
|
|
694
|
+
if (mine === token) setState('error');
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
createEffect(
|
|
699
|
+
on(
|
|
700
|
+
() => [props.url, props.reloadKey] as const,
|
|
701
|
+
() => {
|
|
702
|
+
void renderNow();
|
|
703
|
+
},
|
|
704
|
+
),
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
onMount(() => {
|
|
708
|
+
if (!container || typeof ResizeObserver === 'undefined') return;
|
|
709
|
+
const ro = new ResizeObserver(() => {
|
|
710
|
+
if (state() !== 'success') return;
|
|
711
|
+
clearTimeout(resizeTimer);
|
|
712
|
+
resizeTimer = setTimeout(() => void renderNow(), 200);
|
|
713
|
+
});
|
|
714
|
+
ro.observe(container);
|
|
715
|
+
onCleanup(() => {
|
|
716
|
+
ro.disconnect();
|
|
717
|
+
clearTimeout(resizeTimer);
|
|
718
|
+
token++; // cancel any in-flight render
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
return (
|
|
723
|
+
<div class="absolute inset-0">
|
|
724
|
+
{/* Always present so clientWidth is the real panel width. */}
|
|
725
|
+
<div
|
|
726
|
+
ref={(el) => (container = el)}
|
|
727
|
+
role="region"
|
|
728
|
+
aria-label="PDF preview"
|
|
729
|
+
aria-busy={state() === 'loading'}
|
|
730
|
+
tabindex="0"
|
|
731
|
+
class="absolute inset-0 flex flex-col items-center gap-3 overflow-auto bg-muted/20 p-3 scrollbar-thin focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
732
|
+
/>
|
|
733
|
+
<Show when={isPdfPreviewEnabled() && state() === 'loading'}>
|
|
734
|
+
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
735
|
+
<Loader variant="circular" size="md" />
|
|
736
|
+
</div>
|
|
737
|
+
</Show>
|
|
738
|
+
<Show when={!isPdfPreviewEnabled() || state() === 'error'}>
|
|
739
|
+
<ArtifactPdfFallback url={props.url} />
|
|
740
|
+
</Show>
|
|
741
|
+
</div>
|
|
742
|
+
);
|
|
743
|
+
}
|