dify/web/features/skills/detail/markdown-editor.tsx
wangxiaolei 0024a845f7
feat: support agent skill (#39675)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: 起岚 <155608604+xiaoyaoqilan@users.noreply.github.com>
Co-authored-by: Parman Mohammadalizadeh <prmma23@gmail.com>
Co-authored-by: Escape0707 <tothesong@gmail.com>
Co-authored-by: Xin Zhang <zhangxin@dify.ai>
Co-authored-by: iridescentWen <66297467+iridescentWen@users.noreply.github.com>
Co-authored-by: Bond Zhu <37842169+MRZHUH@users.noreply.github.com>
Co-authored-by: KVOJJJin <jzongcode@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: Chester <superdiao6@gmail.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
Co-authored-by: Jony <619963502@qq.com>
Co-authored-by: Jony <13896935+zyz619963502zyz@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harsh.kashyap2001@gmail.com>
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: csurong <csurong1@gmail.com>
Co-authored-by: caosurong <surong.cao@thinkingdata.cn>
Co-authored-by: Taranum Wasu <81034301+Taranum01@users.noreply.github.com>
Co-authored-by: Taranum01 <50813317+Taranum01@users.noreply.github.com>
Co-authored-by: Pranav Agarwal <agarwalpranav0711@gmail.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: zl86790 <marshal_li_b@163.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-08-24 05:29:14 +00:00

1195 lines
41 KiB
TypeScript

'use client'
import type {
SkillFileResponse,
SkillVersionResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type {
ChangeEvent,
ComponentPropsWithoutRef,
CSSProperties,
FocusEventHandler,
FormEvent,
KeyboardEvent,
MouseEvent,
RefObject,
} from 'react'
import type { MarkdownProps } from '@/app/components/base/markdown'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import { visit } from 'unist-util-visit'
import { Markdown } from '@/app/components/base/markdown'
import useTimestamp from '@/hooks/use-timestamp'
import styles from './markdown-editor.module.css'
import {
getMarkdownLiveEditorSelectionOffset,
getPathBaseName,
getReferenceDisplayLabel,
getReferenceIconClass,
getSkillFileIconClass,
getSkillVersionTitle,
isDirectory,
renderMarkdownLiveEditorContent,
serializeMarkdownLiveEditorNode,
setMarkdownLiveEditorSelectionOffset,
} from './shared'
type SkillMarkdownLinkProps = ComponentPropsWithoutRef<'a'> & { node?: unknown }
const SKILL_REFERENCE_URL_PREFIX = 'https://dify.local/__skill_reference__/'
function getMarkdownReferencePath(href: string | undefined) {
if (href?.startsWith(SKILL_REFERENCE_URL_PREFIX)) {
try {
return decodeURIComponent(href.slice(SKILL_REFERENCE_URL_PREFIX.length))
} catch {
return href.slice(SKILL_REFERENCE_URL_PREFIX.length)
}
}
if (!href || href.startsWith('#') || href.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(href))
return
try {
return decodeURIComponent(href)
} catch {
return href
}
}
function encodeSkillMarkdownReferenceLinks() {
return (tree: Parameters<typeof visit>[0]) => {
visit(tree, 'link', (node) => {
const linkNode = node as { url?: string }
const referencePath = getMarkdownReferencePath(linkNode.url)
if (!referencePath) return
linkNode.url = `${SKILL_REFERENCE_URL_PREFIX}${encodeURIComponent(referencePath)}`
})
}
}
const SKILL_REFERENCE_REMARK_PLUGINS = [encodeSkillMarkdownReferenceLinks]
const markdownPreviewBlockSelector = 'h1, h2, h3, h4, h5, h6, p, li, pre, blockquote, td, th'
function findClosestTextOffset(content: string, text: string, preferredOffset: number) {
let closestOffset = -1
let searchOffset = content.indexOf(text)
while (searchOffset !== -1) {
if (
closestOffset === -1 ||
Math.abs(searchOffset - preferredOffset) < Math.abs(closestOffset - preferredOffset)
) {
closestOffset = searchOffset
}
searchOffset = content.indexOf(text, searchOffset + 1)
}
return closestOffset
}
function getMarkdownPreviewSelectionOffset(
root: HTMLElement,
target: EventTarget | null,
body: string,
clientX?: number,
clientY?: number,
) {
const rootRect = root.getBoundingClientRect()
const clickRatio =
clientY == null || rootRect.height === 0
? 0
: Math.min(Math.max((clientY - rootRect.top) / rootRect.height, 0), 1)
const preferredOffset = Math.round(body.length * clickRatio)
const targetElement = target instanceof HTMLElement ? target : null
const blockElement = targetElement?.closest<HTMLElement>(markdownPreviewBlockSelector)
const blockText = blockElement?.textContent?.trim()
if (clientX != null && clientY != null) {
const documentWithCaretPosition = root.ownerDocument as Document & {
caretPositionFromPoint?: (x: number, y: number) => CaretPosition | null
}
const caretPosition = documentWithCaretPosition.caretPositionFromPoint?.(clientX, clientY)
const caretText = caretPosition?.offsetNode.textContent
if (caretPosition && caretText?.trim()) {
const caretTextOffset = findClosestTextOffset(body, caretText, preferredOffset)
if (caretTextOffset !== -1) return caretTextOffset + caretPosition.offset
}
}
if (blockText) {
const blockOffset = findClosestTextOffset(body, blockText, preferredOffset)
if (blockOffset !== -1) return blockOffset
}
const approximateOffset = Math.round(body.length * clickRatio)
return body.lastIndexOf('\n', approximateOffset) + 1
}
function getScrollParent(element: HTMLElement) {
let parent = element.parentElement
while (parent) {
const overflowY = parent.ownerDocument.defaultView?.getComputedStyle(parent).overflowY
if (
(overflowY === 'auto' || overflowY === 'scroll') &&
parent.scrollHeight > parent.clientHeight
)
return parent
parent = parent.parentElement
}
}
function getSelectionRect(root: HTMLElement) {
const selection = root.ownerDocument.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0).cloneRange()
if (!root.contains(range.startContainer)) return
range.collapse(true)
return range.getBoundingClientRect()
}
function getLineBoxTop(rect: DOMRect, rootRect: DOMRect, lineHeight: number) {
const leadingInset = Number.isFinite(lineHeight) ? Math.max((lineHeight - rect.height) / 2, 0) : 0
return Math.max(rect.top - rootRect.top - leadingInset, 0)
}
function getSelectionLinePosition(root: HTMLElement) {
const selection = root.ownerDocument.getSelection()
if (!selection?.isCollapsed || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!root.contains(range.startContainer)) return
const rootRect = root.getBoundingClientRect()
const lineHeight = Number.parseFloat(
root.ownerDocument.defaultView?.getComputedStyle(root).lineHeight ?? '',
)
let selectionRect = getSelectionRect(root)
if (!selectionRect?.height && range.startContainer !== root) {
const selectionNode = range.startContainer
let lineElement =
selectionNode instanceof HTMLElement ? selectionNode : selectionNode?.parentElement
while (lineElement?.parentElement && lineElement.parentElement !== root)
lineElement = lineElement.parentElement
if (lineElement?.parentElement === root) selectionRect = lineElement.getBoundingClientRect()
}
if (!selectionRect?.height) {
const prefixRange = range.cloneRange()
prefixRange.selectNodeContents(root)
prefixRange.setEnd(range.startContainer, range.startOffset)
const prefixRects = Array.from(prefixRange.getClientRects())
const lastPrefixRect = prefixRects.at(-1)
if (lastPrefixRect && Number.isFinite(lineHeight)) {
// Chrome emits zero-width rects for trailing newlines. Anchor to the last painted
// content rect, then advance by the serialized line breaks so none are counted twice.
let lastContentRect: DOMRect | undefined
for (let index = prefixRects.length - 1; index >= 0; index--) {
const rect = prefixRects[index]
if (!rect?.width) continue
lastContentRect = rect
break
}
const prefix = serializeMarkdownLiveEditorNode(prefixRange.cloneContents()).replace(
/\u00A0/g,
' ',
)
const trailingLineBreaks = prefix.match(/\n+$/)?.[0].length ?? 0
const anchorRect = lastContentRect ?? lastPrefixRect
const remainingLineBreaks = lastContentRect
? trailingLineBreaks
: Math.min(trailingLineBreaks, 1)
return {
left: trailingLineBreaks > 0 ? 0 : Math.max(lastPrefixRect.right - rootRect.left, 0),
top: getLineBoxTop(anchorRect, rootRect, lineHeight) + remainingLineBreaks * lineHeight,
}
}
return
}
return {
left: Math.max(selectionRect.left - rootRect.left, 0),
top: getLineBoxTop(selectionRect, rootRect, lineHeight),
}
}
function getSelectionLineBlank(root: HTMLElement) {
const selection = root.ownerDocument.getSelection()
if (!selection || selection.rangeCount === 0) return
if (!selection.isCollapsed) return false
const range = selection.getRangeAt(0)
if (!root.contains(range.startContainer)) return
let lineElement =
range.startContainer instanceof HTMLElement
? range.startContainer
: range.startContainer.parentElement
while (lineElement?.parentElement && lineElement.parentElement !== root)
lineElement = lineElement.parentElement
if (
lineElement?.parentElement === root &&
(lineElement.tagName === 'DIV' || lineElement.tagName === 'P')
) {
return !serializeMarkdownLiveEditorNode(lineElement).trim()
}
const value = serializeMarkdownLiveEditorNode(root).replace(/\u00A0/g, ' ')
const offset = getMarkdownLiveEditorSelectionOffset(root)
if (offset == null) return
return getCurrentLine(value, offset).blank
}
function getMarkdownPreviewAnchorRect(root: HTMLElement, body: string, sourceOffset: number) {
let closestElement: HTMLElement | undefined
let closestDistance = Number.POSITIVE_INFINITY
for (const element of root.querySelectorAll<HTMLElement>(markdownPreviewBlockSelector)) {
const text = element.textContent?.trim()
if (!text) continue
const textOffset = findClosestTextOffset(body, text, sourceOffset)
if (textOffset === -1) continue
const distance =
sourceOffset < textOffset
? textOffset - sourceOffset
: Math.max(sourceOffset - (textOffset + text.length), 0)
if (distance >= closestDistance) continue
closestDistance = distance
closestElement = element
}
return closestElement?.getBoundingClientRect()
}
function useSkillMarkdownComponents(onOpenReference?: (path: string) => void) {
return useMemo<NonNullable<MarkdownProps['customComponents']>>(
() => ({
a: ({ children, href }: SkillMarkdownLinkProps) => {
const referencePath = getMarkdownReferencePath(href)
if (!referencePath) {
return (
<a
href={href}
target={href?.startsWith('#') ? undefined : '_blank'}
rel={href?.startsWith('#') ? undefined : 'noopener noreferrer'}
className="cursor-pointer underline decoration-primary-700! decoration-dashed"
>
{children}
</a>
)
}
const referenceLabel =
typeof children === 'string' || typeof children === 'number'
? String(children)
: getPathBaseName(referencePath)
return (
<button
type="button"
data-reference-path={referencePath}
title={referencePath}
className="inline-flex cursor-pointer flex-col items-start px-0.5 py-px align-baseline outline-hidden focus-visible:rounded-[5px] focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => onOpenReference?.(referencePath)}
>
<span className="inline-flex min-w-[18px] items-center overflow-hidden rounded-[5px] border border-state-accent-hover-alt bg-state-accent-hover py-px pr-1 pl-px text-text-accent shadow-xs">
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="inline-flex shrink-0 items-center justify-center p-px">
<span
aria-hidden
className={cn('size-3.5 shrink-0', getReferenceIconClass(referencePath))}
/>
</span>
<span className="max-w-48 truncate system-xs-medium">
{getReferenceDisplayLabel(referencePath, referenceLabel)}
</span>
</span>
</span>
</button>
)
},
}),
[onOpenReference],
)
}
function EditorPlaceholder({
className,
style,
text,
}: {
className?: string
style?: CSSProperties
text: string
}) {
const shortcutIndex = text.indexOf('/')
const beforeShortcut = shortcutIndex >= 0 ? text.slice(0, shortcutIndex) : text
const afterShortcut = shortcutIndex >= 0 ? text.slice(shortcutIndex + 1) : ''
return (
<span
aria-hidden
style={style}
className={cn(
'pointer-events-none inline-flex items-center gap-1 text-[15px]/6 tracking-[-0.075px] whitespace-nowrap text-text-quaternary',
className,
)}
>
<span>{beforeShortcut}</span>
<kbd className="inline-flex min-w-4 items-center justify-center rounded bg-components-kbd-bg-gray px-0.5 system-xs-medium text-text-quaternary">
/
</kbd>
<span>{afterShortcut}</span>
</span>
)
}
function getCurrentLine(value: string, offset: number) {
const lineIndex = value.slice(0, offset).split('\n').length - 1
const lines = value.split('\n')
return {
blank: !(lines[lineIndex] ?? '').trim(),
lineIndex,
}
}
export function MarkdownModeSwitch({
mode,
onChange,
}: {
mode: 'live' | 'source'
onChange: (mode: 'live' | 'source') => void
}) {
const { t } = useTranslation('skill')
return (
<div className="absolute top-3 right-2 z-10 flex h-8 items-center rounded-md bg-components-segmented-control-bg-normal p-0.5">
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.markdownLiveMode'])}
title={t(($) => $['skillManagement.detail.markdownLiveMode'])}
className={cn(
'flex size-7 cursor-pointer items-center justify-center rounded-md text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
mode === 'live' &&
'border-[0.5px] border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-primary shadow-xs',
)}
onClick={() => onChange('live')}
>
<span aria-hidden className="i-ri-eye-line size-4" />
</button>
<button
type="button"
aria-label={t(($) => $['skillManagement.detail.markdownSourceMode'])}
title={t(($) => $['skillManagement.detail.markdownSourceMode'])}
className={cn(
'flex size-7 cursor-pointer items-center justify-center rounded-md text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
mode === 'source' &&
'border-[0.5px] border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-primary shadow-xs',
)}
onClick={() => onChange('source')}
>
<span aria-hidden className="i-ri-code-s-slash-line size-4" />
</button>
</div>
)
}
export function ReferenceFilesPicker({
anchor,
confirmText,
currentDirectory,
emptyText,
files,
navigateText,
onBack,
onSelect,
onSelectIndex,
query,
selectedIndex,
title,
}: {
anchor?: { x: number; y: number }
confirmText: string
currentDirectory: string
emptyText: string
files: SkillFileResponse[]
navigateText: string
onBack: () => void
onSelect: (file: SkillFileResponse) => void
onSelectIndex: (index: number) => void
query: string
selectedIndex: number
title: string
}) {
const safeAnchor = anchor ?? { x: 32, y: 64 }
const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth
const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight
const left = Math.min(safeAnchor.x, viewportWidth - 376)
const top = Math.min(safeAnchor.y, viewportHeight - 340)
return (
<div
className="fixed z-50 w-[360px] overflow-hidden rounded-xl border border-divider-regular bg-components-panel-bg shadow-lg"
style={{
left: Math.max(left, 16),
top: Math.max(top, 16),
}}
>
<div className="flex h-10 items-center gap-2 border-b border-divider-subtle px-3">
<span aria-hidden className="i-ri-folder-3-line size-4 text-text-tertiary" />
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
{currentDirectory || title}
</span>
{query && (
<span className="max-w-28 truncate system-xs-regular text-text-quaternary">{query}</span>
)}
</div>
<div className="max-h-[280px] overflow-y-auto p-1">
{currentDirectory && (
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onBack}
>
<span
aria-hidden
className="i-ri-arrow-left-s-line size-4 shrink-0 text-text-tertiary"
/>
<span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary">
..
</span>
</button>
)}
{files.length > 0 ? (
files.map((referenceFile, index) => {
const selected = index === selectedIndex
return (
<button
key={referenceFile.path}
type="button"
className={cn(
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-2 text-left outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
selected && 'bg-state-base-hover',
)}
onMouseEnter={() => onSelectIndex(index)}
onClick={() => onSelect(referenceFile)}
>
<span
aria-hidden
className={cn(
'size-4 shrink-0',
isDirectory(referenceFile) && selected
? 'i-ri-folder-open-line text-text-secondary'
: getSkillFileIconClass(referenceFile),
)}
/>
<span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary">
{getPathBaseName(referenceFile.path)}
</span>
{isDirectory(referenceFile) && (
<span
aria-hidden
className="i-ri-arrow-right-s-line size-4 text-text-quaternary"
/>
)}
</button>
)
})
) : (
<div className="px-3 py-8 text-center system-sm-regular text-text-tertiary">
{emptyText}
</div>
)}
</div>
<div className="flex items-center gap-3 border-t border-divider-subtle px-3 py-2 system-xs-regular text-text-tertiary">
<span>{navigateText}</span>
<span>{confirmText}</span>
</div>
</div>
)
}
export function EditableMetadataField({
label,
multiline = false,
onLabelChange,
onBlurCapture,
onRemove,
onValueChange,
readOnly = false,
value,
valuePlaceholder,
}: {
label: string
multiline?: boolean
onLabelChange?: (value: string) => void
onBlurCapture?: FocusEventHandler<HTMLDivElement>
onRemove?: () => void
onValueChange?: (value: string) => void
readOnly?: boolean
value: string
valuePlaceholder?: string
}) {
const controlClassName =
'w-full resize-none rounded-md border-0 bg-transparent px-1 py-0.5 text-[14px]/5 text-text-primary outline-hidden transition-[background-color,box-shadow] placeholder:text-text-quaternary hover:bg-state-base-hover focus:bg-components-input-bg-active focus:shadow-xs focus:inset-ring-1 focus:inset-ring-components-input-border-active'
return (
<div className="flex w-full flex-col gap-0.5" onBlurCapture={onBlurCapture}>
<div className="flex h-6 items-center gap-1">
{readOnly || !onLabelChange ? (
<span className="min-w-0 truncate px-1 py-0.5 system-sm-medium text-text-tertiary">
{label}
</span>
) : (
<input
aria-label={label}
value={label}
className="[field-sizing:content] max-w-[calc(100%-28px)] min-w-0 rounded-[5px] border-0 bg-transparent px-1 py-0.5 system-sm-medium text-text-tertiary outline-hidden hover:bg-state-base-hover focus:bg-components-input-bg-active focus:text-text-placeholder focus:shadow-xs focus:inset-ring-1 focus:inset-ring-components-input-border-active"
onChange={(event) => onLabelChange(event.target.value)}
/>
)}
{onRemove && !readOnly && (
<button
type="button"
aria-label={`Remove ${label}`}
className="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onRemove}
>
<span aria-hidden className="i-ri-delete-bin-line size-3.5" />
</button>
)}
</div>
{readOnly || !onValueChange ? (
<div className="min-h-6 px-1 py-0.5 text-[14px]/5 break-words whitespace-pre-wrap text-text-primary">
{value || valuePlaceholder || '-'}
</div>
) : multiline ? (
<Textarea
aria-label={`${label} value`}
value={value}
minRows={1}
maxRows={8}
placeholder={valuePlaceholder}
className={controlClassName}
onChange={(event) => onValueChange(event.target.value)}
/>
) : (
<input
aria-label={`${label} value`}
value={value}
placeholder={valuePlaceholder}
className={controlClassName}
onChange={(event) => onValueChange(event.target.value)}
/>
)}
</div>
)
}
export function EditableMetadataEntry({
entryKey,
onCommit,
onRemove,
value,
}: {
entryKey: string
onCommit: (previousKey: string, nextKey: string, value: string) => void
onRemove: () => void
value: string
}) {
const [keyDraft, setKeyDraft] = useState(entryKey)
const [valueDraft, setValueDraft] = useState(value)
const commit = () => {
const nextKey = keyDraft.trim()
if (!nextKey) {
setKeyDraft(entryKey)
return
}
if (nextKey === entryKey && valueDraft === value) return
onCommit(entryKey, nextKey, valueDraft)
}
return (
<EditableMetadataField
label={keyDraft}
value={valueDraft}
multiline
onLabelChange={setKeyDraft}
onRemove={onRemove}
onValueChange={setValueDraft}
valuePlaceholder="Value"
onBlurCapture={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return
commit()
}}
/>
)
}
export function MarkdownSourceEditor({
editorRef,
onChange,
onKeyDown,
placeholder,
readOnly,
value,
}: {
editorRef: RefObject<HTMLTextAreaElement | null>
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void
placeholder: string
readOnly: boolean
value: string
}) {
const [focused, setFocused] = useState(false)
const [selectionOffset, setSelectionOffset] = useState(0)
const [scrollTop, setScrollTop] = useState(0)
const lineCount = Math.max(value.split('\n').length, 48)
const currentLine = getCurrentLine(value, selectionOffset)
const showPlaceholder = focused && currentLine.blank
const syncSelection = (target: HTMLTextAreaElement) => {
setSelectionOffset(target.selectionStart)
}
return (
<div className="relative h-full overflow-hidden bg-background-default">
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 w-10 overflow-hidden bg-background-default pt-1.5 text-right font-mono text-[13px]/[22px] text-text-quaternary"
>
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
{Array.from({ length: lineCount }, (_, index) => (
<div key={index} className="h-[22px] pr-2">
{String(index + 1).padStart(2, '0')}
</div>
))}
</div>
</div>
<textarea
ref={editorRef}
readOnly={readOnly}
value={value}
wrap="off"
spellCheck={false}
className={cn(
'h-full w-full resize-none bg-transparent py-1.5 pr-20 pl-10 text-[14px]/[22px] text-text-primary outline-hidden read-only:text-text-secondary',
styles.sourceTextarea,
)}
onBlur={() => setFocused(false)}
onChange={(event) => {
syncSelection(event.currentTarget)
onChange(event)
}}
onClick={(event) => syncSelection(event.currentTarget)}
onFocus={(event) => {
setFocused(true)
syncSelection(event.currentTarget)
}}
onKeyDown={onKeyDown}
onKeyUp={(event) => syncSelection(event.currentTarget)}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
onSelect={(event) => syncSelection(event.currentTarget)}
/>
{showPlaceholder && (
<EditorPlaceholder
text={placeholder}
className="absolute left-10 text-[14px]/[22px]"
style={{ top: 6 + currentLine.lineIndex * 22 - scrollTop }}
/>
)}
</div>
)
}
export function MarkdownBodyReferencePreview({
body,
className,
onOpenReference,
placeholder,
}: {
body: string
className?: string
onOpenReference?: (path: string) => void
placeholder: string
}) {
const customComponents = useSkillMarkdownComponents(onOpenReference)
if (!body) {
return (
<div className={cn('text-[15px]/7 whitespace-pre-wrap text-text-quaternary', className)}>
{placeholder}
</div>
)
}
return (
<Markdown
content={body}
customComponents={customComponents}
remarkPlugins={SKILL_REFERENCE_REMARK_PLUGINS}
className={cn('text-[15px]/7 text-text-secondary', className)}
/>
)
}
export function MarkdownLiveBodyEditor({
body,
contentRevision,
editorRef,
onInput,
onKeyDown,
onOpenReference,
placeholder,
}: {
body: string
contentRevision: number
editorRef: RefObject<HTMLDivElement | null>
onInput: (event: FormEvent<HTMLDivElement>) => void
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void
onOpenReference: (path: string) => void
placeholder: string
}) {
const renderedBodyRef = useRef<string | null>(null)
const renderedContentRevisionRef = useRef(contentRevision)
const pendingPreviewAnchorRef = useRef<{
previousScrollTop: number
scrollParent: HTMLElement
selectionTop?: number
sourceOffset: number
} | null>(null)
const previewRef = useRef<HTMLDivElement>(null)
const [focused, setFocused] = useState(false)
const [selectionLineBlank, setSelectionLineBlank] = useState<boolean>()
const [placeholderPosition, setPlaceholderPosition] = useState<{
left: number
top: number
}>()
const [selectionOffset, setSelectionOffset] = useState(0)
const [referenceTooltip, setReferenceTooltip] = useState<{
left: number
path: string
top: number
} | null>(null)
const currentLine = getCurrentLine(body, selectionOffset)
const showPlaceholder =
(!focused && !body.trim()) || (focused && (selectionLineBlank ?? currentLine.blank))
const showRenderedPreview = !focused && Boolean(body.trim())
const customComponents = useSkillMarkdownComponents(onOpenReference)
const syncSelection = () => {
const root = editorRef.current
if (!root) return
setSelectionOffset(getMarkdownLiveEditorSelectionOffset(root) ?? 0)
setSelectionLineBlank(getSelectionLineBlank(root))
setPlaceholderPosition(getSelectionLinePosition(root))
}
const getReferencePath = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return
return target.closest<HTMLElement>('[data-reference-path]')?.dataset.referencePath
}
const enterEditMode = (root: HTMLElement, selectionOffset: number, clientY?: number) => {
const scrollParent = getScrollParent(root)
const previousScrollTop = scrollParent?.scrollTop
setFocused(true)
window.requestAnimationFrame(() => {
const editor = editorRef.current
if (!editor) return
editor.focus({ preventScroll: true })
setMarkdownLiveEditorSelectionOffset(editor, selectionOffset)
setSelectionOffset(selectionOffset)
setSelectionLineBlank(getSelectionLineBlank(editor))
setPlaceholderPosition(getSelectionLinePosition(editor))
if (!scrollParent || previousScrollTop == null) return
if (clientY == null) {
scrollParent.scrollTop = previousScrollTop
return
}
const selectionRect = getSelectionRect(editor)
if (selectionRect) scrollParent.scrollTop += selectionRect.top - clientY
else scrollParent.scrollTop = previousScrollTop
})
}
const exitEditMode = (root: HTMLElement) => {
const scrollParent = getScrollParent(root)
if (scrollParent) {
pendingPreviewAnchorRef.current = {
previousScrollTop: scrollParent.scrollTop,
scrollParent,
selectionTop: getSelectionRect(root)?.top,
sourceOffset: getMarkdownLiveEditorSelectionOffset(root) ?? selectionOffset,
}
}
setFocused(false)
}
useLayoutEffect(() => {
if (focused) return
const anchor = pendingPreviewAnchorRef.current
const preview = previewRef.current
if (!anchor || !preview) return
const restoreAnchor = () => {
const anchorRect = getMarkdownPreviewAnchorRect(preview, body, anchor.sourceOffset)
if (!anchorRect || anchor.selectionTop == null) return false
anchor.scrollParent.scrollTop += anchorRect.top - anchor.selectionTop
pendingPreviewAnchorRef.current = null
return true
}
if (restoreAnchor()) return
const MutationObserver = preview.ownerDocument.defaultView?.MutationObserver
if (!MutationObserver) {
anchor.scrollParent.scrollTop = anchor.previousScrollTop
pendingPreviewAnchorRef.current = null
return
}
const observer = new MutationObserver(() => {
if (restoreAnchor()) {
observer.disconnect()
return
}
if (preview.textContent?.trim()) {
anchor.scrollParent.scrollTop = anchor.previousScrollTop
pendingPreviewAnchorRef.current = null
observer.disconnect()
}
})
observer.observe(preview, { childList: true, subtree: true })
return () => observer.disconnect()
}, [body, focused])
useLayoutEffect(() => {
const root = editorRef.current
if (!root) return
const revisionChanged = renderedContentRevisionRef.current !== contentRevision
if (renderedBodyRef.current === body && root.childNodes.length > 0 && !revisionChanged) return
if (root.ownerDocument.activeElement === root && !revisionChanged) return
renderMarkdownLiveEditorContent(root, body)
renderedBodyRef.current = body
renderedContentRevisionRef.current = contentRevision
}, [body, contentRevision, editorRef, focused])
useLayoutEffect(() => {
if (!focused) return
const root = editorRef.current
if (!root) return
const syncSelectionState = () => {
setSelectionOffset(getMarkdownLiveEditorSelectionOffset(root) ?? 0)
setSelectionLineBlank(getSelectionLineBlank(root))
setPlaceholderPosition(getSelectionLinePosition(root))
}
const syncPlaceholderPosition = () => setPlaceholderPosition(getSelectionLinePosition(root))
root.ownerDocument.addEventListener('selectionchange', syncSelectionState)
const ResizeObserver = root.ownerDocument.defaultView?.ResizeObserver
const observer = ResizeObserver ? new ResizeObserver(syncPlaceholderPosition) : null
observer?.observe(root)
return () => {
root.ownerDocument.removeEventListener('selectionchange', syncSelectionState)
observer?.disconnect()
}
}, [editorRef, focused])
return (
<div className="relative min-h-[360px]">
{showRenderedPreview && (
<div
ref={previewRef}
role="textbox"
aria-label={placeholder}
aria-multiline="true"
tabIndex={0}
className="relative z-[2] min-h-[360px] cursor-text outline-none focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClickCapture={(event) => {
if (!(event.target instanceof HTMLElement)) return
const anchor = event.target.closest<HTMLAnchorElement>('a[href]')
const href = anchor?.getAttribute('href')
if (!href || href.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(href)) return
event.preventDefault()
onOpenReference(decodeURIComponent(href))
}}
onMouseDown={(event) => {
if (
event.target instanceof HTMLElement &&
event.target.closest('a[href], button[data-reference-path]')
)
return
event.preventDefault()
enterEditMode(
event.currentTarget,
getMarkdownPreviewSelectionOffset(
event.currentTarget,
event.target,
body,
event.clientX,
event.clientY,
),
event.clientY,
)
}}
onFocus={(event) => {
if (event.currentTarget !== event.target) return
enterEditMode(event.currentTarget, 0)
}}
>
<Markdown
content={body}
customComponents={customComponents}
remarkPlugins={SKILL_REFERENCE_REMARK_PLUGINS}
/>
</div>
)}
{!showRenderedPreview && (
<div
ref={editorRef}
contentEditable
role="textbox"
aria-label={placeholder}
aria-multiline="true"
tabIndex={0}
suppressContentEditableWarning
className="relative z-[1] min-h-[360px] w-full bg-transparent text-[15px]/7 break-words whitespace-pre-wrap text-text-secondary caret-text-secondary outline-none"
onBlur={(event) => exitEditMode(event.currentTarget)}
onClick={(event: MouseEvent<HTMLDivElement>) => {
const referencePath = getReferencePath(event.target)
if (referencePath) {
event.preventDefault()
onOpenReference(referencePath)
return
}
syncSelection()
}}
onFocus={() => {
setFocused(true)
syncSelection()
}}
onInput={(event) => {
onInput(event)
syncSelection()
}}
onKeyDown={(event) => {
const referencePath = getReferencePath(event.target)
if (referencePath && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
onOpenReference(referencePath)
return
}
onKeyDown(event)
}}
onKeyUp={syncSelection}
onMouseOut={(event) => {
const referencePath = getReferencePath(event.target)
if (!referencePath) return
if (
event.relatedTarget instanceof HTMLElement &&
getReferencePath(event.relatedTarget) === referencePath
) {
return
}
setReferenceTooltip(null)
}}
onMouseOver={(event) => {
if (!(event.target instanceof HTMLElement)) return
const reference = event.target.closest<HTMLElement>('[data-reference-path]')
const path = reference?.dataset.referencePath
if (!reference || !path) return
const rootRect = event.currentTarget.getBoundingClientRect()
const referenceRect = reference.getBoundingClientRect()
setReferenceTooltip({
left: referenceRect.left - rootRect.left + referenceRect.width / 2,
path,
top: referenceRect.top - rootRect.top - 6,
})
}}
/>
)}
{referenceTooltip && (
<span
role="tooltip"
style={{ left: referenceTooltip.left, top: referenceTooltip.top }}
className="pointer-events-none absolute z-20 max-w-80 -translate-x-1/2 -translate-y-full rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-2 py-1.5 system-xs-medium break-all whitespace-normal text-text-secondary shadow-lg backdrop-blur-[5px]"
>
{referenceTooltip.path}
</span>
)}
{showPlaceholder && (
<EditorPlaceholder
text={placeholder}
className="absolute text-[15px]/7"
style={{
left: placeholderPosition?.left ?? 0,
top: placeholderPosition?.top ?? currentLine.lineIndex * 28,
}}
/>
)}
</div>
)
}
export function CsvTablePreview({ rows }: { rows: string[][] }) {
const columnCount = rows.reduce((count, row) => Math.max(count, row.length), 0)
if (rows.length === 0 || columnCount === 0) {
return (
<div className="flex h-full items-center justify-center bg-background-default">
<span className="system-sm-regular text-text-tertiary">-</span>
</div>
)
}
const [headerRow, ...bodyRows] = rows
const headers = Array.from({ length: columnCount }, (_, index) => headerRow?.[index] ?? '')
const columnKeys = Array.from({ length: columnCount }, (_, index) => `column-${index + 1}`)
return (
<div className="flex h-full flex-col gap-1 overflow-hidden bg-background-default p-1">
<div className="flex h-6 shrink-0 items-center">
<button
type="button"
className="flex h-6 items-center gap-1 rounded-md px-1.5 system-xs-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-table-line size-3.5" />
workspace
<span aria-hidden className="i-ri-arrow-down-s-line size-3.5" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full border-separate border-spacing-0 border-t border-l border-divider-subtle text-left">
<thead className="sticky top-0 z-10 bg-background-section">
<tr>
{headers.map((header, index) => (
<th
key={columnKeys[index]}
scope="col"
className="h-7 max-w-72 min-w-32 border-r border-b border-divider-subtle px-2 py-1 system-xs-medium text-text-secondary"
>
<span className="block truncate" title={header || undefined}>
{header || `#${index + 1}`}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row) => (
<tr key={row.join('\u0000')} className="hover:bg-state-base-hover">
{columnKeys.map((columnKey, columnIndex) => {
const value = row[columnIndex] ?? ''
return (
<td
key={columnKey}
className="h-7 max-w-72 min-w-32 border-r border-b border-divider-subtle px-2 py-1 align-top system-xs-regular text-text-secondary"
>
<span
className="block break-words whitespace-pre-wrap"
title={value || undefined}
>
{value || '-'}
</span>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
export function VersionActionBar({
onExit,
onRestore,
restoring,
version,
}: {
onExit: () => void
onRestore: () => void
restoring: boolean
version: SkillVersionResponse
}) {
const { t } = useTranslation('skill')
const { formatTime } = useTimestamp()
const publishedBy = version.published_by_name ?? version.published_by ?? '-'
const publishedAt = formatTime(
version.created_at,
t(($) => $['skillManagement.dateTimeFormat']),
)
return (
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center px-4">
<div className="pointer-events-auto flex h-14 w-[428px] max-w-[calc(100%-2rem)] items-center gap-4 rounded-xl border border-divider-subtle bg-background-default px-4 shadow-xl">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate system-sm-semibold text-text-primary">
{getSkillVersionTitle(version)}
</span>
<span className="border-state-accent-border shrink-0 rounded-[5px] border bg-state-accent-hover px-1.5 py-0.5 system-2xs-semibold-uppercase text-text-accent">
{t(($) => $['skillManagement.detail.viewOnly'])}
</span>
</div>
<div className="mt-0.5 truncate system-xs-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.versionPublishedMeta'], {
name: publishedBy,
time: publishedAt,
})}
</div>
</div>
<Button variant="primary" className="h-9 px-5" loading={restoring} onClick={onRestore}>
{t(($) => $['skillManagement.detail.restoreVersion'])}
</Button>
<Button variant="secondary" className="h-9 px-4" onClick={onExit}>
<span aria-hidden className="i-ri-history-line size-4" />
{t(($) => $['skillManagement.detail.exitVersions'])}
</Button>
</div>
</div>
)
}