'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[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(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(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>( () => ({ a: ({ children, href }: SkillMarkdownLinkProps) => { const referencePath = getMarkdownReferencePath(href) if (!referencePath) { return ( {children} ) } const referenceLabel = typeof children === 'string' || typeof children === 'number' ? String(children) : getPathBaseName(referencePath) return ( ) }, }), [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 ( {beforeShortcut} / {afterShortcut} ) } 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 (
) } 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 (
{currentDirectory || title} {query && ( {query} )}
{currentDirectory && ( )} {files.length > 0 ? ( files.map((referenceFile, index) => { const selected = index === selectedIndex return ( ) }) ) : (
{emptyText}
)}
{navigateText} {confirmText}
) } export function EditableMetadataField({ label, multiline = false, onLabelChange, onBlurCapture, onRemove, onValueChange, readOnly = false, value, valuePlaceholder, }: { label: string multiline?: boolean onLabelChange?: (value: string) => void onBlurCapture?: FocusEventHandler 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 (
{readOnly || !onLabelChange ? ( {label} ) : ( onLabelChange(event.target.value)} /> )} {onRemove && !readOnly && ( )}
{readOnly || !onValueChange ? (
{value || valuePlaceholder || '-'}
) : multiline ? (