diff --git a/common/changes/@coze-editor/core/feature-chat_2025-11-14-09-06.json b/common/changes/@coze-editor/core/feature-chat_2025-11-14-09-06.json new file mode 100644 index 00000000..7351549f --- /dev/null +++ b/common/changes/@coze-editor/core/feature-chat_2025-11-14-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/core", + "comment": "add chat preset", + "type": "minor" + } + ], + "packageName": "@coze-editor/core", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/editor/feature-chat_2025-11-14-09-06.json b/common/changes/@coze-editor/editor/feature-chat_2025-11-14-09-06.json new file mode 100644 index 00000000..71b1ff53 --- /dev/null +++ b/common/changes/@coze-editor/editor/feature-chat_2025-11-14-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/editor", + "comment": "add chat preset", + "type": "minor" + } + ], + "packageName": "@coze-editor/editor", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-14-09-06.json b/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-14-09-06.json new file mode 100644 index 00000000..193d36f6 --- /dev/null +++ b/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-14-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/preset-chat", + "comment": "add chat preset", + "type": "minor" + } + ], + "packageName": "@coze-editor/preset-chat", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-20-12-28.json b/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-20-12-28.json new file mode 100644 index 00000000..f8129eee --- /dev/null +++ b/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-20-12-28.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/preset-chat", + "comment": "remove unmount", + "type": "patch" + } + ], + "packageName": "@coze-editor/preset-chat", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-21-10-40.json b/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-21-10-40.json new file mode 100644 index 00000000..dd994342 --- /dev/null +++ b/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-21-10-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/preset-chat", + "comment": "support customize element.toString", + "type": "minor" + } + ], + "packageName": "@coze-editor/preset-chat", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-22-08-20.json b/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-22-08-20.json new file mode 100644 index 00000000..4d2af9cb --- /dev/null +++ b/common/changes/@coze-editor/preset-chat/feature-chat_2025-11-22-08-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/preset-chat", + "comment": "support array for attribute", + "type": "minor" + } + ], + "packageName": "@coze-editor/preset-chat", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-01-05-44.json b/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-01-05-44.json new file mode 100644 index 00000000..d6131f24 --- /dev/null +++ b/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-01-05-44.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/preset-chat", + "comment": "ensure fully parsing", + "type": "patch" + } + ], + "packageName": "@coze-editor/preset-chat", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-10-06-23.json b/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-10-06-23.json new file mode 100644 index 00000000..d9009ab0 --- /dev/null +++ b/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-10-06-23.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/preset-chat", + "comment": "add options.validTagNames", + "type": "patch" + } + ], + "packageName": "@coze-editor/preset-chat", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-18-13-08.json b/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-18-13-08.json new file mode 100644 index 00000000..6193f74f --- /dev/null +++ b/common/changes/@coze-editor/preset-chat/feature-chat_2025-12-18-13-08.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/preset-chat", + "comment": "skip if custom text is not in clipboard", + "type": "minor" + } + ], + "packageName": "@coze-editor/preset-chat", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/changes/@coze-editor/react/feature-chat_2025-11-14-09-06.json b/common/changes/@coze-editor/react/feature-chat_2025-11-14-09-06.json new file mode 100644 index 00000000..ff3abe86 --- /dev/null +++ b/common/changes/@coze-editor/react/feature-chat_2025-11-14-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@coze-editor/react", + "comment": "add chat preset", + "type": "minor" + } + ], + "packageName": "@coze-editor/react", + "email": "stream-pipe@users.noreply.github.com" +} diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 509b5c16..5125b24f 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -1677,6 +1677,9 @@ importers: '@coze-editor/extensions': specifier: workspace:* version: link:../extensions + '@coze-editor/preset-chat': + specifier: workspace:* + version: link:../preset-chat '@coze-editor/preset-code': specifier: workspace:* version: link:../preset-code @@ -2301,6 +2304,70 @@ importers: specifier: ^5.8.2 version: 5.8.2 + ../../../packages/text-editor/preset-chat: + dependencies: + '@codemirror/language': + specifier: ^6.10.1 + version: 6.11.1 + '@coze-editor/core': + specifier: workspace:* + version: link:../core + '@coze-editor/core-plugins': + specifier: workspace:* + version: link:../core-plugins + '@coze-editor/extensions': + specifier: workspace:* + version: link:../extensions + '@coze-editor/preset-universal': + specifier: workspace:* + version: link:../preset-universal + '@coze-editor/utils': + specifier: workspace:* + version: link:../utils + '@lezer/common': + specifier: ^1.2.2 + version: 1.2.3 + '@lezer/html': + specifier: ^1.3.10 + version: 1.3.12 + devDependencies: + '@codemirror/commands': + specifier: ^6.3.3 + version: 6.8.1 + '@codemirror/state': + specifier: ^6.4.1 + version: 6.5.2 + '@codemirror/view': + specifier: ^6.26.1 + version: 6.37.1 + '@coze-arch/ts-config': + specifier: workspace:* + version: link:../../../config/ts-config + '@coze-editor/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@coze-editor/react': + specifier: workspace:* + version: link:../react + '@types/node': + specifier: ^22 + version: 22.13.13 + '@types/react': + specifier: 18.2.37 + version: 18.2.37 + '@types/react-dom': + specifier: 18.2.15 + version: 18.2.15 + eslint: + specifier: 9.14.0 + version: 9.14.0(jiti@2.6.1) + tsup: + specifier: ^8.0.1 + version: 8.5.0(@swc/core@1.11.29(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.5)(tsx@4.19.3)(typescript@5.8.2)(yaml@2.8.0) + typescript: + specifier: ^5.8.2 + version: 5.8.2 + ../../../packages/text-editor/preset-code: dependencies: '@codemirror/autocomplete': @@ -2761,6 +2828,9 @@ importers: '@coze-editor/core': specifier: workspace:* version: link:../core + '@coze-editor/utils': + specifier: workspace:* + version: link:../utils devDependencies: '@codemirror/state': specifier: ^6.4.1 diff --git a/packages/text-editor/dev/src/index.tsx b/packages/text-editor/dev/src/index.tsx index 832aa6d7..0ee94c86 100644 --- a/packages/text-editor/dev/src/index.tsx +++ b/packages/text-editor/dev/src/index.tsx @@ -1,5 +1,5 @@ import { createRoot } from 'react-dom/client'; -import Page from './pages/highlight'; +import Page from './pages/chat'; import './index.css'; createRoot(document.getElementById('app')!).render(); diff --git a/packages/text-editor/dev/src/pages/chat/code.ts b/packages/text-editor/dev/src/pages/chat/code.ts new file mode 100644 index 00000000..da21e3e7 --- /dev/null +++ b/packages/text-editor/dev/src/pages/chat/code.ts @@ -0,0 +1,91 @@ +const code = `/** @flow */ + +import * as React from 'react'; +import {forwardRef} from 'react'; +import Bridge from 'react-devtools-shared/src/bridge'; +import Store from 'react-devtools-shared/src/devtools/store'; +import DevTools from 'react-devtools-shared/src/devtools/views/DevTools'; +import {getSavedComponentFilters} from 'react-devtools-shared/src/utils'; + +import type {Wall} from 'react-devtools-shared/src/frontend/types'; +import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type {Props} from 'react-devtools-shared/src/devtools/views/DevTools'; +import type {Config} from 'react-devtools-shared/src/devtools/store'; + +export function createStore(bridge: FrontendBridge, config?: Config): Store { + return new Store(bridge, { + checkBridgeProtocolCompatibility: true, + supportsTraceUpdates: true, + supportsTimeline: true, + ...config, + }); +} + +export function createBridge(contentWindow: any, wall?: Wall): FrontendBridge { + if (wall == null) { + wall = { + listen(fn) { + // $FlowFixMe[missing-local-annot] + const onMessage = ({data}) => { + fn(data); + }; + window.addEventListener('message', onMessage); + return () => { + window.removeEventListener('message', onMessage); + }; + }, + send(event: string, payload: any, transferable?: Array) { + contentWindow.postMessage({event, payload}, '*', transferable); + }, + }; + } + + return (new Bridge(wall): FrontendBridge); +} + +export function initialize( + contentWindow: any, + { + bridge, + store, + }: { + bridge?: FrontendBridge, + store?: Store, + } = {}, +): component(...props: Props) { + if (bridge == null) { + bridge = createBridge(contentWindow); + } + + // Type refinement. + const frontendBridge = ((bridge: any): FrontendBridge); + + if (store == null) { + store = createStore(frontendBridge); + } + + const onGetSavedPreferences = () => { + // This is the only message we're listening for, + // so it's safe to cleanup after we've received it. + frontendBridge.removeListener('getSavedPreferences', onGetSavedPreferences); + + const data = { + componentFilters: getSavedComponentFilters(), + }; + + // The renderer interface can't read saved preferences directly, + // because they are stored in localStorage within the context of the extension. + // Instead it relies on the extension to pass them through. + frontendBridge.send('savedPreferences', data); + }; + + frontendBridge.addListener('getSavedPreferences', onGetSavedPreferences); + + const ForwardRef = forwardRef((props, ref) => ( + + )); + ForwardRef.displayName = 'DevTools'; + + return ForwardRef; +}` +export default code diff --git a/packages/text-editor/dev/src/pages/chat/completion.tsx b/packages/text-editor/dev/src/pages/chat/completion.tsx new file mode 100644 index 00000000..35fbfa0d --- /dev/null +++ b/packages/text-editor/dev/src/pages/chat/completion.tsx @@ -0,0 +1,46 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { SVGProps } from 'react' + +function GravityUiCode(props: SVGProps) { + return ( + {/* Icon from Gravity UI Icons by YANDEX LLC - https://github.com/gravity-ui/icons/blob/main/LICENSE */} + ) +} + +function GravityUiGhost(props: SVGProps) { + return ( + {/* Icon from Gravity UI Icons by YANDEX LLC - https://github.com/gravity-ui/icons/blob/main/LICENSE */} + ) +} + +function Completion({ query = '' }: { query: string }) { + return + + + No results found + + + + files + + + + chat + + + + +} + +export { + Completion, +} diff --git a/packages/text-editor/dev/src/pages/chat/elements.tsx b/packages/text-editor/dev/src/pages/chat/elements.tsx new file mode 100644 index 00000000..eb1cfbb2 --- /dev/null +++ b/packages/text-editor/dev/src/pages/chat/elements.tsx @@ -0,0 +1,150 @@ +import { EditorAPI, useCurrentElement } from '@coze-editor/editor/preset-chat'; +import { useEditor } from '@coze-editor/editor/react'; +import { Popover } from '@douyinfe/semi-ui'; +import { SVGProps, useContext } from 'react'; +import { UploadingContext } from './upload'; + +function GravityUiCode(props: SVGProps) { + return ( + {/* Icon from Gravity UI Icons by YANDEX LLC - https://github.com/gravity-ui/icons/blob/main/LICENSE */} + ) +} + +function GravityUiGhost(props: SVGProps) { + return ( + {/* Icon from Gravity UI Icons by YANDEX LLC - https://github.com/gravity-ui/icons/blob/main/LICENSE */} + ) +} + +function FlowbiteCloseOutline(props: SVGProps) { + return ( + {/* Icon from Flowbite Icons by Themesberg - https://github.com/themesberg/flowbite-icons/blob/main/LICENSE */} + ) +} + +function FileReference({ name, path }: { name: string; path: string; }) { + const editor = useEditor(); + const { isSelected, select, remove } = useCurrentElement() + + return ( + + { + select() + }} + > + + + {name} + + { + remove() + editor?.focus() + }} + /> + + + ) +} + +function AIReference() { + const editor = useEditor(); + const { isSelected, select, remove } = useCurrentElement() + + return ( + + { + select() + }} + > + + Chat + { + remove() + editor?.focus() + }} + /> + + + ) +} + +function FluentDocumentPdf32Regular(props: SVGProps) { + return ( + {/* Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE */} + ) +} + +function LineMdUploadingLoop(props: SVGProps) { + return ( + {/* Icon from Material Line Icons by Vjacheslav Trushkin - https://github.com/cyberalien/line-md/blob/master/license.txt */} + ) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function UploadFile({ filename, array }: { filename: string; array: any[] }) { + const uploading = useContext(UploadingContext) + console.log('array', array) + return + {uploading ? + <>上传中... : + <> + + {filename} + } + + +} + +const elements = { + file: { + render: FileReference, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + toString(el: any) { + return `[custom string from file - ${el.attributes.name}]` + }, + }, + ai: { + render: AIReference, + }, + upload: { + render: UploadFile, + } +} + +export default elements diff --git a/packages/text-editor/dev/src/pages/chat/index.tsx b/packages/text-editor/dev/src/pages/chat/index.tsx new file mode 100644 index 00000000..efebec4b --- /dev/null +++ b/packages/text-editor/dev/src/pages/chat/index.tsx @@ -0,0 +1,135 @@ +import preset, { schemaUtils, type EditorAPI } from '@coze-editor/editor/preset-chat' +import { type EditorAPI as CodeEditorAPI } from '@coze-editor/editor/preset-code' +import { EditorProvider, PositionMirror, Renderer, useEditor, useMouseSelectionPopup } from '@coze-editor/editor/react' +import { EditorView } from '@codemirror/view' +import { useRef, useState } from 'react' +import { Extension } from '@codemirror/state'; +import { astDebugger } from '@coze-editor/editor'; +import { Popover } from '@douyinfe/semi-ui'; +import { CodeEditor } from '../code'; +import code from './code' +import elements from './elements' +import MentionImpl from './mention' +import { UploadContext, UploadFile } from './upload' + +const extensions: Extension[] = [ + astDebugger, + EditorView.lineWrapping, +]; + +const defaultValue = schemaUtils.fromJSON([ + { + type: 'element', + tagName: 'file', + attributes: { + name: 'index.ts"[100-110]', + num: 1.1, + bool: true, + path: 'path', + } + }, + { + type: 'text', + value: '修改', + }, + { + type: 'element', + tagName: 'ai', + attributes: { + text: 'foobar', + }, + } +]) + +function Page() { + const editorRef = useRef(null) + + return
+
+ + + { + editorRef.current = api + }} + onChange={(e) => { + console.log('schema', schemaUtils.toJSON(e.value, { + validTagNames: Object.keys(elements), + })) + }} + /> + + + + + +
+
+ + + { + editorRef.current?.insertElement({ + tagName: 'file', + attributes: { + name: `index.ts[${range.from}-${range.to}]`, + path: 'src/index.ts' + }, + }) + }} + > + +
+
+} + +function CodePopup({ onReference }: { onReference: (range: { from: number; to: number }) => void }) { + const { head, visible } = useMouseSelectionPopup() + const [posKey, setPosKey] = useState('') + const editor = useEditor() + + function reference() { + const range = editor?.getSelection() + if (range) { + onReference(range) + } + } + + return <> + + + + } + > + setPosKey(String(Math.random()))} + /> + + +} + +export default Page diff --git a/packages/text-editor/dev/src/pages/chat/mention.tsx b/packages/text-editor/dev/src/pages/chat/mention.tsx new file mode 100644 index 00000000..bc14b9da --- /dev/null +++ b/packages/text-editor/dev/src/pages/chat/mention.tsx @@ -0,0 +1,122 @@ +import { EditorAPI, schemaUtils } from '@coze-editor/editor/preset-chat'; +import { getCurrentMentionReplaceRange, Mention, PositionMirror, useEditor, useEditorEvent } from '@coze-editor/editor/react'; +import { Popover } from '@douyinfe/semi-ui'; +import { useEffect, useState } from 'react'; +import useInputs from './use-inputs'; +import { Completion } from './completion'; + +function MentionImpl() { + const editor = useEditor() + + const [open, setOpen] = useState(false) + const [pos, setPos] = useState(-1) + const [prefix, setPrefix] = useState('') + const [posKey, setPosKey] = useState('') + + // 出现补全提示时,禁用快捷键 + useEffect(() => { + if (!editor) { + return; + } + + if (open) { + editor.disableKeybindings([ + 'Enter', + 'ArrowUp', + 'ArrowDown', + ]) + } else { + editor.disableKeybindings([]) + } + }, [editor, open]) + + // 失焦时,隐藏补全面板 + useEditorEvent(editor, 'blur', () => { + setOpen(false) + }) + + // 处理 按键<上 | 下 | 回车>、点击 操作 + useInputs({ + onApply(value) { + if (!editor) { + return; + } + + const range = getCurrentMentionReplaceRange(editor.$view.state) + if (range) { + if (value === 'files') { + const string = schemaUtils.fromJSON([ + { + type: 'element', + tagName: 'file', + attributes: { + name: `index.ts[${range.from}-${range.to}]`, + path: 'src/index.ts' + }, + } + ]) + editor.replaceText({ + ...range, + text: string + }) + } else if (value === 'chat') { + const string = schemaUtils.fromJSON([ + { + type: 'element', + tagName: 'ai', + attributes: {}, + } + ]) + editor.replaceText({ + ...range, + text: string + }) + } + + setOpen(false) + } + } + }) + + return <> + e.preventDefault()}> + + + } + > + { + setPosKey(String(Math.random())) + }} + /> + + + { + const isOpen = e.value + const pos = e.state.selection.main.head + + if (isOpen) { + setPos(pos) + } else { + setPos(-1) + } + setOpen(isOpen) + }} + onSearch={(e) => { + setPrefix(e.value) + }} + /> + +} + +export default MentionImpl diff --git a/packages/text-editor/dev/src/pages/chat/upload.tsx b/packages/text-editor/dev/src/pages/chat/upload.tsx new file mode 100644 index 00000000..f3e31b25 --- /dev/null +++ b/packages/text-editor/dev/src/pages/chat/upload.tsx @@ -0,0 +1,60 @@ +import { Button } from '@/components/ui/button' +import { EditorAPI } from '@coze-editor/editor/preset-chat' +import { useEditor } from '@coze-editor/editor/react' +import { createContext, ReactNode, useContext, useState } from 'react' + +const UploadingContext = createContext(false) +const SetUploadingContext = createContext>>(() => {}) + +function UploadFile() { + const setUploading = useContext(SetUploadingContext) + const editor = useEditor() + + async function insertFile() { + if (!editor) { + return + } + + setUploading(true) + + editor.insertElement({ + tagName: 'upload', + attributes: { + filename: 'my.pdf', + array: [ + { foo: 1 }, + true, + 'bar', + ], + }, + }) + + await delay(2000) + + setUploading(false) + } + + return +} + +function UploadContext({ children }: { children?: ReactNode }) { + const [uploading, setUploading] = useState(false) + + return + + {children} + + +} + +function delay(duration: number) { + return new Promise(resolve => { + return setTimeout(resolve, duration) + }) +} + +export { + UploadContext, + UploadingContext, + UploadFile, +} diff --git a/packages/text-editor/dev/src/pages/chat/use-inputs.ts b/packages/text-editor/dev/src/pages/chat/use-inputs.ts new file mode 100644 index 00000000..95b8039a --- /dev/null +++ b/packages/text-editor/dev/src/pages/chat/use-inputs.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; + +function useInputs({ onApply }: { onApply: (value: string) => void }) { + // keyboard operations + useEffect(() => { + function handleKeydown(e: KeyboardEvent) { + const root = document.querySelector('[cmdk-root]') + if ( + root && + ['ArrowDown', 'ArrowUp'].includes(e.key) && + e.target !== root + ) { + const event = new KeyboardEvent('keydown', { + key: e.key, + code: e.code, + keyCode: e.keyCode, + which: e.which, + bubbles: true, + cancelable: true, + }); + root.dispatchEvent(event) + } + + if (e.key === 'Enter') { + const value = document.querySelector('[cmdk-item][data-selected="true"]')?.getAttribute('data-value') + if (value) { + onApply(value) + } + } + } + + function handleClick(e: MouseEvent) { + const item = (e.target as (HTMLElement | null))?.closest('[cmdk-item]') + if (item) { + const value = item.getAttribute('data-value') + if (value) { + onApply(value) + } + } + } + + document.addEventListener('keydown', handleKeydown, false) + document.addEventListener('click', handleClick, false) + + return () => { + document.removeEventListener('keydown', handleKeydown, false) + document.removeEventListener('click', handleClick, false) + } + }, [onApply]) +} + +export default useInputs \ No newline at end of file diff --git a/packages/text-editor/dev/src/pages/code/index.tsx b/packages/text-editor/dev/src/pages/code/index.tsx index 9025aa67..f8dfd289 100644 --- a/packages/text-editor/dev/src/pages/code/index.tsx +++ b/packages/text-editor/dev/src/pages/code/index.tsx @@ -68,4 +68,8 @@ function Page() { } +export { + CodeEditor +} + export default Page diff --git a/packages/text-editor/editor/package.json b/packages/text-editor/editor/package.json index f9bcb2c9..7e38406e 100644 --- a/packages/text-editor/editor/package.json +++ b/packages/text-editor/editor/package.json @@ -82,6 +82,11 @@ "require": "./dist/preset-universal-code.js", "import": "./dist/esm/preset-universal-code.js" }, + "./preset-chat": { + "types": "./dist/preset-chat.d.ts", + "require": "./dist/preset-chat.js", + "import": "./dist/esm/preset-chat.js" + }, "./preset-none": { "types": "./dist/preset-none.d.ts", "require": "./dist/preset-none.js", @@ -138,6 +143,7 @@ "@coze-editor/extension-json-unnecessary-properties": "workspace:*", "@coze-editor/extension-regexp-decorator": "workspace:*", "@coze-editor/extensions": "workspace:*", + "@coze-editor/preset-chat": "workspace:*", "@coze-editor/preset-code": "workspace:*", "@coze-editor/preset-code-languages": "workspace:*", "@coze-editor/preset-expression": "workspace:*", diff --git a/packages/text-editor/editor/src/preset-chat.ts b/packages/text-editor/editor/src/preset-chat.ts new file mode 100644 index 00000000..53b34dbb --- /dev/null +++ b/packages/text-editor/editor/src/preset-chat.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2025 coze-dev +// SPDX-License-Identifier: MIT + +export * from '@coze-editor/preset-chat'; +export { default } from '@coze-editor/preset-chat'; diff --git a/packages/text-editor/editor/tsconfig.build.json b/packages/text-editor/editor/tsconfig.build.json index aa0ed627..95efc5e0 100644 --- a/packages/text-editor/editor/tsconfig.build.json +++ b/packages/text-editor/editor/tsconfig.build.json @@ -61,6 +61,9 @@ { "path": "../preset-code-languages/tsconfig.build.json" }, + { + "path": "../preset-chat/tsconfig.build.json" + }, { "path": "../preset-code/tsconfig.build.json" }, diff --git a/packages/text-editor/editor/tsup.config.ts b/packages/text-editor/editor/tsup.config.ts index 57ec021f..5c6fae57 100644 --- a/packages/text-editor/editor/tsup.config.ts +++ b/packages/text-editor/editor/tsup.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'src/preset-variable.ts', 'src/preset-code.ts', 'src/preset-code-languages.ts', + 'src/preset-chat.ts', 'src/language-typescript.ts', 'src/language-typescript-worker.ts', 'src/language-json.ts', diff --git a/packages/text-editor/preset-chat/.npmrc b/packages/text-editor/preset-chat/.npmrc new file mode 100644 index 00000000..fb7f2353 --- /dev/null +++ b/packages/text-editor/preset-chat/.npmrc @@ -0,0 +1 @@ +//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} diff --git a/packages/text-editor/preset-chat/LICENSE b/packages/text-editor/preset-chat/LICENSE new file mode 100644 index 00000000..6a84ba97 --- /dev/null +++ b/packages/text-editor/preset-chat/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 coze-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/text-editor/preset-chat/config/rush-project.json b/packages/text-editor/preset-chat/config/rush-project.json new file mode 100644 index 00000000..31290b5a --- /dev/null +++ b/packages/text-editor/preset-chat/config/rush-project.json @@ -0,0 +1,12 @@ +{ + "operationSettings": [ + { + "operationName": "build", + "outputFolderNames": ["dist"] + }, + { + "operationName": "ts-check", + "outputFolderNames": ["./dist"] + } + ] +} diff --git a/packages/text-editor/preset-chat/eslint.config.js b/packages/text-editor/preset-chat/eslint.config.js new file mode 100644 index 00000000..cf9385eb --- /dev/null +++ b/packages/text-editor/preset-chat/eslint.config.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025 coze-dev +// SPDX-License-Identifier: MIT + +const { defineWebConfig } = require('@coze-editor/eslint-config'); + +module.exports = defineWebConfig({ + packageRoot: __dirname, +}); diff --git a/packages/text-editor/preset-chat/package.json b/packages/text-editor/preset-chat/package.json new file mode 100644 index 00000000..897e8f7f --- /dev/null +++ b/packages/text-editor/preset-chat/package.json @@ -0,0 +1,61 @@ +{ + "name": "@coze-editor/preset-chat", + "version": "0.1.0", + "description": "preset-chat", + "license": "MIT", + "author": "fengzilong", + "maintainers": [], + "sideEffects": [ + "**/*.css", + "**/*.less", + "**/*.sass", + "**/*.scss" + ], + "main": "./dist/esm/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "lint": "eslint && tsc --noEmit" + }, + "dependencies": { + "@codemirror/language": "^6.10.1", + "@coze-editor/core": "workspace:*", + "@coze-editor/core-plugins": "workspace:*", + "@coze-editor/extensions": "workspace:*", + "@coze-editor/preset-universal": "workspace:*", + "@coze-editor/utils": "workspace:*", + "@lezer/common": "^1.2.2", + "@lezer/html": "^1.3.10" + }, + "devDependencies": { + "@codemirror/commands": "^6.3.3", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.26.1", + "@coze-arch/ts-config": "workspace:*", + "@coze-editor/eslint-config": "workspace:*", + "@coze-editor/react": "workspace:*", + "@types/node": "^22", + "@types/react": "18.2.37", + "@types/react-dom": "18.2.15", + "eslint": "9.14.0", + "tsup": "^8.0.1", + "typescript": "^5.8.2" + }, + "peerDependencies": { + "@codemirror/commands": "^6.3.3", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.26.1", + "@coze-editor/react": "workspace:*", + "react": ">=18", + "react-dom": ">=18" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "test:main": "./src/index.ts" +} diff --git a/packages/text-editor/preset-chat/src/context.tsx b/packages/text-editor/preset-chat/src/context.tsx new file mode 100644 index 00000000..8eb1b145 --- /dev/null +++ b/packages/text-editor/preset-chat/src/context.tsx @@ -0,0 +1,20 @@ +import React, { createContext, type ReactNode, useContext } from 'react'; + +const Context = createContext(''); + +function ElementProvider({ + internalId, + children, +}: { + internalId: string; + children: ReactNode; +}) { + return {children}; +} + +function useElementId() { + const id = useContext(Context); + return id; +} + +export { ElementProvider, useElementId }; diff --git a/packages/text-editor/preset-chat/src/extension.ts b/packages/text-editor/preset-chat/src/extension.ts new file mode 100644 index 00000000..7fda1447 --- /dev/null +++ b/packages/text-editor/preset-chat/src/extension.ts @@ -0,0 +1,326 @@ +import { createPortal, flushSync } from 'react-dom'; +import { createElement, type ReactNode } from 'react'; + +import { createRoot, type Root } from 'react-dom/client'; +import { type SyntaxNode } from '@lezer/common'; +import { FacetCombineStrategy } from '@coze-editor/utils'; +import { connector } from '@coze-editor/react'; +import { + Decoration, + type DecorationSet, + EditorView, + WidgetType, +} from '@codemirror/view'; +import { + EditorSelection, + type EditorState, + Facet, + RangeSetBuilder, + StateField, +} from '@codemirror/state'; +import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'; + +import { extractElementData } from './utils'; +import { + type EditorElement, + type EditorNode, + INTERNAL_ID, + schemaUtils, +} from './schema'; +import { ElementProvider } from './context'; + +interface ElementDefinition { + render: (props: Attrs) => ReactNode; + toString?: (element: EditorElement) => string; +} + +interface ElementsDefinition { + [key: string]: ElementDefinition; +} + +class ElementWidget extends WidgetType { + public $$type = 'element'; + private root: Root; + private element: HTMLElement; + private view: EditorView | null = null; + constructor( + public definition: ElementDefinition, + public id: string, + public props: any, + ) { + super(); + + const element = document.createElement('span'); + this.element = element; + this.root = createRoot(element); + } + + get elementId() { + return `element-${this.id}`; + } + + toDOM(view: EditorView) { + this.view = view; + + const c = view.state.facet(connector); + queueMicrotask(() => { + flushSync(() => { + const jsxElement = createElement(ElementProvider, { + internalId: this.id, + children: createElement(this.definition.render, this.props), + }); + c.connect(this.elementId, createPortal(jsxElement, this.element)); + }); + }); + return this.element; + } + + eq(other: ElementWidget) { + return ( + this.id === other.id && + this.props && + other.props && + JSON.stringify(this.props) === JSON.stringify(other.props) + ); + } + + destroy(): void { + if (this.view) { + const c = this.view.state.facet(connector); + c.disconnect(this.elementId); + } + } + + ignoreEvent(event: Event): boolean { + return false; + } +} + +// function isElementWidget(widget: WidgetType | null) { +// return (widget as any)?.$$type === 'element'; +// } + +const elementsFacet = Facet.define< + ElementsDefinition | undefined, + ElementsDefinition | undefined +>({ + combine: FacetCombineStrategy.Last, +}); + +const field = StateField.define({ + create(state) { + return build(state); + }, + update(value, tr) { + if (tr.docChanged) { + return build(tr.state); + } + return value; + }, + provide(f) { + return [ + EditorView.decorations.of(view => view.state.field(f)), + EditorView.atomicRanges.of(view => view.state.field(f)), + ]; + }, +}); + +function build(state: EditorState): DecorationSet { + const allElements = state.facet(elementsFacet); + + if (!allElements) { + return Decoration.none; + } + + const tree = ensureSyntaxTree(state, state.doc.length) ?? syntaxTree(state); + const builder = new RangeSetBuilder(); + tree.iterate({ + enter(node) { + const data = extract(node.node, state); + if (data) { + const definition = allElements[data.tagName]; + if (definition) { + builder.add( + node.from, + node.to, + Decoration.replace({ + widget: new ElementWidget( + definition, + data.internalId, + data.props, + ), + }), + ); + } + } + + // if (node.matchContext(['Document'])) { + // return false; + // } + + return true; + }, + }); + + const decorations = builder.finish(); + + return decorations; +} + +function extract(node: SyntaxNode, state: EditorState) { + if (node.name === 'Element') { + const elementData = extractElementData(node.node, state.doc.toString()); + + if (elementData) { + const { tagName, attributes } = elementData; + const { [INTERNAL_ID]: internalId, ...props } = attributes ?? {}; + + return { + tagName, + internalId, + props, + }; + } + } +} + +const CUSTOM_CLIPBOARD_MIMETYPE = 'application/x-with-elements'; + +const copyPasteHandler = EditorView.domEventHandlers({ + paste(event, view) { + try { + let text = event.clipboardData?.getData(CUSTOM_CLIPBOARD_MIMETYPE); + + if (!text) { + return false; + } + + // normalize \r\n to \n for Windows (if user copies rich text) + text = text.replace(/\r\n/g, '\n'); + + const definitions = view.state.facet(elementsFacet); + + const nodes = schemaUtils.toJSON(text, { + validTagNames: definitions ? Object.keys(definitions) : undefined, + }); + const newText = schemaUtils.fromJSON( + nodes + .map(node => { + if (node.type === 'text') { + return node; + } + + if (node.type === 'element') { + return { + ...node, + attributes: { + ...(node.attributes ?? {}), + cmid: `e${Math.random()}`, + }, + } satisfies EditorElement; + } + }) + .filter(v => isEditorNode(v)), + ); + + view.dispatch({ + changes: { + from: view.state.selection.main.from, + to: view.state.selection.main.to, + insert: newText, + }, + selection: EditorSelection.cursor( + view.state.selection.main.from + newText.length, + ), + }); + return true; + } catch (e) { + return false; + } + }, + copy(event, view) { + const definitions = view.state.facet(elementsFacet); + + if (!definitions) { + return false; + } + + try { + const { from, to } = view.state.selection.main; + const slice = view.state.doc.sliceString(from, to); + const nodes = schemaUtils.toJSON(slice, { + validTagNames: definitions ? Object.keys(definitions) : undefined, + }); + + const plainText = nodes + .map(node => { + if (node.type === 'text') { + return node.value; + } + + if (node.type === 'element') { + const definition = definitions[node.tagName]; + const toString = definition?.toString; + + if (!definition) { + return node.raw ?? ''; + } + + if ( + Object.prototype.hasOwnProperty.call(definition, 'toString') && + typeof toString === 'function' + ) { + return toString(node); + } + + return `[${node.tagName}]`; + } + + return ''; + }) + .join(''); + + event.clipboardData?.setData('text/plain', plainText); + event.clipboardData?.setData(CUSTOM_CLIPBOARD_MIMETYPE, slice); + + return true; + } catch (e) { + return false; + } + }, +}); + +function isEditorNode(v: unknown): v is EditorNode { + return Boolean(v); +} + +function chatExtension() { + return [ + field, + copyPasteHandler, + // selectionEnlarger.of(state => { + // const decorations = state.field(field); + // const cursor = decorations.iter(); + // const array = []; + + // while (cursor.value) { + // const widget = cursor.value.spec?.widget; + // const { from, to } = cursor; + // if (isElementWidget(widget)) { + // array.push({ + // source: { from, to }, + // target: { from, to }, + // }); + // } + + // cursor.next(); + // } + + // return array; + // }), + ]; +} + +export { field, chatExtension, elementsFacet, CUSTOM_CLIPBOARD_MIMETYPE }; + +export type { ElementsDefinition, ElementDefinition }; diff --git a/packages/text-editor/preset-chat/src/hooks.tsx b/packages/text-editor/preset-chat/src/hooks.tsx new file mode 100644 index 00000000..65de5443 --- /dev/null +++ b/packages/text-editor/preset-chat/src/hooks.tsx @@ -0,0 +1,128 @@ +import { useLayoutEffect, useRef, useState } from 'react'; + +import { + type BuiltinEditorAPI, + useEditor, + useInjector, +} from '@coze-editor/react'; +import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view'; +import { EditorSelection, type EditorState } from '@codemirror/state'; + +import { field } from './extension'; +import { useElementId } from './context'; + +function useCurrentElement() { + const editor = useEditor(); + const elementId = useElementId(); + const injector = useInjector(); + const [isSelected, setIsSelected] = useState(false); + const elementIdRef = useRef(elementId); + + elementIdRef.current = elementId; + + function select() { + if (!editor) { + return; + } + + const view = editor.$view; + + const decorations = view.state.field(field); + + const cursor = decorations.iter(); + while (cursor.value) { + const { spec } = cursor.value; + if (spec?.widget?.id === elementId) { + view.dispatch({ + selection: EditorSelection.range(cursor.from, cursor.to), + }); + break; + } + cursor.next(); + } + } + + function remove() { + if (!editor) { + return; + } + + const view = editor.$view; + + if (view.state.readOnly) { + return; + } + + const range = findElement(view.state, elementId); + if (range) { + view.dispatch({ + changes: { + from: range.from, + to: range.to, + insert: '', + }, + selection: EditorSelection.cursor(range.from), + }); + } + } + + useLayoutEffect(() => { + function selectionContainsCurrentElement(state: EditorState) { + const range = findElement(state, elementId); + const { ranges } = state.selection; + if (!range) { + return false; + } + + for (const r of ranges) { + if (r.from <= range.from && r.to >= range.to) { + return true; + } + } + + return false; + } + + const plugin = ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + const contains = selectionContainsCurrentElement(view.state); + setIsSelected(contains); + } + + update(update: ViewUpdate) { + if (update.selectionSet) { + const contains = selectionContainsCurrentElement(update.state); + setIsSelected(contains); + } + } + }, + ); + + return injector.inject([plugin]); + }, [injector]); + + return { + isSelected, + select, + remove, + }; +} + +function findElement(state: EditorState, elementId: string) { + const decorations = state.field(field); + + const cursor = decorations.iter(); + while (cursor.value) { + const { spec } = cursor.value; + if (spec?.widget?.id === elementId) { + return { + from: cursor.from, + to: cursor.to, + }; + } + cursor.next(); + } +} + +export { useCurrentElement }; diff --git a/packages/text-editor/preset-chat/src/index.ts b/packages/text-editor/preset-chat/src/index.ts new file mode 100644 index 00000000..376c493b --- /dev/null +++ b/packages/text-editor/preset-chat/src/index.ts @@ -0,0 +1,44 @@ +import universalPreset from '@coze-editor/preset-universal'; +import { extension, type InferEditorAPIFromPlugins } from '@coze-editor/core'; +import { EditorView, keymap } from '@codemirror/view'; +import { defaultKeymap, historyKeymap, history } from '@codemirror/commands'; + +import { + type EditorNode, + type EditorElement, + type EditorText, + schemaUtils, +} from './schema'; +import plugins from './plugins'; +import { htmlLanguage } from './language'; +import { useCurrentElement } from './hooks'; +import { CUSTOM_CLIPBOARD_MIMETYPE, type ElementDefinition } from './extension'; + +const preset = [ + ...universalPreset, + extension([ + EditorView.theme({ + '&.cm-focused': { + outline: 'none', + }, + }), + htmlLanguage, + history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + ]), + ...plugins, +]; + +type EditorAPI = InferEditorAPIFromPlugins; + +export default preset; + +export { schemaUtils, useCurrentElement, CUSTOM_CLIPBOARD_MIMETYPE }; + +export type { + EditorAPI, + ElementDefinition, + EditorNode, + EditorElement, + EditorText, +}; diff --git a/packages/text-editor/preset-chat/src/language.ts b/packages/text-editor/preset-chat/src/language.ts new file mode 100644 index 00000000..33c6e76c --- /dev/null +++ b/packages/text-editor/preset-chat/src/language.ts @@ -0,0 +1,16 @@ +import { parser } from '@lezer/html'; +import { LRLanguage } from '@codemirror/language'; + +const htmlParser = parser.configure({ + dialect: 'noMatch', +}); + +function parse(text: string) { + return htmlParser.parse(text); +} + +const htmlLanguage = LRLanguage.define({ + parser: htmlParser, +}); + +export { parse, htmlLanguage }; diff --git a/packages/text-editor/preset-chat/src/plugins.ts b/packages/text-editor/preset-chat/src/plugins.ts new file mode 100644 index 00000000..c24d670d --- /dev/null +++ b/packages/text-editor/preset-chat/src/plugins.ts @@ -0,0 +1,68 @@ +import { api, extension, option } from '@coze-editor/core'; +import { EditorView } from '@codemirror/view'; +import { EditorSelection, StateEffect, StateField } from '@codemirror/state'; + +import { type EditorElement, toElementString } from './schema'; +import { + chatExtension, + type ElementsDefinition, + elementsFacet, +} from './extension'; + +const focusedEffect = StateEffect.define(); + +const focusedField = StateField.define({ + create() { + return false; + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(focusedEffect)) { + return true; + } + } + + return value; + }, +}); + +function insertElement({ view }: { view: EditorView }) { + return (element: Omit) => { + let selection = view.state.selection.main; + + const hasFocused = view.state.field(focusedField, false); + if (hasFocused === false) { + selection = EditorSelection.cursor(view.state.doc.length); + } + + const insert = toElementString(element); + view.dispatch({ + changes: { + from: selection.from, + to: selection.to, + insert, + }, + selection: EditorSelection.cursor(selection.from + insert.length), + }); + }; +} + +const plugins = [ + extension([ + focusedField, + EditorView.domEventObservers({ + click(e, view) { + view.dispatch({ + effects: focusedEffect.of(null), + }); + }, + }), + chatExtension(), + ]), + api('insertElement', insertElement), + option('elements', (elements: ElementsDefinition) => + elementsFacet.of(elements), + ), +]; + +export default plugins; diff --git a/packages/text-editor/preset-chat/src/schema.ts b/packages/text-editor/preset-chat/src/schema.ts new file mode 100644 index 00000000..56885b10 --- /dev/null +++ b/packages/text-editor/preset-chat/src/schema.ts @@ -0,0 +1,154 @@ +import { extractElementData } from './utils'; +import { parse } from './language'; + +interface EditorElement> { + type: 'element'; + tagName: string; + attributes: Attrs; + raw?: string; +} + +interface EditorText { + type: 'text'; + value: string; +} + +type EditorNode = EditorElement | EditorText; + +function isElementRegistered( + tagName: string, + validTagNames: string[] | undefined, +) { + if (!Array.isArray(validTagNames)) { + return false; + } + + if (validTagNames.includes(tagName)) { + return true; + } + + return false; +} + +const schemaUtils = { + toJSON(text: string, options?: { validTagNames?: string[] }): EditorNode[] { + if (text === '') { + return []; + } + + const tree = parse(text); + const tags: { + from: number; + to: number; + tagName: string; + attributes: Record; + raw: string; + }[] = []; + tree.iterate({ + enter(node) { + if (node.name === 'Element') { + const data = extractElementData(node.node, text); + if ( + data && + isElementRegistered(data.tagName, options?.validTagNames) + ) { + tags.push({ + from: node.from, + to: node.to, + raw: text.slice(node.from, node.to), + ...data, + }); + } + } + }, + }); + + let pos = 0; + const elements: EditorNode[] = []; + for (const tag of tags) { + const { from, to, tagName, attributes, raw } = tag; + const { cmid, ...restAttributes } = attributes ?? {}; + + // overlap, skip this element + if (from < pos) { + continue; + } + + if (from > pos) { + elements.push({ + type: 'text', + value: text.slice(pos, from), + }); + } + + elements.push({ + type: 'element', + tagName, + attributes: restAttributes, + raw, + }); + + pos = to; + } + + if (pos < text.length) { + elements.push({ + type: 'text', + value: text.slice(pos), + }); + } + + return elements; + }, + fromJSON(elements: EditorNode[]): string { + return elements + .map(el => { + if (el.type === 'element') { + return toElementString(el); + } else if (el.type === 'text') { + return el.value; + } + return ''; + }) + .join(''); + }, +}; + +function toElementString(element: Omit) { + const attrsString = attributesToString(element.attributes); + return `<${element.tagName}${attrsString ? ` ${attrsString}` : ''}>`; +} + +const INTERNAL_ID = 'cmid'; + +function uniqueId() { + return `e${Math.random()}`; +} + +function attributesToString(attributes: EditorElement['attributes']) { + const array: string[] = []; + let hasId = false; + + Object.keys(attributes).forEach(key => { + if (key === INTERNAL_ID) { + hasId = true; + } + const value = attributes[key]; + if (value === true) { + array.push(key); + } else if (typeof value !== 'undefined') { + array.push(`${key}="${encodeURIComponent(JSON.stringify(value))}"`); + } + }); + + if (!hasId) { + array.unshift( + `${INTERNAL_ID}="${encodeURIComponent(JSON.stringify(uniqueId()))}"`, + ); + } + + return array.join(' '); +} + +export { schemaUtils, toElementString, INTERNAL_ID }; +export type { EditorNode, EditorElement, EditorText }; diff --git a/packages/text-editor/preset-chat/src/utils.ts b/packages/text-editor/preset-chat/src/utils.ts new file mode 100644 index 00000000..b53c43bc --- /dev/null +++ b/packages/text-editor/preset-chat/src/utils.ts @@ -0,0 +1,66 @@ +/* eslint-disable complexity */ + +import { type SyntaxNode } from '@lezer/common'; + +function extractElementData(node: SyntaxNode, text: string) { + const openTag = node.firstChild; + + let tagName: string | null = null; + const attributes: Record = {}; + + if (openTag && openTag.name === 'OpenTag' && openTag.firstChild) { + let sibling = openTag.firstChild.nextSibling; + while (true) { + if (!sibling) { + break; + } + + if (sibling.name === 'TagName') { + const { from, to } = sibling; + tagName = text.slice(from, to); + } else if (sibling.name === 'Attribute') { + const nameNode = sibling.firstChild; + const isNode = nameNode?.nextSibling; + const valueNode = isNode?.nextSibling; + if ( + nameNode?.name === 'AttributeName' && + isNode?.name === 'Is' && + (valueNode?.name === 'AttributeValue' || + valueNode?.name === 'UnquotedAttributeValue') + ) { + const name = text.slice(nameNode.from, nameNode.to); + const fullValue = text.slice(valueNode.from, valueNode.to); + + let value = fullValue; + if (valueNode.name === 'AttributeValue') { + value = fullValue.slice(1, -1); + } + + try { + attributes[name] = JSON.parse(decodeURIComponent(value)); + } catch (e) { + /* empty */ + } + } else if ( + nameNode?.name === 'AttributeName' && + !isNode && + !valueNode + ) { + const name = text.slice(nameNode.from, nameNode.to); + attributes[name] = true; + } + } + + sibling = sibling.nextSibling; + } + } + + if (tagName) { + return { + tagName, + attributes, + }; + } +} + +export { extractElementData }; diff --git a/packages/text-editor/preset-chat/tsconfig.build.json b/packages/text-editor/preset-chat/tsconfig.build.json new file mode 100644 index 00000000..e21690bb --- /dev/null +++ b/packages/text-editor/preset-chat/tsconfig.build.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@coze-arch/ts-config/tsconfig.web.json", + "compilerOptions": { + "types": [], + "strictNullChecks": true, + "noImplicitAny": true, + "moduleResolution": "bundler", + "module": "ESNext", + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo" + }, + "include": ["src"], + "references": [ + { + "path": "../../../config/ts-config/tsconfig.build.json" + }, + { + "path": "../core-plugins/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + }, + { + "path": "../eslint-config/tsconfig.build.json" + }, + { + "path": "../extensions/tsconfig.build.json" + }, + { + "path": "../preset-universal/tsconfig.build.json" + }, + { + "path": "../react/tsconfig.build.json" + }, + { + "path": "../utils/tsconfig.build.json" + } + ] +} diff --git a/packages/text-editor/preset-chat/tsconfig.json b/packages/text-editor/preset-chat/tsconfig.json new file mode 100644 index 00000000..e2582454 --- /dev/null +++ b/packages/text-editor/preset-chat/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "exclude": ["**/*"], + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "./tsconfig.build.json" + } + ] +} diff --git a/packages/text-editor/preset-chat/tsup.config.ts b/packages/text-editor/preset-chat/tsup.config.ts new file mode 100644 index 00000000..e1c9cb0e --- /dev/null +++ b/packages/text-editor/preset-chat/tsup.config.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2025 coze-dev +// SPDX-License-Identifier: MIT + +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: [ + 'src/index.ts' + ], + format: ['cjs', 'esm'], + sourcemap: true, + legacyOutput: true, + clean: true, + dts: { + resolve: true, + compilerOptions: { + composite: false, + moduleResolution: 'bundler', + module: 'esnext', + jsx: 'react-jsx', + }, + }, +}); diff --git a/packages/text-editor/react/package.json b/packages/text-editor/react/package.json index 63a48b40..159817e1 100644 --- a/packages/text-editor/react/package.json +++ b/packages/text-editor/react/package.json @@ -23,7 +23,8 @@ "lint": "eslint && tsc --noEmit" }, "dependencies": { - "@coze-editor/core": "workspace:*" + "@coze-editor/core": "workspace:*", + "@coze-editor/utils": "workspace:*" }, "devDependencies": { "@codemirror/state": "^6.4.1", diff --git a/packages/text-editor/react/src/connector.tsx b/packages/text-editor/react/src/connector.tsx new file mode 100644 index 00000000..4c610084 --- /dev/null +++ b/packages/text-editor/react/src/connector.tsx @@ -0,0 +1,56 @@ +import React, { useState, useCallback, Fragment } from 'react'; +import type { ReactPortal } from 'react'; + +type PortalItem = { + id: string; + portal: ReactPortal; +}; + +function createPortalConnector() { + let add = (id: string, portal: ReactPortal) => {}; + let remove = (id: string) => {}; + + function connect(id: string, portal: ReactPortal) { + add(id, portal); + } + + function disconnect(id: string) { + remove(id); + } + + function Portal() { + const [items, setItems] = useState([]); + + const addItem = useCallback((id: string, portal: ReactPortal) => { + setItems(prevItems => { + const nextItems = [...prevItems]; + const index = nextItems.findIndex(item => item.id === id); + if (index > -1) { + nextItems[index] = { id, portal }; + } else { + nextItems.push({ id, portal }); + } + return nextItems; + }); + }, []); + const removeItem = useCallback((id: string) => { + setItems(items => items.filter(item => item.id !== id)); + }, []); + + add = addItem; + remove = removeItem; + + return {items.map(item => item.portal)}; + } + + return { + connect, + disconnect, + Portal, + }; +} + +type Connector = ReturnType; + +export { createPortalConnector }; +export type { Connector }; diff --git a/packages/text-editor/react/src/index.ts b/packages/text-editor/react/src/index.ts index 4f4a5fcb..d9e16b8c 100644 --- a/packages/text-editor/react/src/index.ts +++ b/packages/text-editor/react/src/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 coze-dev // SPDX-License-Identifier: MIT -export { Renderer, type InferRendererProps } from './renderer'; +export { Renderer, connector, type InferRendererProps } from './renderer'; export { createRenderer, type CreateRendererOptions } from './create-renderer'; diff --git a/packages/text-editor/react/src/renderer.tsx b/packages/text-editor/react/src/renderer.tsx index dd6e64e3..a93639ca 100644 --- a/packages/text-editor/react/src/renderer.tsx +++ b/packages/text-editor/react/src/renderer.tsx @@ -8,8 +8,10 @@ import React, { useRef, useState, type ReactNode, + useMemo, } from 'react'; +import { FacetCombineStrategy } from '@coze-editor/utils'; import { type EditorPluginSpec, type InferEditorAPIFromPlugins, @@ -17,9 +19,10 @@ import { type InferValues, create, } from '@coze-editor/core'; -import { type Extension } from '@codemirror/state'; +import { Facet, type Extension } from '@codemirror/state'; import { useInjector, useSetEditor } from './provider'; +import { type Connector, createPortalConnector } from './connector'; type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( k: infer I, @@ -50,6 +53,10 @@ type InferRendererProps[]> = { ? InferReactEvents> : unknown); +const connector = Facet.define({ + combine: FacetCombineStrategy.First, +}); + function Renderer[]>( props: { plugins: T } & InferRendererProps, ) { @@ -71,6 +78,11 @@ function Renderer[]>( const setEditor = useSetEditor(); const injector = useInjector(); + const pc = useMemo(() => createPortalConnector(), []); + const connectorRef = useRef(pc); + connectorRef.current = pc; + const { Portal } = pc; + propsRef.current = props; useEffect(() => { @@ -84,7 +96,7 @@ function Renderer[]>( root, defaultValue, options: options ?? {}, - extensions, + extensions: [connector.of(connectorRef.current), ...(extensions ?? [])], }); apiRef.current = exported; @@ -127,10 +139,11 @@ function Renderer[]>( <>
{children} + ); } -export { Renderer }; +export { Renderer, connector }; export type { InferRendererProps }; diff --git a/packages/text-editor/react/tsconfig.build.json b/packages/text-editor/react/tsconfig.build.json index 9c902fca..9b9de5f0 100644 --- a/packages/text-editor/react/tsconfig.build.json +++ b/packages/text-editor/react/tsconfig.build.json @@ -21,6 +21,9 @@ }, { "path": "../eslint-config/tsconfig.build.json" + }, + { + "path": "../utils/tsconfig.build.json" } ] } diff --git a/rush.json b/rush.json index ded18365..36f707df 100644 --- a/rush.json +++ b/rush.json @@ -641,6 +641,12 @@ "shouldPublish": true, "tags": ["level-3"] }, + { + "packageName": "@coze-editor/preset-chat", + "projectFolder": "packages/text-editor/preset-chat", + "shouldPublish": true, + "tags": ["level-3"] + }, { "packageName": "@coze-editor/core-plugins", "projectFolder": "packages/text-editor/core-plugins",