Преглед на файлове

Feat/jsonviwer customrender (#2676)

* refactor: optimize selection model logic for improved performance

* test: update jsonviewer e2e test cases

* feat: support custom render

* docs: update jsonviewer api docs

* chore: update jsonviewer storybook instance

* refactor: optimize custom rendering rules for better flexibility

* docs: update jsonviewer docs

---------

Co-authored-by: 田丰 <[email protected]>
田丰 преди 7 месеца
родител
ревизия
aecb1f4b45

+ 86 - 6
content/plus/jsonviewer/index-en-US.md

@@ -151,6 +151,78 @@ function FormatJsonComponent() {
 render(FormatJsonComponent);
 render(FormatJsonComponent);
 ```
 ```
 
 
+### Custom Render Rules
+
+By configuring the `options.customRenderRule` parameter, you can customize how JSON content is rendered (Note: only works in read-only mode).
+
+`customRenderRule` is an array of rules, where each rule contains two properties:
+- `match`: Matching condition, can be one of three types:
+  - String: Exact match
+  - Regular expression: Match by regex
+  - Function: Custom matching logic, with signature `(value: string, pathChain: string) => boolean`
+    - `value`: Value to match (key or value from JSON key-value pairs, as strings since internal processing only filters quotes)
+    - `path`: Current matching path, format is `root.key1.key2.key3[0].key4`
+- `render`: Custom render function, with signature `(content: string) => React.ReactNode`
+  - `content`: Matched content. For string values, includes double quotes (e.g., `"name"`, `"Semi"`)
+
+```jsx live=true dir="column" noInline=true
+import React, { useRef } from 'react';
+import { JsonViewer, Button, Rating, Popover, Tag, Image } from '@douyinfe/semi-ui';
+const data = `{
+  "name": "Semi",
+  "version": "2.7.4",
+  "rating": 5,
+  "tags": ["design", "react", "ui"],
+  "image": "https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg"
+}`;
+function CustomRenderJsonComponent() {
+    const jsonviewerRef = useRef();
+    const customRenderRule = [
+        {
+            match: 'Semi',
+            render: (content) => {
+                return <Popover showArrow content={'I am a custom render'} trigger='hover'><span>{content}</span></Popover>;
+            }
+        },
+        {
+            match: (value)=> value == 5,
+            render: (content) => {
+                return <Rating defaultValue={content} size={10} disabled/>;
+            }
+        },
+        {
+            match: (value, path)=> path === 'root.tags[0]' || path === 'root.tags[1]' || path === 'root.tags[2]',
+            render: (content) => {
+                return <Tag size='small' shape='circle'>{content}</Tag>;
+            }
+        },
+        {
+            match: new RegExp('^http'),
+            render: (content) => {
+                // content is original string with quotes, need to remove quotes for valid URL
+                return <Popover showArrow content={<Image width={100} height={100} src={content.replace(/^"|"$/g, '')} />} trigger='hover'><span>{content}</span></Popover>;
+            }
+        }
+    ];
+    return (
+        <div>
+            <div style={{ marginBottom: 16, marginTop: 16 }}>
+                <JsonViewer
+                    ref={jsonviewerRef}
+                    height={200}
+                    width={600}
+                    value={data}
+                    showSearch={false}
+                    options={{ formatOptions: { tabSize: 4, insertSpaces: true, eol: '\n' }, customRenderRule, readOnly: true, autoWrap: true }}
+                />
+            </div>
+        </div>
+    );
+}
+
+render(CustomRenderJsonComponent);
+```
+
 ## API Reference
 ## API Reference
 
 
 ### JsonViewer
 ### JsonViewer
@@ -168,12 +240,13 @@ render(FormatJsonComponent);
 
 
 ### JsonViewerOptions
 ### JsonViewerOptions
 
 
-| Attribute     | Description                             | Type              | Default |
-| ------------- | --------------------------------------- | ----------------- | ------- |
-| lineHeight    | Height of each line of content, unit:px | number            | 20      |
-| autoWrap      | Whether to wrap lines automatically.    | boolean           | true    |
-| readOnly      | Whether to be read-only.    | boolean           | false    |
-| formatOptions | Content format setting                  | FormattingOptions | -       |
+| Attribute     | Description                             | Type              | Default | Version |
+| ------------- | --------------------------------------- | ----------------- | ------- | ------- |
+| lineHeight    | Height of each line of content, unit:px | number            | 20      | -       |
+| autoWrap      | Whether to wrap lines automatically.    | boolean           | true    | -       |
+| readOnly      | Whether to be read-only.    | boolean           | false    | -       |
+| customRenderRule | Custom render rules | CustomRenderRule[] | -       | 2.74.0  |
+| formatOptions | Content format setting                  | FormattingOptions | -       | -       |
 
 
 ### FormattingOptions
 ### FormattingOptions
 
 
@@ -183,6 +256,13 @@ render(FormatJsonComponent);
 | insertSpaces | Whether to use spaces for indentation | boolean | true    |
 | insertSpaces | Whether to use spaces for indentation | boolean | true    |
 | eol          | Line break character                  | string  | '\n'    |
 | eol          | Line break character                  | string  | '\n'    |
 
 
+### CustomRenderRule
+
+| Attribute | Description | Type | Default |
+| --- | --- | --- | --- |
+| match | Matching rule | string \| RegExp \| (value: string, path: string) => boolean | - |
+| render | Render function | (content: string) => React.ReactNode | - |
+
 ## Methods
 ## Methods
 
 
 Methods bound to the component instance can be called via `ref` to achieve certain special interactions.
 Methods bound to the component instance can be called via `ref` to achieve certain special interactions.

+ 84 - 5
content/plus/jsonviewer/index.md

@@ -147,6 +147,78 @@ function FormatJsonComponent() {
 render(FormatJsonComponent);
 render(FormatJsonComponent);
 ```
 ```
 
 
+### 自定义渲染规则
+
+通过配置 `options.customRenderRule` 参数,你可以自定义 JSON 内容的渲染方式(注意:仅在只读模式下生效)。
+
+`customRenderRule` 是一个规则数组,每条规则包含两个属性:
+- `match`: 匹配条件,可以是以下三种类型之一:
+  - 字符串:精确匹配
+  - 正则表达式:按正则匹配
+  - 函数:自定义匹配逻辑,函数签名为 `(value: string, path: string) => boolean`
+    - `value`: 待匹配的值(为Json字符串的键值对的键或者值,由于内部处理注入时仅过滤引号,因此类型全部为string)
+    - `path`: 当前匹配到的路径,格式为 `root.key1.key2.key3[0].key4`
+- `render`: 自定义渲染函数,函数签名为 `(content: string) => React.ReactNode`
+  - `content`: 匹配到的内容。如果是字符串类型的值,将包含双引号(如 `"name"`,`"Semi"`)
+
+```jsx live=true dir="column" noInline=true
+import React, { useRef } from 'react';
+import { JsonViewer, Button, Rating, Popover, Tag, Image } from '@douyinfe/semi-ui';
+const data = `{
+  "name": "Semi",
+  "version": "2.7.4",
+  "rating": 5,
+  "tags": ["design", "react", "ui"],
+  "image": "https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg"
+}`;
+function CustomRenderJsonComponent() {
+    const jsonviewerRef = useRef();
+    const customRenderRule = [
+        {
+            match: 'Semi',
+            render: (content) => {
+                return <Popover showArrow content={'我是用户自定义的渲染'} trigger='hover'><span>{content}</span></Popover>;
+            }
+        },
+        {
+            match: (value)=> value == 5,
+            render: (content) => {
+                return <Rating defaultValue={content} size={10} disabled/>;
+            }
+        },
+        {
+            match: (value, path)=> path === 'root.tags[0]' || path === 'root.tags[1]' || path === 'root.tags[2]',
+            render: (content) => {
+                return <Tag size='small' shape='circle'>{content}</Tag>;
+            }
+        },
+        {
+            match: new RegExp('^http'),
+            render: (content) => {
+                // content 为原始字符串,包含引号,因此需要去除引号才可以作为合法的url
+                return <Popover showArrow content={<Image width={100} height={100} src={content.replace(/^"|"$/g, '')} />} trigger='hover'><span>{content}</span></Popover>;
+            }
+        }
+    ];
+    return (
+        <div>
+            <div style={{ marginBottom: 16, marginTop: 16 }}>
+                <JsonViewer
+                    ref={jsonviewerRef}
+                    height={200}
+                    width={600}
+                    value={data}
+                    showSearch={false}
+                    options={{ formatOptions: { tabSize: 4, insertSpaces: true, eol: '\n' }, customRenderRule, readOnly: true, autoWrap: true }}
+                />
+            </div>
+        </div>
+    );
+}
+
+render(CustomRenderJsonComponent);
+```
+
 
 
 ## API 参考
 ## API 参考
 
 
@@ -160,17 +232,24 @@ render(FormatJsonComponent);
 | className         | 类名                           | string                                  | -   |
 | className         | 类名                           | string                                  | -   |
 | style             | 内联样式                           | object                                  | -   |
 | style             | 内联样式                           | object                                  | -   |
 | showSearch        | 是否显示搜索Icon                           | boolean                                  | true   |
 | showSearch        | 是否显示搜索Icon                           | boolean                                  | true   |
-| options           | 格式化配置                                | JsonViewerOptions                       | -   |
+| options           | 编辑器配置                                | JsonViewerOptions                       | -   |
 | onChange          | 内容变化回调                           | (value: string) => void                  | -   |
 | onChange          | 内容变化回调                           | (value: string) => void                  | -   |
 
 
 ### JsonViewerOptions
 ### JsonViewerOptions
 
 
+| 属性                | 说明                                          | 类型                              | 默认值    | 版本
+|-------------------|------------------------------------------------|---------------------------------|-----------|---------|
+| lineHeight        | 行高                                    | number                          | 20  | - |
+| autoWrap        | 是否自动换行                             | boolean                            | true  | - |
+| readOnly        | 是否只读                             | boolean                            | false  | - |
+| customRenderRule | 自定义渲染规则                             | CustomRenderRule[]               |  -  | 2.74.0 |
+| formatOptions     | 格式化配置                               | FormattingOptions                |  -  | - |
+
+### CustomRenderRule
 | 属性                | 说明                                          | 类型                              | 默认值    |
 | 属性                | 说明                                          | 类型                              | 默认值    |
 |-------------------|------------------------------------------------|---------------------------------|-----------|
 |-------------------|------------------------------------------------|---------------------------------|-----------|
-| lineHeight        | 行高                                    | number                          | 20  |
-| autoWrap        | 是否自动换行                             | boolean                            | true  |
-| readOnly        | 是否只读                             | boolean                            | false  |
-| formatOptions     | 格式化配置                               | FormattingOptions                |  -  |
+| match             | 匹配规则                                   | string \| RegExp \| (value: string, path: string) => boolean | -  |
+| render            | 渲染函数                                   | (content: string) => React.ReactNode | -  |
 
 
 ### FormattingOptions
 ### FormattingOptions
 
 

+ 11 - 11
cypress/e2e/jsonViewer.spec.js

@@ -77,23 +77,23 @@ describe('jsonViewer', () => {
         typeTextAtPosition(2, 7, `:`);
         typeTextAtPosition(2, 7, `:`);
         typeTextAtPosition(2, 8, `1`);
         typeTextAtPosition(2, 8, `1`);
         typeTextAtPosition(2, 9, `,`);
         typeTextAtPosition(2, 9, `,`);
-        cy.get('.lines-content').children().eq(1).children().should('have.length', 5);
+        cy.get('.lines-content').children().eq(1).children().children().should('have.length', 5);
 
 
 
 
         // undo redo
         // undo redo
         undo(1);
         undo(1);
-        cy.get('.lines-content').children().eq(1).children().should('have.length', 4);
+        cy.get('.lines-content').children().eq(1).children().children().should('have.length', 4);
         redo(1);
         redo(1);
-        cy.get('.lines-content').children().eq(1).children().should('have.length', 5);
+        cy.get('.lines-content').children().eq(1).children().children().should('have.length', 5);
         undo(8);
         undo(8);
-        cy.get('.lines-content').children().eq(1).children().should('have.length', 6);
+        cy.get('.lines-content').children().eq(1).children().children().should('have.length', 6);
 
 
         //del
         //del
         typeTextAtPosition(2, 1, `{backspace}`);
         typeTextAtPosition(2, 1, `{backspace}`);
-        cy.get('.lines-content').children().eq(0).children().should('have.length', 7);
+        cy.get('.lines-content').children().eq(0).children().children().should('have.length', 7);
         undo(1);
         undo(1);
-        cy.get('.lines-content').children().eq(0).children().should('have.length', 1);
-        cy.get('.lines-content').children().eq(1).children().should('have.length', 6);
+        cy.get('.lines-content').children().eq(0).children().children().should('have.length', 1);
+        cy.get('.lines-content').children().eq(1).children().children().should('have.length', 6);
 
 
         // cut
         // cut
         // typeTextAtPosition(2, 1, `{meta+x}`);
         // typeTextAtPosition(2, 1, `{meta+x}`);
@@ -103,19 +103,19 @@ describe('jsonViewer', () => {
 
 
         //complete
         //complete
         typeTextAtPosition(14, 4, '{enter}');
         typeTextAtPosition(14, 4, '{enter}');
-        cy.get('.lines-content').children().eq(14).children().should('have.length', 1);
+        cy.get('.lines-content').children().eq(14).children().children().should('have.length', 1);
         typeTextAtPosition(15, 4, `c`);
         typeTextAtPosition(15, 4, `c`);
         cy.get('.semi-json-viewer-complete-suggestions-container').children().should('have.length', 2);
         cy.get('.semi-json-viewer-complete-suggestions-container').children().should('have.length', 2);
         cy.get('.lines-content').type('{enter}');
         cy.get('.lines-content').type('{enter}');
         cy.get('.semi-json-viewer-complete-container').should('have.css', 'display', 'none');
         cy.get('.semi-json-viewer-complete-container').should('have.css', 'display', 'none');
-        cy.get('.lines-content').children().eq(14).children().should('have.length', 2);
+        cy.get('.lines-content').children().eq(14).children().children().should('have.length', 2);
         typeTextAtPosition(15, 11, `:`);
         typeTextAtPosition(15, 11, `:`);
         cy.get('.semi-json-viewer-complete-container').should('have.css', 'display', 'block');
         cy.get('.semi-json-viewer-complete-container').should('have.css', 'display', 'block');
         cy.get('.semi-json-viewer-complete-suggestions-container').children().should('have.length', 2);
         cy.get('.semi-json-viewer-complete-suggestions-container').children().should('have.length', 2);
         cy.get('.lines-content').type('{enter}');
         cy.get('.lines-content').type('{enter}');
         cy.get('.semi-json-viewer-complete-container').should('have.css', 'display', 'none');
         cy.get('.semi-json-viewer-complete-container').should('have.css', 'display', 'none');
         typeTextAtPosition(15, 19, `,{enter}`);
         typeTextAtPosition(15, 19, `,{enter}`);
-        cy.get('.lines-content').children().eq(14).children().should('have.length', 5);
+        cy.get('.lines-content').children().eq(14).children().children().should('have.length', 5);
         typeTextAtPosition(16, 4, `a`);
         typeTextAtPosition(16, 4, `a`);
         cy.get('.semi-json-viewer-complete-suggestions-container').children().should('have.length', 2);
         cy.get('.semi-json-viewer-complete-suggestions-container').children().should('have.length', 2);
         typeTextAtPosition(16, 5, `{rightArrow}`);
         typeTextAtPosition(16, 5, `{rightArrow}`);
@@ -127,7 +127,7 @@ describe('jsonViewer', () => {
         typeTextAtPosition(16, 9, `:`);
         typeTextAtPosition(16, 9, `:`);
         cy.get('.lines-content').type('{enter}');
         cy.get('.lines-content').type('{enter}');
         cy.get('.semi-json-viewer-complete-container').should('have.css', 'display', 'none');
         cy.get('.semi-json-viewer-complete-container').should('have.css', 'display', 'none');
-        cy.get('.lines-content').children().eq(15).children().should('have.length', 4);
+        cy.get('.lines-content').children().eq(15).children().children().should('have.length', 4);
 
 
         //search
         //search
         cy.get('.semi-json-viewer-search-bar-trigger').click();
         cy.get('.semi-json-viewer-search-bar-trigger').click();

+ 9 - 10
packages/semi-foundation/jsonViewer/foundation.ts

@@ -1,15 +1,16 @@
 
 
-import { JsonViewer, JsonViewerOptions } from '@douyinfe/semi-json-viewer-core';
-import BaseFoundation, { DefaultAdapter, noopFunction } from '../base/foundation';
+import { JsonViewer, JsonViewerOptions, CustomRenderRule } from '@douyinfe/semi-json-viewer-core';
+import BaseFoundation, { DefaultAdapter } from '../base/foundation';
 
 
-export type { JsonViewerOptions };
+export type { JsonViewerOptions, CustomRenderRule };
 export interface JsonViewerAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
 export interface JsonViewerAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
     getEditorRef: () => HTMLElement;
     getEditorRef: () => HTMLElement;
     getSearchRef: () => HTMLInputElement;
     getSearchRef: () => HTMLInputElement;
     notifyChange: (value: string) => void;
     notifyChange: (value: string) => void;
     notifyHover: (value: string, el: HTMLElement) => HTMLElement | undefined;
     notifyHover: (value: string, el: HTMLElement) => HTMLElement | undefined;
     setSearchOptions: (key: string) => void;
     setSearchOptions: (key: string) => void;
-    showSearchBar: () => void
+    showSearchBar: () => void;
+    notifyCustomRender: (customRenderMap: Map<HTMLElement, any>) => void
 }
 }
 
 
 class JsonViewerFoundation extends BaseFoundation<JsonViewerAdapter> {
 class JsonViewerFoundation extends BaseFoundation<JsonViewerAdapter> {
@@ -23,6 +24,9 @@ class JsonViewerFoundation extends BaseFoundation<JsonViewerAdapter> {
         const props = this.getProps();
         const props = this.getProps();
         const editorRef = this._adapter.getEditorRef();
         const editorRef = this._adapter.getEditorRef();
         this.jsonViewer = new JsonViewer(editorRef, props.value, props.options);
         this.jsonViewer = new JsonViewer(editorRef, props.value, props.options);
+        this.jsonViewer.emitter.on('customRender', (e) => {
+            this._adapter.notifyCustomRender(e.customRenderMap);
+        });
         this.jsonViewer.layout();
         this.jsonViewer.layout();
         this.jsonViewer.emitter.on('contentChanged', (e) => {
         this.jsonViewer.emitter.on('contentChanged', (e) => {
             this._adapter.notifyChange(this.jsonViewer?.getModel().getValue());
             this._adapter.notifyChange(this.jsonViewer?.getModel().getValue());
@@ -30,12 +34,7 @@ class JsonViewerFoundation extends BaseFoundation<JsonViewerAdapter> {
                 this.search(this._adapter.getSearchRef().value);
                 this.search(this._adapter.getSearchRef().value);
             }
             }
         });
         });
-        this.jsonViewer.emitter.on('hoverNode', (e) => {
-            const el = this._adapter.notifyHover(e.value, e.target);
-            if (el) {
-                this.jsonViewer.emitter.emit('renderHoverNode', { el });
-            }
-        });
+
     }
     }
 
 
     search(searchText: string) {
     search(searchText: string) {

+ 8 - 1
packages/semi-json-viewer-core/src/common/emitterEvents.ts

@@ -7,7 +7,8 @@ export interface GlobalEvents {
     problemsChanged: IProblemsChangedEvent;
     problemsChanged: IProblemsChangedEvent;
     hoverNode: IHoverNodeEvent;
     hoverNode: IHoverNodeEvent;
     renderHoverNode: IRenderHoverNodeEvent;
     renderHoverNode: IRenderHoverNodeEvent;
-    forceRender: undefined
+    forceRender: undefined;
+    customRender: ICustomRenderEvent
 }
 }
 
 
 interface IRange {
 interface IRange {
@@ -50,3 +51,9 @@ export interface IHoverNodeEvent {
     value: string;
     value: string;
     target: HTMLElement
     target: HTMLElement
 }
 }
+
+export interface ICustomRenderEvent {
+    customRenderMap: ICustomRenderMap
+}
+
+export type ICustomRenderMap = Map<HTMLElement, any>;

+ 7 - 1
packages/semi-json-viewer-core/src/json-viewer/jsonViewer.ts

@@ -14,7 +14,8 @@ export interface JsonViewerOptions {
     autoWrap?: boolean;
     autoWrap?: boolean;
     readOnly?: boolean;
     readOnly?: boolean;
     formatOptions?: FormattingOptions;
     formatOptions?: FormattingOptions;
-    completionOptions?: CompletionOptions
+    completionOptions?: CompletionOptions;
+    customRenderRule?: CustomRenderRule[]
 }
 }
 
 
 export interface CompletionOptions {
 export interface CompletionOptions {
@@ -27,6 +28,11 @@ export interface FormattingOptions {
     eol?: string
     eol?: string
 }
 }
 
 
+export interface CustomRenderRule {
+    match: string | RegExp | ((value: string, pathChain: string) => boolean);
+    render: (value: string) => HTMLElement
+}
+
 export class JsonViewer {
 export class JsonViewer {
     private _container: HTMLElement;
     private _container: HTMLElement;
     private _jsonModel: JSONModel;
     private _jsonModel: JSONModel;

+ 43 - 16
packages/semi-json-viewer-core/src/model/selectionModel.ts

@@ -85,7 +85,6 @@ export class SelectionModel {
 
 
     public toViewPosition() {
     public toViewPosition() {
         const selection = window.getSelection();
         const selection = window.getSelection();
-
         if (!selection) return;
         if (!selection) return;
         const range = new Range();
         const range = new Range();
 
 
@@ -96,28 +95,38 @@ export class SelectionModel {
             selection.addRange(range);
             selection.addRange(range);
             return;
             return;
         }
         }
+
         const row = this._jsonModel.lastChangeBufferPos.lineNumber;
         const row = this._jsonModel.lastChangeBufferPos.lineNumber;
         const col = this._jsonModel.lastChangeBufferPos.column - 1;
         const col = this._jsonModel.lastChangeBufferPos.column - 1;
-
         const lineElement = this._view.getLineElement(row);
         const lineElement = this._view.getLineElement(row);
+        
         if (!lineElement) return;
         if (!lineElement) return;
+        
         if (col === 0) {
         if (col === 0) {
             range.setStart(lineElement, 0);
             range.setStart(lineElement, 0);
             range.setEnd(lineElement, 0);
             range.setEnd(lineElement, 0);
         } else {
         } else {
-            let offset = col;
-            for (let i = 0; i < lineElement.childNodes.length; i++) {
-                const childNode = lineElement.childNodes[i];
-                if (childNode.textContent && offset <= childNode.textContent.length) {
-                    range.setStart(childNode.childNodes[0], offset);
-                    range.setEnd(childNode.childNodes[0], offset);
+            const walker = document.createTreeWalker(
+                lineElement,
+                NodeFilter.SHOW_TEXT,
+                null
+            );
+
+            let node: Text | null = walker.nextNode() as Text;
+            let currentOffset = 0;
+            
+            while (node) {
+                const nodeLength = node.length;
+                if (currentOffset + nodeLength >= col) {
+                    range.setStart(node, col - currentOffset);
+                    range.setEnd(node, col - currentOffset);
                     break;
                     break;
                 }
                 }
-                offset -= (childNode as Text).textContent?.length || 0;
+                currentOffset += nodeLength;
+                node = walker.nextNode() as Text;
             }
             }
         }
         }
 
 
-        if (!selection) return;
         selection.removeAllRanges();
         selection.removeAllRanges();
         selection.addRange(range);
         selection.addRange(range);
     }
     }
@@ -144,25 +153,42 @@ export class SelectionModel {
         let row = 1;
         let row = 1;
         let col = 0;
         let col = 0;
         if (!node) return { row, col };
         if (!node) return { row, col };
+        
         let lineElement: HTMLElement | null;
         let lineElement: HTMLElement | null;
         if (node instanceof HTMLElement) {
         if (node instanceof HTMLElement) {
             lineElement = node.closest('.semi-json-viewer-view-line');
             lineElement = node.closest('.semi-json-viewer-view-line');
         } else {
         } else {
             lineElement = getLineElement(node);
             lineElement = getLineElement(node);
             if (!lineElement) return { row, col };
             if (!lineElement) return { row, col };
-            let totalOffset = 0;
-            for (let i = 0; i < lineElement.childNodes.length; i++) {
-                const childNode = lineElement.childNodes[i];
 
 
-                if (childNode === node.parentElement) {
+            const walker = document.createTreeWalker(
+                lineElement,
+                NodeFilter.SHOW_TEXT,
+                null
+            );
+
+            let currentNode: Text | null = walker.nextNode() as Text;
+            let totalOffset = 0;
+            
+            while (currentNode) {
+                if (currentNode === node) {
                     totalOffset += isStart ? selection.anchorOffset : selection.focusOffset;
                     totalOffset += isStart ? selection.anchorOffset : selection.focusOffset;
                     break;
                     break;
                 }
                 }
-                totalOffset += childNode.textContent?.length || 0;
+                if (currentNode.parentNode === node.parentNode) {
+                    if (currentNode === node) {
+                        totalOffset += isStart ? selection.anchorOffset : selection.focusOffset;
+                        break;
+                    }
+                }
+                
+                totalOffset += currentNode.length;
+                currentNode = walker.nextNode() as Text;
             }
             }
-
+            
             col = totalOffset;
             col = totalOffset;
         }
         }
+
         row = (lineElement as any).lineNumber || 1;
         row = (lineElement as any).lineNumber || 1;
         return { row, col: col + 1 };
         return { row, col: col + 1 };
     }
     }
@@ -185,3 +211,4 @@ export class SelectionModel {
         };
         };
     }
     }
 }
 }
+

+ 12 - 0
packages/semi-json-viewer-core/src/service/parse.ts

@@ -27,6 +27,18 @@ export function getNodePath(node: ASTNode): Json.JSONPath {
     return Json.getNodePath(node);
     return Json.getNodePath(node);
 }
 }
 
 
+export function getPathChain(path: Json.JSONPath): string {
+    let result = 'root';
+    for (let i = 0; i < path.length; i++) {
+        if (typeof path[i] === 'number') {
+            result += '[' + path[i] + ']';
+        } else {
+            result += '.' + path[i];
+        }
+    }
+    return result;
+}
+
 export function contains(node: ASTNode, offset: number, includeRightBound = false): boolean {
 export function contains(node: ASTNode, offset: number, includeRightBound = false): boolean {
     return (
     return (
         (offset >= node.offset && offset < node.offset + node.length) ||
         (offset >= node.offset && offset < node.offset + node.length) ||

+ 148 - 133
packages/semi-json-viewer-core/src/view/view.ts

@@ -1,9 +1,17 @@
 import { JSONModel } from '../model/jsonModel';
 import { JSONModel } from '../model/jsonModel';
 import { elt, setStyles } from '../common/dom';
 import { elt, setStyles } from '../common/dom';
-import { Token } from '../tokens/tokenize';
+import { createPortal } from 'react-dom';
+import {
+    Token,
+    TOKEN_PROPERTY_NAME,
+    TOKEN_VALUE_BOOLEAN,
+    TOKEN_VALUE_NULL,
+    TOKEN_VALUE_NUMBER,
+    TOKEN_VALUE_STRING,
+} from '../tokens/tokenize';
 import { Emitter, getEmitter } from '../common/emitter';
 import { Emitter, getEmitter } from '../common/emitter';
 import { SelectionModel } from '../model/selectionModel';
 import { SelectionModel } from '../model/selectionModel';
-import { JsonViewerOptions } from '../json-viewer/jsonViewer';
+import { CustomRenderRule, JsonViewerOptions } from '../json-viewer/jsonViewer';
 import { getJsonWorkerManager, JsonWorkerManager } from '../worker/jsonWorkerManager';
 import { getJsonWorkerManager, JsonWorkerManager } from '../worker/jsonWorkerManager';
 import { FoldingModel } from '../model/foldingModel';
 import { FoldingModel } from '../model/foldingModel';
 import { SearchWidget } from './search/searchWidget';
 import { SearchWidget } from './search/searchWidget';
@@ -16,6 +24,8 @@ import { CompleteWidget } from './complete/completeWidget';
 import { HoverWidget } from './hover/hoverWidget';
 import { HoverWidget } from './hover/hoverWidget';
 import { GlobalEvents } from '../common/emitterEvents';
 import { GlobalEvents } from '../common/emitterEvents';
 import { ErrorWidget } from './error/errorWidget';
 import { ErrorWidget } from './error/errorWidget';
+import { ViewDOMBuilder } from './viewDOMBuilder';
+import { getNodePath, getPathChain, JsonDocument, parseJson } from '../service/parse';
 //TODO 实现ViewModel抽离代码
 //TODO 实现ViewModel抽离代码
 
 
 /**
 /**
@@ -29,6 +39,9 @@ export class View {
     private _options: JsonViewerOptions | undefined;
     private _options: JsonViewerOptions | undefined;
     public _lineHeight: number;
     public _lineHeight: number;
 
 
+    private _root: JsonDocument | null = null;
+    private _customRenderMap: Map<HTMLElement, any> = new Map();
+
     private _container: HTMLElement;
     private _container: HTMLElement;
     private _jsonViewerDom: HTMLElement;
     private _jsonViewerDom: HTMLElement;
     private _lineNumberDom: HTMLElement;
     private _lineNumberDom: HTMLElement;
@@ -38,6 +51,7 @@ export class View {
 
 
     public startLineNumber: number = 1;
     public startLineNumber: number = 1;
     public visibleLineCount: number = 0;
     public visibleLineCount: number = 0;
+    private _domBuilder: ViewDOMBuilder;
 
 
     private _verticalOffsetAdjustment: number = 0;
     private _verticalOffsetAdjustment: number = 0;
 
 
@@ -50,7 +64,7 @@ export class View {
     private _jsonWorkerManager: JsonWorkerManager = getJsonWorkerManager();
     private _jsonWorkerManager: JsonWorkerManager = getJsonWorkerManager();
     private _tokenizationJsonModelPart: TokenizationJsonModelPart;
     private _tokenizationJsonModelPart: TokenizationJsonModelPart;
     private _scalingCellSizeAndPositionManager: ScalingCellSizeAndPositionManager;
     private _scalingCellSizeAndPositionManager: ScalingCellSizeAndPositionManager;
-
+    private _customRenderRule: CustomRenderRule[];
     private _measuredHeights: { [index: number]: number } = {};
     private _measuredHeights: { [index: number]: number } = {};
 
 
     private emitter: Emitter<GlobalEvents> = getEmitter();
     private emitter: Emitter<GlobalEvents> = getEmitter();
@@ -63,12 +77,15 @@ export class View {
 
 
         this._lineHeight = options?.lineHeight || 20;
         this._lineHeight = options?.lineHeight || 20;
         this._options = options;
         this._options = options;
+        this._customRenderRule = options?.customRenderRule || null;
 
 
-        this._jsonViewerDom = this.createRenderContainer();
-        this._lineNumberDom = this.createLineNumberContainer();
-        this._contentDom = this.createContentContainer();
-        this._scrollDom = this.createScrollElement();
-        this._lineScrollDom = this.createLineScrollContainerElement();
+        this._domBuilder = new ViewDOMBuilder(this._lineHeight, model.getLineCount(), options);
+
+        this._jsonViewerDom = this._domBuilder.createRenderContainer();
+        this._lineNumberDom = this._domBuilder.createLineNumberContainer();
+        this._contentDom = this._domBuilder.createContentContainer();
+        this._scrollDom = this._domBuilder.createScrollElement();
+        this._lineScrollDom = this._domBuilder.createLineScrollContainer();
 
 
         this._contentDom.appendChild(this._scrollDom);
         this._contentDom.appendChild(this._scrollDom);
         this._lineNumberDom.appendChild(this._lineScrollDom);
         this._lineNumberDom.appendChild(this._lineScrollDom);
@@ -139,10 +156,15 @@ export class View {
     }
     }
 
 
     private _attachEventListeners() {
     private _attachEventListeners() {
+        if (this._options?.readOnly && this._options.customRenderRule) {
+            const { root } = parseJson(this._jsonModel);
+            this._root = root;
+        }
+
         this._jsonViewerDom.addEventListener('scroll', e => {
         this._jsonViewerDom.addEventListener('scroll', e => {
             this.onScroll(this._jsonViewerDom.scrollTop);
             this.onScroll(this._jsonViewerDom.scrollTop);
         });
         });
-
+        if (this._options?.readOnly) return;
         this._jsonViewerDom.addEventListener('click', e => {
         this._jsonViewerDom.addEventListener('click', e => {
             e.preventDefault();
             e.preventDefault();
             this._selectionModel.toLastPosition();
             this._selectionModel.toLastPosition();
@@ -192,62 +214,6 @@ export class View {
         this.onScroll(scrollTop);
         this.onScroll(scrollTop);
     }
     }
 
 
-    private createRenderContainer(): HTMLElement {
-        const renderContainer = elt('div', 'json-viewer-container');
-        setStyles(renderContainer, {
-            position: 'relative',
-            height: '100%',
-            width: '100%',
-            overflow: 'auto',
-        });
-        return renderContainer;
-    }
-
-    private createLineNumberContainer(): HTMLElement {
-        const lineNumberClass = 'semi-json-viewer-line-number-container';
-        const lineNumberContainer = elt('div', lineNumberClass);
-        setStyles(lineNumberContainer, {
-            position: 'absolute',
-            left: '0',
-            top: '0',
-            width: '50px',
-        });
-        return lineNumberContainer;
-    }
-
-    private createLineScrollContainerElement(): HTMLElement {
-        const lineScrollContainer = elt('div', 'line-scroll-container');
-        setStyles(lineScrollContainer, {
-            position: 'absolute',
-            top: '0',
-            left: '0',
-            height: `${this._lineHeight * this._jsonModel.getLineCount()}px`,
-            width: '100%',
-            overflow: 'hidden',
-        });
-        return lineScrollContainer;
-    }
-
-    private createContentContainer(): HTMLElement {
-        const contentClass = 'semi-json-viewer-content-container';
-        const contentContainer = elt('div', contentClass);
-        setStyles(contentContainer, {
-            position: 'absolute',
-            left: '50px',
-            top: '0',
-            right: '0',
-            overflowX: 'auto',
-            overflowY: 'scroll',
-            outline: 'none',
-        });
-        if (!this._options?.readOnly) {
-            contentContainer.contentEditable = 'true';
-            contentContainer.style.caretColor = 'black';
-            contentContainer.spellcheck = false;
-        }
-        return contentContainer;
-    }
-
     private createLineNumberElement(actualLineNumber: number, visibleLineNumber: number): HTMLElement {
     private createLineNumberElement(actualLineNumber: number, visibleLineNumber: number): HTMLElement {
         const lineNumberClass = 'semi-json-viewer-line-number';
         const lineNumberClass = 'semi-json-viewer-line-number';
         const lineNumberElement = elt('div', lineNumberClass);
         const lineNumberElement = elt('div', lineNumberClass);
@@ -273,42 +239,23 @@ export class View {
         return lineNumberElement;
         return lineNumberElement;
     }
     }
 
 
-    private createScrollElement(): HTMLElement {
-        const scrollEl = elt('div', 'lines-content');
-
-        setStyles(scrollEl, {
-            position: 'relative',
-            overflow: 'hidden',
-            top: '0',
-            left: '0',
-            tabSize: (this._options?.formatOptions?.tabSize || 4).toString(),
-            height: `${this._lineHeight * this._jsonModel.getLineCount()}px`,
-        });
-        if (this._options?.autoWrap) {
-            scrollEl.style.width = '100%';
-        }
-        return scrollEl;
-    }
-
-    private createLineContentElement(
-        lineContent: string,
-        actualLineNumber: number,
-        visibleLineNumber: number
-    ): HTMLElement {
-        const rowDatum = this._scalingCellSizeAndPositionManager.getSizeAndPositionOfCell(visibleLineNumber);
+    private createLineContentElement(actualLineNumber: number, visibleLineNumber: number): HTMLElement {
         const lineElementClass = 'semi-json-viewer-view-line';
         const lineElementClass = 'semi-json-viewer-view-line';
         const lineElement = elt('div', lineElementClass);
         const lineElement = elt('div', lineElementClass);
         lineElement.setAttribute('data-line-element', 'true');
         lineElement.setAttribute('data-line-element', 'true');
+        
+        const rowDatum = this._scalingCellSizeAndPositionManager.getSizeAndPositionOfCell(visibleLineNumber);
         setStyles(lineElement, {
         setStyles(lineElement, {
             lineHeight: `${this._lineHeight}px`,
             lineHeight: `${this._lineHeight}px`,
             width: '100%',
             width: '100%',
             position: 'absolute',
             position: 'absolute',
             top: `${rowDatum.offset + this._verticalOffsetAdjustment}px`,
             top: `${rowDatum.offset + this._verticalOffsetAdjustment}px`,
         });
         });
+        
         if (!this._options?.autoWrap) {
         if (!this._options?.autoWrap) {
             lineElement.style.height = `${this._lineHeight}px`;
             lineElement.style.height = `${this._lineHeight}px`;
         }
         }
-        lineElement.innerHTML = lineContent;
+        
         lineElement.dataset.lineNumber = actualLineNumber.toString();
         lineElement.dataset.lineNumber = actualLineNumber.toString();
         // @ts-ignore
         // @ts-ignore
         lineElement.lineNumber = actualLineNumber;
         lineElement.lineNumber = actualLineNumber;
@@ -324,7 +271,8 @@ export class View {
 
 
     private _measureAndUpdateItemHeight(item: HTMLElement, index: number) {
     private _measureAndUpdateItemHeight(item: HTMLElement, index: number) {
         const height = item.offsetHeight;
         const height = item.offsetHeight;
-        const width = item.textContent?.length * 10;
+        const width = item.children[0].getBoundingClientRect().width * 2;
+        
         if (!this._options?.autoWrap && width > this._scrollDom.offsetWidth) {
         if (!this._options?.autoWrap && width > this._scrollDom.offsetWidth) {
             this._scrollDom.style.width = `${width}px`;
             this._scrollDom.style.width = `${width}px`;
         }
         }
@@ -355,6 +303,7 @@ export class View {
 
 
     public layout() {
     public layout() {
         this.clearContainers();
         this.clearContainers();
+        this._customRenderMap.clear();
 
 
         const visibleLineCount = this._foldingModel.getVisibleLineCount();
         const visibleLineCount = this._foldingModel.getVisibleLineCount();
         this._scalingCellSizeAndPositionManager.configure({
         this._scalingCellSizeAndPositionManager.configure({
@@ -383,6 +332,14 @@ export class View {
         const totalSize = this._scalingCellSizeAndPositionManager.getTotalSize();
         const totalSize = this._scalingCellSizeAndPositionManager.getTotalSize();
         this._scrollDom.style.height = `${totalSize}px`;
         this._scrollDom.style.height = `${totalSize}px`;
         this._lineScrollDom.style.height = `${totalSize}px`;
         this._lineScrollDom.style.height = `${totalSize}px`;
+        if (this._options?.readOnly && this._customRenderMap.size > 0) {
+            this._customRenderMap.forEach((value, key) => {
+                key.innerHTML = '';
+            });
+            this.emitter.emit('customRender', {
+                customRenderMap: this._customRenderMap
+            });
+        }
     }
     }
 
 
     private renderVisibleLines(startVisibleLine: number, endVisibleLine: number) {
     private renderVisibleLines(startVisibleLine: number, endVisibleLine: number) {
@@ -414,85 +371,143 @@ export class View {
     }
     }
 
 
     private renderLineContent(actualLineNumber: number, visibleLineNumber: number, tokens: Token[], line: string) {
     private renderLineContent(actualLineNumber: number, visibleLineNumber: number, tokens: Token[], line: string) {
-        const lineContent = this.renderTokensWithHighlight(tokens, line, actualLineNumber);
-        const lineElement = this.createLineContentElement(lineContent, actualLineNumber, visibleLineNumber);
+        const lineElement = this.createLineContentElement(actualLineNumber, visibleLineNumber);
+        const contentContainer = this.renderTokensWithHighlight(tokens, line, actualLineNumber);
+        lineElement.appendChild(contentContainer);
         this._scrollDom.appendChild(lineElement);
         this._scrollDom.appendChild(lineElement);
-
+    
         this._measureAndUpdateItemHeight(lineElement, visibleLineNumber);
         this._measureAndUpdateItemHeight(lineElement, visibleLineNumber);
         return lineElement;
         return lineElement;
     }
     }
+    
 
 
-    private renderTokensWithHighlight(tokens: Token[], text: string, lineNumber: number): string {
-        let html = '';
+    private renderTokensWithHighlight(tokens: Token[], text: string, lineNumber: number): HTMLElement {
+        const container = document.createElement('span');
         let currentOffset = 0;
         let currentOffset = 0;
-
+    
         const searchResults = this._searchWidget.binarySearchByLine(lineNumber);
         const searchResults = this._searchWidget.binarySearchByLine(lineNumber);
         for (let i = 0; i < tokens.length; i++) {
         for (let i = 0; i < tokens.length; i++) {
             const token = tokens[i];
             const token = tokens[i];
             const start = token.startIndex;
             const start = token.startIndex;
             const end = i + 1 < tokens.length ? tokens[i + 1].startIndex : text.length;
             const end = i + 1 < tokens.length ? tokens[i + 1].startIndex : text.length;
             let content = text.substring(start, end);
             let content = text.substring(start, end);
-
+    
             if (searchResults && searchResults.length > 0) {
             if (searchResults && searchResults.length > 0) {
-                html += this.highlightContent(content, currentOffset, searchResults, token.scopes);
+                const highlightedSpan = this.createHighlightedContent(content, currentOffset, searchResults, token.scopes);
+                container.appendChild(highlightedSpan);
             } else {
             } else {
-                content = this.escapeHtml(content);
-                html += `<span class="${token.scopes}">${content}</span>`;
+                if (this._options?.readOnly && this._tryApplyCustomRender(token.scopes, content)) {
+                    const offset = this._jsonModel.getOffsetAt(lineNumber, (start + end) / 2);
+                    const node = this._root?.getNodeFromOffset(offset);
+                    const path = getNodePath(node);
+                    const pathChain = getPathChain(path);
+                    const customElement = this._renderCustomToken(content, this._customRenderRule, token, pathChain);
+                    if (customElement instanceof HTMLElement) {
+                        container.appendChild(customElement);
+                        continue;
+                    } else if (customElement !== null) {
+                        const span = document.createElement('span');
+                        span.className = token.scopes;
+                        span.textContent = content;
+                        container.appendChild(span);
+                        this._customRenderMap.set(span, customElement);
+                        continue;
+                    }
+                }
+    
+                const span = document.createElement('span');
+                span.className = token.scopes;
+                span.textContent = content;
+                container.appendChild(span);
             }
             }
-
+    
             currentOffset += content.length;
             currentOffset += content.length;
         }
         }
-
-        return html;
+    
+        return container;
     }
     }
 
 
-    private highlightContent(content: string, offset: number, searchResults: FindMatch[], tokenClass: string): string {
-        let result = '';
+    private createHighlightedContent(content: string, offset: number, searchResults: FindMatch[], tokenClass: string): HTMLElement {
+        const container = document.createElement('span');
         let lastIndex = 0;
         let lastIndex = 0;
-
+    
         for (const match of searchResults) {
         for (const match of searchResults) {
             const startIndex = Math.max(0, match.range.startColumn - 1 - offset);
             const startIndex = Math.max(0, match.range.startColumn - 1 - offset);
             const endIndex = Math.min(content.length, match.range.endColumn - 1 - offset);
             const endIndex = Math.min(content.length, match.range.endColumn - 1 - offset);
-
+    
             if (startIndex >= content.length || endIndex <= 0) continue;
             if (startIndex >= content.length || endIndex <= 0) continue;
-
+    
             if (startIndex > lastIndex) {
             if (startIndex > lastIndex) {
-                result += `<span class="${tokenClass}">${this.escapeHtml(
-                    content.substring(lastIndex, startIndex)
-                )}</span>`;
+                const normalSpan = document.createElement('span');
+                normalSpan.className = tokenClass;
+                normalSpan.textContent = content.substring(lastIndex, startIndex);
+                container.appendChild(normalSpan);
             }
             }
-
-            const highlightedText = this.escapeHtml(content.substring(startIndex, endIndex));
+    
+            const highlightSpan = document.createElement('span');
+            highlightSpan.textContent = content.substring(startIndex, endIndex);
+            
             const currentMatch = this._searchWidget.searchResults?.[this._searchWidget._currentResultIndex];
             const currentMatch = this._searchWidget.searchResults?.[this._searchWidget._currentResultIndex];
-            const searchResultClass = 'semi-json-viewer-search-result';
-            const currentSearchResultClass = 'semi-json-viewer-current-search-result';
-            if (
+            const isCurrentMatch = 
                 match.range.startLineNumber === currentMatch?.range.startLineNumber &&
                 match.range.startLineNumber === currentMatch?.range.startLineNumber &&
                 match.range.endLineNumber === currentMatch?.range.endLineNumber &&
                 match.range.endLineNumber === currentMatch?.range.endLineNumber &&
                 match.range.startColumn === currentMatch?.range.startColumn &&
                 match.range.startColumn === currentMatch?.range.startColumn &&
-                match.range.endColumn === currentMatch?.range.endColumn
-            ) {
-                result += `<span class="${tokenClass} ${searchResultClass} ${currentSearchResultClass}" data-start-column="${match.range.startColumn}" data-end-column="${match.range.endColumn}">${highlightedText}</span>`;
-            } else {
-                result += `<span class="${tokenClass} ${searchResultClass}" data-start-column="${match.range.startColumn}" data-end-column="${match.range.endColumn}">${highlightedText}</span>`;
-            }
-
+                match.range.endColumn === currentMatch?.range.endColumn;
+    
+            highlightSpan.className = `${tokenClass} semi-json-viewer-search-result${
+                isCurrentMatch ? ' semi-json-viewer-current-search-result' : ''
+            }`;
+            highlightSpan.dataset.startColumn = match.range.startColumn.toString();
+            highlightSpan.dataset.endColumn = match.range.endColumn.toString();
+            
+            container.appendChild(highlightSpan);
             lastIndex = endIndex;
             lastIndex = endIndex;
         }
         }
-
+    
         if (lastIndex < content.length) {
         if (lastIndex < content.length) {
-            result += `<span class="${tokenClass}">${this.escapeHtml(content.substring(lastIndex))}</span>`;
+            const remainingSpan = document.createElement('span');
+            remainingSpan.className = tokenClass;
+            remainingSpan.textContent = content.substring(lastIndex);
+            container.appendChild(remainingSpan);
         }
         }
+    
+        return container;
+    }
+
+    private _tryApplyCustomRender(tokenClass: string, content: string): boolean {
+        if (!this._customRenderRule || this._customRenderRule.length <= 0) return false;
+        if (
+            tokenClass === TOKEN_VALUE_BOOLEAN ||
+            tokenClass === TOKEN_VALUE_NULL ||
+            tokenClass === TOKEN_VALUE_STRING ||
+            tokenClass === TOKEN_VALUE_NUMBER ||
+            tokenClass === TOKEN_PROPERTY_NAME
+        ) {
+            return true;
+        }
+        return false;
+    }
 
 
-        return result;
+    private isMatch(content: string, pathChain: string, rule: CustomRenderRule) {
+        const match = rule.match;
+        if (typeof match === 'function') {
+            return match(content, pathChain);
+        } else if (typeof match === 'string') {
+            return match === content;
+        } else if (match instanceof RegExp) {
+            return match.test(content);
+        }
+        return false;
     }
     }
 
 
-    private escapeHtml(text: string): string {
-        return text
-            .replace(/&/g, '&amp;')
-            .replace(/</g, '&lt;')
-            .replace(/>/g, '&gt;')
-            .replace(/ /g, '&nbsp;')
-            .replace(/\t/g, '&#9;');
+    private _renderCustomToken(content: string, rule: CustomRenderRule[], token: Token, pathChain: string): HTMLElement | null {
+        const realContent = content.replace(/^"|"$/g, '');
+        for (const item of rule) {
+            if (this.isMatch(realContent, pathChain, item)) {
+                const element = item.render(content);
+                return element;
+            }
+        }
+        return null;
     }
     }
 }
 }

+ 88 - 0
packages/semi-json-viewer-core/src/view/viewDOMBuilder.ts

@@ -0,0 +1,88 @@
+import { elt, setStyles } from '../common/dom';
+import { JsonViewerOptions } from '../json-viewer/jsonViewer';
+
+export class ViewDOMBuilder {
+    private _lineHeight: number;
+    private _options?: JsonViewerOptions;
+    private _totalLines: number;
+
+    constructor(lineHeight: number, totalLines: number, options?: JsonViewerOptions) {
+        this._lineHeight = lineHeight;
+        this._totalLines = totalLines;
+        this._options = options;
+    }
+
+    public createRenderContainer(): HTMLElement {
+        const renderContainer = elt('div', 'json-viewer-container');
+        setStyles(renderContainer, {
+            position: 'relative',
+            height: '100%',
+            width: '100%',
+            overflow: 'auto',
+        });
+        return renderContainer;
+    }
+
+    public createLineNumberContainer(): HTMLElement {
+        const lineNumberClass = 'semi-json-viewer-line-number-container';
+        const lineNumberContainer = elt('div', lineNumberClass);
+        setStyles(lineNumberContainer, {
+            position: 'absolute',
+            left: '0',
+            top: '0',
+            width: '50px',
+        });
+        return lineNumberContainer;
+    }
+
+    public createContentContainer(): HTMLElement {
+        const contentClass = 'semi-json-viewer-content-container';
+        const contentContainer = elt('div', contentClass);
+        setStyles(contentContainer, {
+            position: 'absolute',
+            left: '50px',
+            top: '0',
+            right: '0',
+            overflowX: 'auto',
+            overflowY: 'scroll',
+            outline: 'none',
+        });
+
+        if (!this._options?.readOnly) {
+            contentContainer.contentEditable = 'true';
+            contentContainer.style.caretColor = 'black';
+            contentContainer.spellcheck = false;
+        }
+        return contentContainer;
+    }
+
+    public createScrollElement(): HTMLElement {
+        const scrollEl = elt('div', 'lines-content');
+        setStyles(scrollEl, {
+            position: 'relative',
+            overflow: 'hidden',
+            top: '0',
+            left: '0',
+            tabSize: (this._options?.formatOptions?.tabSize || 4).toString(),
+            height: `${this._lineHeight * this._totalLines}px`,
+        });
+        
+        if (this._options?.autoWrap) {
+            scrollEl.style.width = '100%';
+        }
+        return scrollEl;
+    }
+
+    public createLineScrollContainer(): HTMLElement {
+        const lineScrollContainer = elt('div', 'line-scroll-container');
+        setStyles(lineScrollContainer, {
+            position: 'absolute',
+            top: '0',
+            left: '0',
+            height: `${this._lineHeight * this._totalLines}px`,
+            width: '100%',
+            overflow: 'hidden',
+        });
+        return lineScrollContainer;
+    }
+}

+ 131 - 2
packages/semi-ui/jsonViewer/_story/jsonViewer.stories.jsx

@@ -1,10 +1,14 @@
 import React, { useState, useEffect, useRef } from 'react';
 import React, { useState, useEffect, useRef } from 'react';
-
 import JsonViewer from '../index';
 import JsonViewer from '../index';
-import Button from '../../button';
+import Popover from '../../popover';
+import Image from '../../image';
+import Modal from '../../modal';
 export default {
 export default {
     title: 'JsonViewer',
     title: 'JsonViewer',
 };
 };
+import Rating from '../../rating';
+import Button from '../../button';
+import Tag from '../../tag';
 
 
 const baseStr = `{
 const baseStr = `{
 	"min_position": 1,
 	"min_position": 1,
@@ -43,6 +47,36 @@ const baseStr = `{
 	]
 	]
 }`;
 }`;
 
 
+const customStr = `{
+	"url": "https://semi.design/zh-CN/plus/jsonviewer",
+	"name": "Semi Design",
+	"boolean": false,
+	"dialog": "showDialog",
+	"image": "https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg",
+	"number": 100,
+	"array": [
+		"https://semi.design/zh-CN/plus/jsonviewer",
+		"https://semi.design/zh-CN/plus/jsonviewer",
+		"https://semi.design/zh-CN/plus/jsonviewer"
+	],
+	"objArray": [
+		{
+			"name": "Semi Design1",
+			"age": 50
+		},
+		{
+			"name": "Semi Design2",
+			"age": 100
+		},
+		{
+			"name": "Semi Design3",
+			"age": 150
+		}
+	]
+}`;
+
+
+
 export const DefaultJsonViewer = () => {
 export const DefaultJsonViewer = () => {
     const onChangeHandler = value => {
     const onChangeHandler = value => {
         console.log(value, 'value');
         console.log(value, 'value');
@@ -52,6 +86,8 @@ export const DefaultJsonViewer = () => {
     const [lineHeight, setLineHeight] = useState(20);
     const [lineHeight, setLineHeight] = useState(20);
     const jsonviewerRef = useRef(null);
     const jsonviewerRef = useRef(null);
 
 
+
+
     return (
     return (
         <>
         <>
             <JsonViewer
             <JsonViewer
@@ -65,3 +101,96 @@ export const DefaultJsonViewer = () => {
         </>
         </>
     );
     );
 };
 };
+
+
+export const CustomRender = () => {
+
+	const customRender = [
+		{
+			match: (val, pathChain) => {
+				if(pathChain !== 'root.url') {
+					return false;
+				}
+				return typeof val === 'string' && val.startsWith('http');
+			},
+			render: (val) => {
+				return <Popover showArrow content={'我是用户自定义的渲染'} trigger='hover'><span href={val.replace(/^"|"$/g, '')} target='_blank'>{val}</span></Popover>;
+			},
+		},
+		{
+			match: (val, pathChain) => pathChain === 'root.image' && typeof val === 'string' && val.startsWith('http'),
+			render: (val) => {
+				return <Popover showArrow content={<Image width={100} height={100} src={val.replace(/^"|"$/g, '')} />} trigger='hover'><span>{val}</span></Popover>;
+			}
+		},
+		{
+			match: 'Semi Design1',
+			render: (val) => {
+				return <Tag size='small' shape='circle'>{val}</Tag>
+			}
+		},
+		{
+			match: 'false',
+			render: (val) => {
+				return <Rating defaultValue={3} size={10} disabled/>
+			}
+		},
+		{
+			match: new RegExp('^\\d+$'),
+			render: (val) => {
+				return <span style={{color:'black',backgroundColor:'transparent',border:'1px solid #031126',borderRadius:'4px',padding:'2px 4px'}}>{val}</span>
+			}
+		},
+		{
+			match: 'showDialog',
+			render: (val) => {
+				return <Button onClick={showDialog} type='danger' style={{height:'18px',lineHeight:'18px'}}>{val}</Button>
+			}
+		}
+	];
+
+	const [visible, setVisible] = useState(false);
+    const showDialog = () => {
+        setVisible(true);
+    };
+    const handleOk = () => {
+        setVisible(false);
+        console.log('Ok button clicked');
+    };
+    const handleCancel = () => {
+        setVisible(false);
+        console.log('Cancel button clicked');
+    };
+    const handleAfterClose = () => {
+        console.log('After Close callback executed');
+    };
+
+    const [autoWrap, setAutoWrap] = useState(true);
+    const [lineHeight, setLineHeight] = useState(20);
+    const jsonviewerRef = useRef(null);
+
+
+    return (
+        <>
+            <JsonViewer
+                value={customStr}
+                width={700}
+                height={400}
+                options={{ lineHeight: lineHeight, autoWrap: autoWrap,readOnly:true, customRenderRule: customRender, formatOptions: { tabSize: 4 } }}
+                ref={jsonviewerRef}
+            />
+			            <Modal
+                title="基本对话框"
+                visible={visible}
+                onOk={handleOk}
+                afterClose={handleAfterClose} //>=1.16.0
+                onCancel={handleCancel}
+                closeOnEsc={true}
+            >
+                This is the content of a basic modal.
+                <br />
+                More content...
+            </Modal>
+        </>
+    );
+};

+ 124 - 0
packages/semi-ui/jsonViewer/_story/jsonViewer.stories.tsx

@@ -1,5 +1,11 @@
 import React, { useRef, useState } from "react"
 import React, { useRef, useState } from "react"
 import JsonViewer from "../index"
 import JsonViewer from "../index"
+import Popover from '../../popover'
+import Image from '../../image'
+import Modal from '../../modal'
+import Rating from '../../rating'
+import Button from '../../button'
+import Tag from '../../tag'
 
 
 
 
 
 
@@ -74,4 +80,122 @@ const baseStr = `{
            />
            />
        </>
        </>
    );
    );
+};
+
+const customStr = `{
+    "url": "https://semi.design/zh-CN/plus/jsonviewer",
+    "name": "Semi Design",
+    "boolean": false,
+    "dialog": "showDialog",
+    "image": "https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg",
+    "number": 100,
+    "array": [
+        "https://semi.design/zh-CN/plus/jsonviewer",
+        "https://semi.design/zh-CN/plus/jsonviewer",
+        "https://semi.design/zh-CN/plus/jsonviewer"
+    ],
+    "objArray": [
+        {
+            "name": "Semi Design1",
+            "age": 50
+        },
+        {
+            "name": "Semi Design2",
+            "age": 100
+        },
+        {
+            "name": "Semi Design3",
+            "age": 150
+        }
+    ]
+}`;
+
+export const CustomRender = () => {
+    const customRender = [
+        {
+            match: (val: any, key?: string) => {
+                if(key && key !== 'url') {
+                    return false;
+                }
+                return typeof val === 'string' && val.startsWith('http');
+            },
+            render: (val: string) => {
+                return <Popover showArrow content={'我是用户自定义的渲染'} trigger='hover'><span href={val.replace(/^"|"$/g, '')} target='_blank'>{val}</span></Popover>;
+            },
+        },
+        {
+            match: (val: any, key?: string) => key === 'image' && typeof val === 'string' && val.startsWith('http'),
+            render: (val: string) => {
+                return <Popover showArrow content={<Image width={100} height={100} src={val.replace(/^"|"$/g, '')} />} trigger='hover'><span>{val}</span></Popover>;
+            }
+        },
+        {
+            match: 'Semi Design1',
+            render: (val: string) => {
+                return <Tag size='small' shape='circle'>{val}</Tag>
+            }
+        },
+        {
+            match: 'false',
+            render: (val: string) => {
+                return <Rating defaultValue={3} size={10} disabled/>
+            }
+        },
+        {
+            match: new RegExp('^\\d+$'),
+            render: (val: string) => {
+                return <span style={{color:'black',backgroundColor:'transparent',border:'1px solid #031126',borderRadius:'4px',padding:'2px 4px'}}>{val}</span>
+            }
+        },
+        {
+            match: 'showDialog',
+            render: (val: string) => {
+                return <Button onClick={showDialog} type='danger' style={{height:'18px',lineHeight:'18px'}}>{val}</Button>
+            }
+        }
+    ];
+
+    const [visible, setVisible] = useState(false);
+    const showDialog = () => {
+        setVisible(true);
+    };
+    const handleOk = () => {
+        setVisible(false);
+        console.log('Ok button clicked');
+    };
+    const handleCancel = () => {
+        setVisible(false);
+        console.log('Cancel button clicked');
+    };
+    const handleAfterClose = () => {
+        console.log('After Close callback executed');
+    };
+
+    const [autoWrap, setAutoWrap] = useState(true);
+    const [lineHeight, setLineHeight] = useState(20);
+    const jsonviewerRef = useRef(null);
+
+    return (
+        <>
+            <JsonViewer
+                value={customStr}
+                width={700}
+                height={400}
+                options={{ lineHeight: lineHeight, autoWrap: autoWrap, readOnly: true, customRenderRule: customRender, formatOptions: { tabSize: 4 } }}
+                ref={jsonviewerRef}
+            />
+            <Modal
+                title="基本对话框"
+                visible={visible}
+                onOk={handleOk}
+                afterClose={handleAfterClose}
+                onCancel={handleCancel}
+                closeOnEsc={true}
+            >
+                This is the content of a basic modal.
+                <br />
+                More content...
+            </Modal>
+        </>
+    );
 };
 };

+ 11 - 1
packages/semi-ui/jsonViewer/index.tsx

@@ -20,6 +20,7 @@ import {
     IconWholeWord,
     IconWholeWord,
 } from '@douyinfe/semi-icons';
 } from '@douyinfe/semi-icons';
 import BaseComponent, { BaseProps } from '../_base/baseComponent';
 import BaseComponent, { BaseProps } from '../_base/baseComponent';
+import { createPortal } from 'react-dom';
 import {isEqual} from "lodash";
 import {isEqual} from "lodash";
 const prefixCls = cssClasses.PREFIX;
 const prefixCls = cssClasses.PREFIX;
 
 
@@ -38,7 +39,8 @@ export interface JsonViewerProps extends BaseProps {
 
 
 export interface JsonViewerState {
 export interface JsonViewerState {
     searchOptions: SearchOptions;
     searchOptions: SearchOptions;
-    showSearchBar: boolean
+    showSearchBar: boolean;
+    customRenderMap: Map<HTMLElement, React.ReactNode>
 }
 }
 
 
 interface SearchOptions {
 interface SearchOptions {
@@ -78,6 +80,7 @@ class JsonViewerCom extends BaseComponent<JsonViewerProps, JsonViewerState> {
                 regex: false,
                 regex: false,
             },
             },
             showSearchBar: false,
             showSearchBar: false,
+            customRenderMap: new Map(),
         };
         };
     }
     }
 
 
@@ -104,6 +107,9 @@ class JsonViewerCom extends BaseComponent<JsonViewerProps, JsonViewerState> {
                 const res = this.props.renderTooltip?.(value, el);
                 const res = this.props.renderTooltip?.(value, el);
                 return res;
                 return res;
             },
             },
+            notifyCustomRender: (customRenderMap) => {
+                this.setState({ customRenderMap });
+            },
             setSearchOptions: (key: string) => {
             setSearchOptions: (key: string) => {
                 this.setState(
                 this.setState(
                     {
                     {
@@ -316,6 +322,10 @@ class JsonViewerCom extends BaseComponent<JsonViewerProps, JsonViewerState> {
                         </DragMove>
                         </DragMove>
                     )}
                     )}
                 </div>
                 </div>
+                {Array.from(this.state.customRenderMap.entries()).map(([key, value]) => {
+                    // key.innerHTML = '';
+                    return createPortal(value, key);
+                })}
             </>
             </>
         );
         );
     }
     }