feat: support vditor (#894)

Co-authored-by: sealday <sealday@gmail.com>
Reviewed-on: daoyoucloud/tachybase#894
This commit is contained in:
sealday 2024-04-30 02:24:09 +08:00
parent 8a789c70f1
commit fda9853509
29 changed files with 723 additions and 0 deletions

View File

@ -25,6 +25,22 @@ const handleErrorMessage = (error, notification) => {
};
const errorCache = new Map();
function offsetToTimeZone(offset) {
const hours = Math.floor(Math.abs(offset));
const minutes = Math.abs((offset % 1) * 60);
const formattedHours = (hours < 10 ? '0' : '') + hours;
const formattedMinutes = (minutes < 10 ? '0' : '') + minutes;
const sign = offset >= 0 ? '+' : '-';
return sign + formattedHours + ':' + formattedMinutes;
}
const getCurrentTimezone = () => {
const timezoneOffset = new Date().getTimezoneOffset() / -60;
return offsetToTimeZone(timezoneOffset);
};
export class APIClient extends APIClientSDK {
services: Record<string, Result<any, any>> = {};
silence = false;
@ -36,6 +52,17 @@ export class APIClient extends APIClientSDK {
return this.services[uid];
}
getHeaders() {
const headers = super.getHeaders();
const appName = this.app.getName();
if (appName) {
headers['X-App'] = appName;
}
headers['X-Timezone'] = getCurrentTimezone();
headers['X-Hostname'] = window?.location?.hostname;
return headers;
}
interceptors() {
this.axios.interceptors.request.use((config) => {
config.headers['X-With-ACL-Meta'] = true;

View File

@ -167,6 +167,19 @@ export class Application {
return this.options.publicPath || '/';
}
getName() {
return getSubAppName(this.getPublicPath()) || null;
}
getApiUrl(pathname = '') {
let baseURL = this.apiClient.axios['defaults']['baseURL'];
if (!baseURL.startsWith('http://') && !baseURL.startsWith('https://')) {
const { protocol, host } = window.location;
baseURL = `${protocol}//${host}${baseURL}`;
}
return baseURL.replace(/\/$/g, '') + '/' + pathname.replace(/^\//g, '');
}
getRouteUrl(pathname: string) {
return this.options.publicPath.replace(/\/$/g, '') + pathname;
}

View File

@ -258,6 +258,23 @@ export class APIClient {
}
}
getHeaders() {
const headers = {};
if (this.auth.locale) {
headers['X-Locale'] = this.auth.locale;
}
if (this.auth.role) {
headers['X-Role'] = this.auth.role;
}
if (this.auth.authenticator) {
headers['X-Authenticator'] = this.auth.authenticator;
}
if (this.auth.token) {
headers['Authorization'] = `Bearer ${this.auth.token}`;
}
return headers;
}
interceptors() {
this.axios.interceptors.request.use((config) => {
config.paramsSerializer = (params) => {

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-field-markdown-vditor

View File

@ -0,0 +1,15 @@
import { defineConfig } from '@nocobase/build';
import fs from 'fs/promises';
import path from 'path';
const vditor = path.dirname(require.resolve('vditor'));
export default defineConfig({
afterBuild: async (log) => {
log('coping vditor dist');
await fs.cp(vditor, path.resolve(__dirname, 'dist/client/vditor/dist'), {
recursive: true,
force: true,
});
},
});

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,25 @@
{
"name": "@tachybase/plugin-field-markdown-vditor",
"displayName": "Collection field: Markdown(Vditor)",
"version": "0.0.1",
"description": "Used to store Markdown and render it using Vditor editor, supports common Markdown syntax such as list, code, quote, etc., and supports uploading images, recordings, etc.It also allows for instant rendering, where what you see is what you get.",
"keywords": [
"Collection fields"
],
"license": "Apache-2.0",
"main": "dist/server/index.js",
"devDependencies": {
"@ant-design/icons": "^5.3.6",
"@tachybase/schema": "workspace:*",
"antd": "^5.16.1",
"koa-send": "^5.0.1",
"vditor": "^3.10.3"
},
"peerDependencies": {
"@nocobase/client": "workspace:*",
"@nocobase/server": "workspace:*",
"@nocobase/test": "workspace:*"
},
"description.zh-CN": "用于存储 Markdown并使用 Vditor 编辑器渲染,支持常见 Markdown 语法,如列表,代码,引用等,并支持上传图片,录音等。同时可以做到即时渲染,所见即所得。",
"displayName.zh-CN": "数据表字段Markdown(Vditor)"
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,120 @@
import { Field, useField } from '@tachybase/schema';
import { withDynamicSchemaProps } from '@nocobase/client';
import { Popover } from 'antd';
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import Vditor from 'vditor';
import { useCDN } from './const';
import useStyle from './style';
function convertToText(markdownText: string) {
const content = markdownText;
let temp = document.createElement('div');
temp.innerHTML = content;
const text = temp.innerText;
temp = null;
return text?.replace(/[\n\r]/g, '') || '';
}
const getContentWidth = (element) => {
if (element) {
const range = document.createRange();
range.selectNodeContents(element);
const contentWidth = range.getBoundingClientRect().width;
return contentWidth;
}
};
function DisplayInner(props: { value: string; style?: CSSProperties }) {
const containerRef = useRef<HTMLDivElement>();
const { wrapSSR, componentCls, hashId } = useStyle();
const cdn = useCDN();
useEffect(() => {
if (!props.value) return;
Vditor.preview(containerRef.current, props.value, {
mode: 'light',
cdn,
});
}, [props.value]);
return wrapSSR(
<div className={`${hashId} ${componentCls}`}>
<div ref={containerRef} style={{ border: 'none', ...(props?.style ?? {}) }} />
</div>,
);
}
export const Display = withDynamicSchemaProps((props) => {
const field = useField<Field>();
const value = props.value ?? field.value;
const cdn = useCDN();
const containerRef = useRef<HTMLDivElement>();
const [popoverVisible, setPopoverVisible] = useState(false);
const [ellipsis, setEllipsis] = useState(false);
const [text, setText] = useState('');
const elRef = useRef<HTMLDivElement>();
useEffect(() => {
if (!props.value || !field.value) return;
if (props.ellipsis) {
Vditor.md2html(props.value, {
mode: 'light',
cdn,
})
.then((html) => {
setText(convertToText(html));
})
.catch(() => setText(''));
} else {
Vditor.preview(containerRef.current, props.value ?? field.value, {
mode: 'light',
cdn,
});
}
}, [props.value, props.ellipsis, field.value]);
const isOverflowTooltip = useCallback(() => {
if (!elRef.current) return false;
const contentWidth = getContentWidth(elRef.current);
const offsetWidth = elRef.current?.offsetWidth;
return contentWidth > offsetWidth;
}, [elRef]);
if (props.ellipsis) {
return (
<Popover
open={popoverVisible}
onOpenChange={(visible) => {
setPopoverVisible(ellipsis && visible);
}}
content={<DisplayInner value={value} style={{ maxWidth: 500, maxHeight: 400, overflowY: 'auto' }} />}
>
<div
ref={elRef}
style={{
overflow: 'hidden',
overflowWrap: 'break-word',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
wordBreak: 'break-all',
}}
onMouseEnter={(e) => {
const el = e.target as any;
const isShowTooltips = isOverflowTooltip();
if (isShowTooltips) {
setEllipsis(el.scrollWidth >= el.clientWidth);
}
}}
>
{text}
</div>
</Popover>
);
}
return <DisplayInner value={value} />;
});

View File

@ -0,0 +1,134 @@
import { useAPIClient, useApp, withDynamicSchemaProps } from '@nocobase/client';
import React, { useEffect, useLayoutEffect, useRef } from 'react';
import Vditor from 'vditor';
import { defaultToolbar } from '../interfaces/markdown-vditor';
import { useCDN } from './const';
import useStyle from './style';
export const Edit = withDynamicSchemaProps((props) => {
const { disabled, onChange, value, fileCollection, toolbar } = props;
const vdRef = useRef<Vditor>();
const vdFullscreen = useRef(false);
const containerRef = useRef<HTMLDivElement>();
const containerParentRef = useRef<HTMLDivElement>();
const app = useApp();
const apiClient = useAPIClient();
const cdn = useCDN();
const { wrapSSR, hashId, componentCls: containerClassName } = useStyle();
useEffect(() => {
const uploadFileCollection = fileCollection || 'attachments';
const toolbarConfig = toolbar ?? defaultToolbar;
const vditor = new Vditor(containerRef.current, {
value: value ?? '',
lang: apiClient.auth.locale.replaceAll('-', '_') as any,
cache: {
enable: false,
},
undoDelay: 0,
preview: {
math: {
engine: 'KaTeX',
},
},
toolbar: toolbarConfig,
fullscreen: {
index: 1200,
},
cdn,
minHeight: 200,
after: () => {
vdRef.current = vditor;
vditor.setValue(value ?? '');
if (disabled) {
vditor.disabled();
} else {
vditor.enable();
}
},
input(value) {
onChange(value);
},
upload: {
url: app.getApiUrl(`${uploadFileCollection}:create`),
headers: apiClient.getHeaders(),
multiple: false,
fieldName: 'file',
format(files, responseText) {
const response = JSON.parse(responseText);
const formatResponse = {
msg: '',
code: 0,
data: {
errFiles: [],
succMap: {
[response.data.filename]: response.data.url,
},
},
};
return JSON.stringify(formatResponse);
},
},
});
return () => {
vdRef.current?.destroy();
vdRef.current = undefined;
};
}, [fileCollection, toolbar?.join(',')]);
useEffect(() => {
if (value === vdRef?.current?.getValue()) {
return;
}
vdRef.current?.setValue(value);
vdRef.current?.focus();
// 移动光标到末尾
const preArea = containerRef.current.querySelector('div.vditor-content > div.vditor-ir > pre') as HTMLPreElement;
if (preArea) {
const range = document.createRange();
const selection = window.getSelection();
if (selection) {
range.selectNodeContents(preArea);
range.collapse(false); // 将光标移动到内容末尾
selection.removeAllRanges();
selection.addRange(range);
}
}
}, [value]);
useEffect(() => {
if (disabled) {
vdRef.current?.disabled();
} else {
vdRef.current?.enable();
}
}, [disabled]);
useLayoutEffect(() => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target;
if (target.className.includes('vditor--fullscreen')) {
document.body.appendChild(target);
vdFullscreen.current = true;
} else if (vdFullscreen.current) {
containerParentRef.current?.appendChild(target);
vdFullscreen.current = false;
}
}
});
observer.observe(containerRef.current);
return () => {
observer.unobserve(containerRef.current);
};
}, []);
return wrapSSR(
<div ref={containerParentRef} className={`${hashId} ${containerClassName}`}>
<div id={hashId} ref={containerRef}></div>
</div>,
);
});

View File

@ -0,0 +1,7 @@
import { usePlugin } from '@nocobase/client';
import { PluginFieldMarkdownVditorClient } from '../';
export const useCDN = () => {
const plugin = usePlugin(PluginFieldMarkdownVditorClient);
return plugin.getCDN();
};

View File

@ -0,0 +1,8 @@
import { connect, mapReadPretty } from '@tachybase/schema';
import { withDynamicSchemaProps } from '@nocobase/client';
import { Display } from './Display';
import { Edit } from './Edit';
export const MarkdownVditor = withDynamicSchemaProps(connect(Edit, mapReadPretty(Display)));
export default MarkdownVditor;

View File

@ -0,0 +1,15 @@
import { genStyleHook } from '@nocobase/client';
export default genStyleHook('nb-field-markdown-vditor', (token) => {
const { componentCls } = token;
return {
[componentCls]: {
'.vditor-reset': { fontSize: `${token.fontSize}px !important` },
'.vditor': { borderRadius: 8 },
'.vditor .vditor-content': { borderRadius: '0 0 8px 8px', overflow: 'hidden' },
'.vditor .vditor-toolbar': { paddingLeft: ' 16px !important', borderRadius: '8px 8px 0 0' },
'.vditor .vditor-content .vditor-ir .vditor-reset': { paddingLeft: ' 16px !important' },
},
};
});

View File

@ -0,0 +1,54 @@
import { Plugin } from '@nocobase/client';
import 'vditor/dist/index.css';
import { MarkdownVditor } from './components';
import { MarkdownVditorFieldInterface } from './interfaces/markdown-vditor';
export class PluginFieldMarkdownVditorClient extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {
this.app.addComponents({ MarkdownVditor });
this.initVditorDependency();
this.app.dataSourceManager.addFieldInterfaces([MarkdownVditorFieldInterface]);
}
getCDN() {
if (process.env.NODE_ENV !== 'production') {
// 开发模式下使用远程 cdn
return 'https://unpkg.com/vditor@3.10.4';
}
// 生产环境,使用本地链接,支持内网
// 需要支持子目录,比如应用部署在 /xxx/ 目录下
return this.app.getPublicPath() + 'static/plugins/@nocobase/plugin-field-markdown-vditor/dist/client/vditor';
}
initVditorDependency() {
const cdn = this.getCDN();
try {
const vditorDepdencePrefix = 'plugin-field-markdown-vditor-dep';
const vditorDepdence = {
[`${vditorDepdencePrefix}.katex`]: `${cdn}/dist/js/katex/katex.min.js?v=0.16.9`,
[`${vditorDepdencePrefix}.ABCJS`]: `${cdn}/dist/js/abcjs/abcjs_basic.min`,
[`${vditorDepdencePrefix}.plantumlEncoder`]: `${cdn}/dist/js/plantuml/plantuml-encoder.min`,
[`${vditorDepdencePrefix}.echarts`]: `${cdn}/dist/js/echarts/echarts.min`,
[`${vditorDepdencePrefix}.flowchart`]: `${cdn}/dist/js/flowchart.js/flowchart.min`,
[`${vditorDepdencePrefix}.Viz`]: `${cdn}/dist/js/graphviz/viz`,
[`${vditorDepdencePrefix}.mermaid`]: `${cdn}/dist/js/mermaid/mermaid.min`,
};
this.app.requirejs.require.config({
waitSeconds: 120,
paths: vditorDepdence,
});
Object.keys(vditorDepdence).forEach((key) => {
this.app.requirejs.require([key], (m) => {
window[key.split('.')[1]] = m;
});
});
} catch (e) {
console.log('initVditorDependency failed', e);
}
}
}
export default PluginFieldMarkdownVditorClient;

View File

@ -0,0 +1,106 @@
import { CollectionFieldInterface, interfacesProperties } from '@nocobase/client';
import { generateNTemplate } from '../locale';
import { ISchema } from '@tachybase/schema';
const { defaultProps, operators } = interfacesProperties;
export const defaultToolbar = [
'headings',
'bold',
'italic',
'strike',
'link',
'list',
'ordered-list',
'check',
'quote',
'line',
'code',
'inline-code',
'upload',
'fullscreen',
];
export class MarkdownVditorFieldInterface extends CollectionFieldInterface {
name = 'vditor';
type = 'object';
group = 'media';
order = 1;
title = generateNTemplate('Vditor');
sortable = true;
default = {
type: 'text',
length: 'long',
uiSchema: {
type: 'string',
'x-component': 'MarkdownVditor',
},
};
properties = {
...defaultProps,
'uiSchema.x-component-props.fileCollection': {
type: 'string',
title: generateNTemplate('File collection'),
'x-component': 'CollectionSelect',
'x-component-props': { filter: (collection) => collection?.options?.template === 'file' },
'x-decorator': 'FormItem',
default: '',
'x-reactions': {
fulfill: {
schema: {
description: generateNTemplate('Used to store files uploaded in the Markdown editor'),
},
},
},
},
'uiSchema.x-component-props.toolbar': {
type: 'array',
title: generateNTemplate('Toolbar'),
'x-component': 'Select',
'x-component-props': {
mode: 'multiple',
},
'x-decorator': 'FormItem',
default: defaultToolbar,
enum: [
{ value: 'emoji', label: generateNTemplate('Emoji') },
{ value: 'headings', label: generateNTemplate('Headings') },
{ value: 'bold', label: generateNTemplate('Bold') },
{ value: 'italic', label: generateNTemplate('Italic') },
{ value: 'strike', label: generateNTemplate('Strike') },
{ value: 'line', label: generateNTemplate('Line') },
{ value: 'quote', label: generateNTemplate('Quote') },
{ value: 'list', label: generateNTemplate('List') },
{ value: 'ordered-list', label: generateNTemplate('OrderedList') },
{ value: 'check', label: generateNTemplate('Check') },
{ value: 'outdent', label: generateNTemplate('Outdent') },
{ value: 'indent', label: generateNTemplate('Indent') },
{ value: 'code', label: generateNTemplate('Code') },
{ value: 'inline-code', label: generateNTemplate('InlineCode') },
{ value: 'insert-after', label: generateNTemplate('InsertAfter') },
{ value: 'insert-before', label: generateNTemplate('InsertBefore') },
{ value: 'undo', label: generateNTemplate('Undo') },
{ value: 'redo', label: generateNTemplate('Redo') },
{ value: 'upload', label: generateNTemplate('Upload') },
{ value: 'link', label: generateNTemplate('Link') },
{ value: 'record', label: generateNTemplate('Record') },
{ value: 'table', label: generateNTemplate('Table') },
{ value: 'edit-mode', label: generateNTemplate('EditMode') },
{ value: 'both', label: generateNTemplate('Both') },
{ value: 'preview', label: generateNTemplate('Preview') },
{ value: 'fullscreen', label: generateNTemplate('Fullscreen') },
{ value: 'outline', label: generateNTemplate('Outline') },
],
},
};
schemaInitialize(schema: ISchema, { block }) {
if (['Table', 'Kanban'].includes(block)) {
schema['x-component-props'] = schema['x-component-props'] || {};
schema['x-component-props']['ellipsis'] = true;
}
}
filterable = {
operators: operators.number,
};
titleUsable = true;
}

View File

@ -0,0 +1,7 @@
import { tval } from '@nocobase/client';
const NAMESPACE = 'field-markdown-vditor';
export function generateNTemplate(key: string) {
return tval(key, { ns: NAMESPACE })
}

View File

@ -0,0 +1,2 @@
export * from './server';
export { default } from './server';

View File

@ -0,0 +1,33 @@
export default {
"Vditor": "Markdown(Vditor)",
"File collection": "File collection",
"Used to store files uploaded in the Markdown editor": "Used to store files uploaded in the Markdown editor",
"Toolbar": "Editor toolbar configuration",
"Emoji": "Emoji",
"Headings": "Headings",
"Bold": "Bold",
"Italic": "Italic",
"Strike": "Strike",
"Record": "Start Record/End Record",
"Line": "Line",
"Quote": "Quote",
"List": "List",
"OrderedList": "Order List",
"Check": "Task List",
"Outdent": "Outdent",
"Indent": "Indent",
"Code": "Code Block",
"InlineCode": "Inline Code",
"InsertAfter": "Insert Line After",
"InsertBefore": "Insert Line Before",
"Undo": "Undo",
"Redo": "Redo",
"Upload": "Upload image or file",
"Link": "Link",
"Table": "Table",
"EditMode": "Edit Mode",
"Both": "Editor & Preview",
"Preview": "Preview",
"Fullscreen": "Toggle Fullscreen",
"Outline": "Outline"
}

View File

@ -0,0 +1,33 @@
export default {
"Vditor": "Markdown(Vditor)",
"File collection": "파일 데이터 테이블",
"Used to store files uploaded in the Markdown editor": "Markdown 편집기에 업로드된 파일을 저장하는 데 사용됩니다",
"Toolbar": "편집기 도구 모음 구성",
"Emoji": "이모지",
"Headings": "제목크기",
"Bold": "굵게",
"Italic": "기울임꼴",
"Strike": "취소선",
"Record": "녹음시작/녹음종료",
"Line": "문단나눔",
"Quote": "인용단락",
"List": "순서없는 목록",
"OrderedList": "순서있는 목록",
"Check": "체크박스",
"Outdent": "내어쓰기",
"Indent": "들여쓰기",
"Code": "코드블럭삽입",
"InlineCode": "인라인코드",
"InsertAfter": "블락 뒤로 입력",
"InsertBefore": "블락 앞으로 입력",
"Undo": "취소하기",
"Redo": "되돌리기",
"Upload": "이미지 업로드하기",
"Link": "링크",
"Table": "표삽입",
"EditMode": "편집모드",
"Both": "에디터 & 미리보기",
"Preview": "미리보기",
"Fullscreen": "전체화면",
"Outline": "개요"
}

View File

@ -0,0 +1,33 @@
export default {
"Vditor": "Markdown(Vditor)",
"File collection": "文件数据表",
"Used to store files uploaded in the Markdown editor": "用于存储在Markdown编辑器中上传的文件",
"Toolbar": "编辑器工具栏配置",
"Emoji": "表情",
"Headings": "标题",
"Bold": "粗体",
"Italic": "斜体",
"Strike": "删除线",
"Record": "开始录音/结束录音",
"Line": "分割线",
"Quote": "引用",
"List": "无序列表",
"OrderedList": "有序列表",
"Check": "任务列表",
"Outdent": "列表反向缩进",
"Indent": "列表缩进",
"Code": "代码块",
"InlineCode": "行内代码",
"InsertAfter": "末尾插入行",
"InsertBefore": "起始插入行",
"Undo": "撤销",
"Redo": "重做",
"Upload": "上传图片或文件",
"Link": "链接",
"Table": "表格",
"EditMode": "切换编辑模式",
"Both": "编辑 & 预览",
"Preview": "预览",
"Fullscreen": "全屏切换",
"Outline": "大纲"
}

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,7 @@
import { DataTypes, Field } from '@nocobase/database';
export class MarkdownVditorField extends Field {
get dataType() {
return DataTypes.TEXT;
}
}

View File

@ -0,0 +1,19 @@
import { Plugin } from '@nocobase/server';
export class PluginFieldMarkdownVditorServer extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {}
async install() {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default PluginFieldMarkdownVditorServer;

View File

@ -3801,6 +3801,34 @@ importers:
specifier: ^4.0.525
version: 4.0.759
packages/plugins/@tachybase/plugin-field-markdown-vditor:
dependencies:
'@nocobase/client':
specifier: workspace:*
version: link:../../../core/client
'@nocobase/server':
specifier: workspace:*
version: link:../../../core/server
'@nocobase/test':
specifier: workspace:*
version: link:../../../core/test
devDependencies:
'@ant-design/icons':
specifier: ^5.3.6
version: 5.3.6(react-dom@18.2.0)(react@18.2.0)
'@tachybase/schema':
specifier: workspace:*
version: link:../../../core/schema
antd:
specifier: ^5.16.1
version: 5.16.1(react-dom@18.2.0)(react@18.2.0)
koa-send:
specifier: ^5.0.1
version: 5.0.1
vditor:
specifier: ^3.10.3
version: 3.10.4
packages/plugins/@tachybase/plugin-mobile-client:
dependencies:
'@nocobase/client':
@ -16944,6 +16972,10 @@ packages:
readable-stream: 1.1.14
streamsearch: 0.1.2
/diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
dev: true
/diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -29784,6 +29816,12 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
/vditor@3.10.4:
resolution: {integrity: sha512-NWaMom0buUvRjOCaK/jKeJEVfZNmfTgblK4+pxBoeTdiCYn5yWokcGYMh9GzHIvt5gy6FiQFc1VQvytIwyeIwA==}
dependencies:
diff-match-patch: 1.0.5
dev: true
/verror@1.10.0:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0}