优化
This commit is contained in:
11
client/web/plugins/com.msgbyte.bbcode/manifest.json
Normal file
11
client/web/plugins/com.msgbyte.bbcode/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"label": "BBCode Mmessage Interpreter",
|
||||
"label.zh-CN": "BBCode 消息解释器",
|
||||
"name": "com.msgbyte.bbcode",
|
||||
"url": "/plugins/com.msgbyte.bbcode/index.js",
|
||||
"version": "0.0.0",
|
||||
"author": "msgbyte",
|
||||
"description": "A plugin for supporting bbcode syntax to interpret rich text messages",
|
||||
"description.zh-CN": "一个用于支持bbcode语法解释富文本消息的插件",
|
||||
"requireRestart": true
|
||||
}
|
||||
17
client/web/plugins/com.msgbyte.bbcode/package.json
Normal file
17
client/web/plugins/com.msgbyte.bbcode/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@plugins/com.msgbyte.bbcode",
|
||||
"main": "src/index.tsx",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@bbob/parser": "^2.7.0",
|
||||
"highlight.js": "^11.5.1",
|
||||
"react-highlight": "^0.14.0",
|
||||
"url-regex": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react-highlight": "^0.12.5",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`render <BBCode /> mention with space name 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="plugin-bbcode-mention-tag"
|
||||
data-userid="6251986eab331ca2efbba9c6"
|
||||
>
|
||||
@
|
||||
[UserName {"userId":"6251986eab331ca2efbba9c6","fallbackName":"Notify Bot"}]
|
||||
</span>
|
||||
<pre
|
||||
style="display: inline; white-space: break-spaces;"
|
||||
>
|
||||
123123
|
||||
</pre>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import BBCode from '../render';
|
||||
|
||||
describe('render <BBCode />', () => {
|
||||
test('mention with space name', () => {
|
||||
const raw = '[at=6251986eab331ca2efbba9c6]Notify Bot[/at] 123123';
|
||||
const wrapper = render(<BBCode plainText={raw} />);
|
||||
expect(wrapper.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { preProcessLinkText, preProcessText } from '../index';
|
||||
|
||||
describe('bbcode common', () => {
|
||||
describe('preprocess text', () => {
|
||||
it('simple url parse', () => {
|
||||
const text = preProcessLinkText('http://baidu.com');
|
||||
expect(text).toBe('[url]http://baidu.com[/url]');
|
||||
});
|
||||
|
||||
it('mix text and url parse', () => {
|
||||
const text = preProcessLinkText('open:http://baidu.com');
|
||||
expect(text).toBe('open:[url]http://baidu.com[/url]');
|
||||
});
|
||||
|
||||
it('mix text and more url parse', () => {
|
||||
const text = preProcessLinkText(
|
||||
'open:http://baidu.com and http://google.com'
|
||||
);
|
||||
expect(text).toBe(
|
||||
'open:[url]http://baidu.com[/url] and [url]http://google.com[/url]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preProcessText', () => {
|
||||
test.each([
|
||||
['https://baidu.com', '[url]https://baidu.com[/url]'],
|
||||
['[url]https://baidu.com[/url]', '[url]https://baidu.com[/url]'],
|
||||
[
|
||||
'[url=https://baidu.com]百度[/url]',
|
||||
'[url="https://baidu.com"]百度[/url]',
|
||||
],
|
||||
[
|
||||
'[url=https://baidu.com alt=test]百度[/url]',
|
||||
'[url="https://baidu.com" alt="test"]百度[/url]',
|
||||
],
|
||||
])('%s', (input, output) => {
|
||||
expect(preProcessText(input)).toBe(output);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import bbcodeParser from '../parser';
|
||||
|
||||
describe('bbcode parser', () => {
|
||||
test('simple text', () => {
|
||||
const ast = bbcodeParser.parse('text');
|
||||
|
||||
expect(ast).toMatchObject(['text']);
|
||||
});
|
||||
|
||||
test('simple text in []', () => {
|
||||
const ast = bbcodeParser.parse('[text]');
|
||||
|
||||
expect(ast).toMatchObject(['[text]']);
|
||||
});
|
||||
|
||||
test('non text in bbcode tag', () => {
|
||||
const ast = bbcodeParser.parse('[url][/url]');
|
||||
|
||||
expect(ast).toMatchObject(['[url]']);
|
||||
});
|
||||
|
||||
test('space char in bbcode tag', () => {
|
||||
const ast = bbcodeParser.parse('[url] [/url]');
|
||||
|
||||
expect(ast).toMatchObject([
|
||||
{
|
||||
tag: 'url',
|
||||
attrs: {},
|
||||
content: [' '],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('tag url', () => {
|
||||
test('with plain text', () => {
|
||||
const ast = bbcodeParser.parse('[url]http://baidu.com[/url]');
|
||||
|
||||
expect(ast).toMatchObject([
|
||||
{
|
||||
tag: 'url',
|
||||
attrs: {},
|
||||
content: ['http://baidu.com'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('with custom text', () => {
|
||||
const ast = bbcodeParser.parse('[url=http://baidu.com]a[/url]');
|
||||
|
||||
expect(ast).toMatchObject([
|
||||
{
|
||||
tag: 'url',
|
||||
attrs: { url: 'http://baidu.com' },
|
||||
content: ['a'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('with space', () => {
|
||||
const ast = bbcodeParser.parse(
|
||||
'[at=6251986eab331ca2efbba9c6]Notify Bot[/at] 123123'
|
||||
);
|
||||
|
||||
expect(ast).toMatchObject([
|
||||
{
|
||||
tag: 'at',
|
||||
attrs: { at: '6251986eab331ca2efbba9c6' },
|
||||
content: ['Notify', ' ', 'Bot'],
|
||||
},
|
||||
' ',
|
||||
'123123',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { bbcodeToPlainText } from '../serialize';
|
||||
|
||||
describe('bbcodeToPlainText should be ok', () => {
|
||||
test.each([
|
||||
['normal', 'normal'],
|
||||
['with space', 'with space'],
|
||||
['image [img]http://image.url[/img]', 'image [图片]'],
|
||||
['url [url]http://link.url[/url]', 'url http://link.url'],
|
||||
['url2 [url=http://baidu.com]a[/url]', 'url2 a'],
|
||||
['at [at=uuid]name[/at]', 'at @name'],
|
||||
['[emoji]smile[/emoji]', ':smile:'],
|
||||
])('%s', (input, output) => {
|
||||
const plain = bbcodeToPlainText(input);
|
||||
|
||||
expect(output).toBe(plain);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AstNodeObj } from '../type';
|
||||
import { getUrlTagRealUrl } from '../utils';
|
||||
|
||||
describe('getUrlTagRealUrl', () => {
|
||||
test.each([
|
||||
[
|
||||
{
|
||||
tag: 'url',
|
||||
attrs: { url: 'https://baidu.com' },
|
||||
content: ['百度'],
|
||||
},
|
||||
'https://baidu.com',
|
||||
],
|
||||
[
|
||||
{
|
||||
tag: 'url',
|
||||
attrs: {},
|
||||
content: ['https://baidu.com'],
|
||||
},
|
||||
'https://baidu.com',
|
||||
],
|
||||
])('%o => %s', (input: AstNodeObj, output) => {
|
||||
expect(getUrlTagRealUrl(input)).toBe(output);
|
||||
});
|
||||
});
|
||||
31
client/web/plugins/com.msgbyte.bbcode/src/bbcode/index.tsx
Normal file
31
client/web/plugins/com.msgbyte.bbcode/src/bbcode/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import bbcodeParser from './parser';
|
||||
import urlRegex from 'url-regex';
|
||||
|
||||
/**
|
||||
* 客户端预处理文本
|
||||
* @param plainText 服务端文本
|
||||
*/
|
||||
export function preProcessLinkText(plainText: string): string {
|
||||
const text = plainText.replace(
|
||||
urlRegex({ exact: false, strict: true }),
|
||||
'[url]$&[/url]'
|
||||
); // 将聊天记录中的url提取成bbcode 需要过滤掉被bbcode包住的部分
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
// 处理所有的预处理文本
|
||||
export function preProcessText(plainText: string): string {
|
||||
return bbcodeParser.preProcessText(plainText, preProcessLinkText);
|
||||
}
|
||||
|
||||
interface BBCodeProps {
|
||||
plainText: string;
|
||||
}
|
||||
export const BBCode: React.FC<BBCodeProps> = React.memo(({ plainText }) => {
|
||||
const bbcodeComponent = bbcodeParser.render(preProcessText(plainText ?? ''));
|
||||
|
||||
return <Fragment>{bbcodeComponent}</Fragment>;
|
||||
});
|
||||
BBCode.displayName = 'BBCode';
|
||||
176
client/web/plugins/com.msgbyte.bbcode/src/bbcode/parser.tsx
Normal file
176
client/web/plugins/com.msgbyte.bbcode/src/bbcode/parser.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { ComponentType, ReactNode } from 'react';
|
||||
import type { TagProps, AstNode } from './type';
|
||||
import { parse } from '@bbob/parser';
|
||||
import _last from 'lodash/last';
|
||||
import _set from 'lodash/set';
|
||||
import _get from 'lodash/get';
|
||||
import _has from 'lodash/has';
|
||||
import _isObject from 'lodash/isObject';
|
||||
import _isArray from 'lodash/isArray';
|
||||
import _isEmpty from 'lodash/isEmpty';
|
||||
import _mapKeys from 'lodash/mapKeys';
|
||||
import _toPairs from 'lodash/toPairs';
|
||||
|
||||
/**
|
||||
* 通用的bbcode解释器
|
||||
* 一个纯语言实现
|
||||
*/
|
||||
|
||||
type StringTagComponent = ComponentType<{ children?: string }> | string;
|
||||
type ObjectTagComponent = ComponentType<TagProps>;
|
||||
type TagMapComponent = StringTagComponent | ObjectTagComponent;
|
||||
|
||||
const tagMap: { [tag: string]: TagMapComponent } = {};
|
||||
|
||||
/**
|
||||
* 注册一个组件到内部的tagMap中
|
||||
* @param tagName 标签名
|
||||
* @param component 组件
|
||||
*/
|
||||
export const registerBBCodeTag = (
|
||||
tagName: string,
|
||||
component: TagMapComponent
|
||||
) => {
|
||||
tagMap[tagName] = component;
|
||||
};
|
||||
|
||||
const DefaultBBCodeComponent: React.FC<TagProps> = React.memo((props) => {
|
||||
if (_has(tagMap, '_text')) {
|
||||
const Component = tagMap['_text'] as StringTagComponent;
|
||||
return <Component>{props.node.content.join('')}</Component>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
DefaultBBCodeComponent.displayName = 'DefaultBBCodeComponent';
|
||||
|
||||
/**
|
||||
* 获取BBCode标签组件
|
||||
*/
|
||||
export const getBBCodeTag = (tagName: string): TagMapComponent => {
|
||||
return tagMap[tagName] ?? DefaultBBCodeComponent;
|
||||
};
|
||||
|
||||
/**
|
||||
* BBCode 解析器
|
||||
*/
|
||||
class BBCodeParser {
|
||||
options = {
|
||||
onlyAllowTags: Object.keys(tagMap),
|
||||
onError: (err: any) => {
|
||||
console.warn(err.message, err.lineNumber, err.columnNumber);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 将文本中没有被bbcode标签包裹住的部分进行预处理后重新拼装成bbcode字符串
|
||||
*/
|
||||
preProcessText(input: string, processFn: (text: string) => string): string {
|
||||
const ast = parse(input, this.options) as AstNode[];
|
||||
|
||||
return ast
|
||||
.map((node, index) => {
|
||||
if (typeof node === 'string') {
|
||||
// 此处进行预处理
|
||||
const text = node;
|
||||
return processFn(text);
|
||||
}
|
||||
|
||||
const { tag, content, attrs } = node;
|
||||
const attrsStr = _toPairs(attrs)
|
||||
.map(([key, value]) => {
|
||||
/**
|
||||
* 增加双引号以解决value中可能会出现空格的问题
|
||||
*/
|
||||
if (key === value) {
|
||||
return `="${value}"`;
|
||||
} else {
|
||||
return ` ${key}="${value}"`;
|
||||
}
|
||||
})
|
||||
// NOTICE: 这里排序看起来好像有问题,但是attrs的顺序是有序的,所以没有问题
|
||||
.join('');
|
||||
return `[${tag}${attrsStr}]${content.join('')}[/${tag}]`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 将bbcode字符串转化为AstNode
|
||||
parse(input: string): AstNode[] {
|
||||
try {
|
||||
return parse(input, this.options).map((node: AstNode) => {
|
||||
if (_isObject(node)) {
|
||||
const content = _get(node, 'content');
|
||||
const attrs = _get(node, 'attrs');
|
||||
|
||||
if (_isEmpty(attrs) && _isArray(content) && content.length === 0) {
|
||||
// 如果是[text]这种格式的话会被误解析成一个节点
|
||||
// 做一下特殊处理
|
||||
// NOTICE: 这种处理方式会将[url][/url]解析成字符串[url]
|
||||
// 最好的解决方案是自己写一个BBCode的词法解释器
|
||||
const tag = _get(node, 'tag');
|
||||
if (typeof tag === 'string') {
|
||||
return `[${tag}]`;
|
||||
}
|
||||
}
|
||||
|
||||
// 将[url=http://baidu.com] 解析出的attrs: { 'http://baidu.com': 'http://baidu.com' }
|
||||
// 转换为attrs: { 'url': 'http://baidu.com' }
|
||||
_set(
|
||||
node,
|
||||
'attrs',
|
||||
_mapKeys(attrs, (value, key) => {
|
||||
if (value === key) {
|
||||
return node.tag;
|
||||
} else {
|
||||
return key;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
render(input: string): ReactNode[] {
|
||||
const ast = this.parse(input);
|
||||
|
||||
return ast
|
||||
.reduce<AstNode[]>((prev, curr) => {
|
||||
if (typeof curr === 'string' && typeof _last(prev) === 'string') {
|
||||
// 合并字符串, 使其渲染时能公用一个Text组件
|
||||
prev[prev.length - 1] += curr;
|
||||
} else {
|
||||
prev.push(curr);
|
||||
}
|
||||
|
||||
return prev;
|
||||
}, [])
|
||||
.map<ReactNode>((node, index) => {
|
||||
if (typeof node === 'string') {
|
||||
if (_has(tagMap, '_text')) {
|
||||
const Component = tagMap['_text'] as StringTagComponent;
|
||||
return <Component key={index}>{node}</Component>;
|
||||
} else {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof node === 'object') {
|
||||
const Component = getBBCodeTag(node.tag);
|
||||
return <Component key={index} node={node} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bbcodeParser = new BBCodeParser();
|
||||
|
||||
export default bbcodeParser;
|
||||
@@ -0,0 +1,36 @@
|
||||
import bbcodeParser from './parser';
|
||||
import type { AstNode } from './type';
|
||||
import _isNil from 'lodash/isNil';
|
||||
|
||||
function bbcodeNodeToPlainText(node: AstNode): string {
|
||||
if (_isNil(node)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof node === 'string') {
|
||||
return String(node);
|
||||
} else {
|
||||
if (node.tag === 'img') {
|
||||
return '[图片]';
|
||||
}
|
||||
if (node.tag === 'emoji') {
|
||||
return `:${node.content.join('')}:`;
|
||||
}
|
||||
if (node.tag === 'at') {
|
||||
return `@${node.content.join('')}`;
|
||||
}
|
||||
|
||||
return (node.content ?? [])
|
||||
.map((sub) => bbcodeNodeToPlainText(sub))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 BBCode 转化为普通的字符串
|
||||
*/
|
||||
export function bbcodeToPlainText(bbcode: string): string {
|
||||
const ast = bbcodeParser.parse(bbcode);
|
||||
|
||||
return ast.map(bbcodeNodeToPlainText).join('');
|
||||
}
|
||||
13
client/web/plugins/com.msgbyte.bbcode/src/bbcode/type.ts
Normal file
13
client/web/plugins/com.msgbyte.bbcode/src/bbcode/type.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type AstNode = AstNodeObj | AstNodeStr;
|
||||
|
||||
export type AstNodeObj = {
|
||||
tag: string;
|
||||
attrs: Record<string, string>;
|
||||
content: AstNode[];
|
||||
};
|
||||
|
||||
export type AstNodeStr = string;
|
||||
|
||||
export interface TagProps {
|
||||
node: AstNodeObj;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AstNodeObj } from './type';
|
||||
|
||||
/**
|
||||
* 获取 url 节点的url地址
|
||||
* @param urlTag url节点
|
||||
*/
|
||||
export function getUrlTagRealUrl(urlTag: AstNodeObj): string {
|
||||
return urlTag.attrs.url ?? urlTag.content.join('');
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import CodeRender from 'react-highlight';
|
||||
|
||||
import 'highlight.js/styles/default.css';
|
||||
|
||||
const Highlight: React.FC<{
|
||||
language: string;
|
||||
code: string;
|
||||
}> = React.memo((props) => {
|
||||
const CodeComponent = (CodeRender as any).default as typeof CodeRender; // TODO: ministar编译问题,先跳过
|
||||
return <CodeComponent className={props.language}>{props.code}</CodeComponent>;
|
||||
});
|
||||
Highlight.displayName = 'Highlight';
|
||||
|
||||
export default Highlight;
|
||||
47
client/web/plugins/com.msgbyte.bbcode/src/index.tsx
Normal file
47
client/web/plugins/com.msgbyte.bbcode/src/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Loadable,
|
||||
regMessageRender,
|
||||
regMessageTextDecorators,
|
||||
} from '@capital/common';
|
||||
|
||||
const PLUGIN_ID = 'com.msgbyte.bbcode';
|
||||
|
||||
// 预加载
|
||||
import('./render');
|
||||
|
||||
const BBCode = Loadable(() => import('./render'), {
|
||||
componentName: `${PLUGIN_ID}:renderComponent`,
|
||||
fallback: null,
|
||||
});
|
||||
let serialize: (bbcode: string) => string;
|
||||
import('./bbcode/serialize').then((module) => {
|
||||
serialize = module.bbcodeToPlainText;
|
||||
});
|
||||
|
||||
regMessageRender((message) => {
|
||||
return <BBCode plainText={message} />;
|
||||
});
|
||||
|
||||
regMessageTextDecorators(() => ({
|
||||
url: (url, label?) =>
|
||||
label ? `[url=${url}]${label}[/url]` : `[url]${url}[/url]`,
|
||||
image: (plain, attrs) => {
|
||||
if (attrs.height && attrs.width) {
|
||||
return `[img height=${attrs.height} width=${attrs.width}]${plain}[/img]`;
|
||||
}
|
||||
|
||||
return `[img]${plain}[/img]`;
|
||||
},
|
||||
card: (plain, attrs) => {
|
||||
const h = [
|
||||
'card',
|
||||
...Object.entries(attrs).map(([k, v]) => `${k}=${v}`),
|
||||
].join(' ');
|
||||
|
||||
return `[${h}]${plain}[/card]`;
|
||||
},
|
||||
mention: (userId, userName) => `[at=${userId}]${userName}[/at]`,
|
||||
emoji: (emojiCode) => `[emoji]${emojiCode}[/emoji]`,
|
||||
serialize: (plain: string) => (serialize ? serialize(plain) : plain),
|
||||
}));
|
||||
4
client/web/plugins/com.msgbyte.bbcode/src/render.ts
Normal file
4
client/web/plugins/com.msgbyte.bbcode/src/render.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { BBCode } from './bbcode';
|
||||
import './tags/__all__';
|
||||
|
||||
export default BBCode;
|
||||
10
client/web/plugins/com.msgbyte.bbcode/src/tags/BoldTag.tsx
Normal file
10
client/web/plugins/com.msgbyte.bbcode/src/tags/BoldTag.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const BoldTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const text = node.content.join('');
|
||||
|
||||
return <b>{text}</b>;
|
||||
});
|
||||
BoldTag.displayName = 'BoldTag';
|
||||
17
client/web/plugins/com.msgbyte.bbcode/src/tags/CardTag.tsx
Normal file
17
client/web/plugins/com.msgbyte.bbcode/src/tags/CardTag.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Card } from '@capital/component';
|
||||
import React from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const CardTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const label = node.content.join('');
|
||||
const attrs = node.attrs ?? {};
|
||||
|
||||
const payload: any = {
|
||||
label,
|
||||
...attrs,
|
||||
};
|
||||
|
||||
return <Card type={payload.type} payload={payload} />;
|
||||
});
|
||||
CardTag.displayName = 'CardTag';
|
||||
14
client/web/plugins/com.msgbyte.bbcode/src/tags/CodeTag.tsx
Normal file
14
client/web/plugins/com.msgbyte.bbcode/src/tags/CodeTag.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Loadable } from '@capital/common';
|
||||
import React from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
const Highlight = Loadable(() => import('../components/Highlight'));
|
||||
|
||||
export const CodeTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const text = node.content.join('');
|
||||
const language = node.attrs.language ?? 'bash';
|
||||
|
||||
return <Highlight language={language} code={text} />;
|
||||
});
|
||||
CodeTag.displayName = 'CodeTag';
|
||||
10
client/web/plugins/com.msgbyte.bbcode/src/tags/DeleteTag.tsx
Normal file
10
client/web/plugins/com.msgbyte.bbcode/src/tags/DeleteTag.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const DeleteTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const text = node.content.join('');
|
||||
|
||||
return <del>{text}</del>;
|
||||
});
|
||||
DeleteTag.displayName = 'DeleteTag';
|
||||
11
client/web/plugins/com.msgbyte.bbcode/src/tags/EmojiTag.tsx
Normal file
11
client/web/plugins/com.msgbyte.bbcode/src/tags/EmojiTag.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Emoji } from '@capital/component';
|
||||
import React from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const EmojiTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const code = node.content.join('');
|
||||
|
||||
return <Emoji emoji={code} />;
|
||||
});
|
||||
EmojiTag.displayName = 'EmojiTag';
|
||||
62
client/web/plugins/com.msgbyte.bbcode/src/tags/ImgTag.tsx
Normal file
62
client/web/plugins/com.msgbyte.bbcode/src/tags/ImgTag.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { getPopupContainer } from '@capital/common';
|
||||
import { Image } from '@capital/component';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
const MAX_HEIGHT = 320;
|
||||
const MAX_WIDTH = 320;
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
maxHeight: MAX_HEIGHT,
|
||||
maxWidth: MAX_WIDTH,
|
||||
width: 'auto',
|
||||
};
|
||||
|
||||
function parseImageAttr(attr: { height: string; width: string }): {
|
||||
height?: number;
|
||||
width?: number;
|
||||
} {
|
||||
const height = Number(attr.height);
|
||||
const width = Number(attr.width);
|
||||
|
||||
if (!(height > 0 && width > 0)) {
|
||||
// 确保宽高为数字且均大于0
|
||||
return {};
|
||||
}
|
||||
|
||||
if (height <= MAX_HEIGHT && width <= MAX_WIDTH) {
|
||||
return {
|
||||
height,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
const ratio = Math.max(height / MAX_HEIGHT, width / MAX_WIDTH);
|
||||
|
||||
return {
|
||||
height: height / ratio,
|
||||
width: width / ratio,
|
||||
};
|
||||
}
|
||||
|
||||
export const ImgTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const text = node.content.join('');
|
||||
const url = node.attrs.url ?? text;
|
||||
|
||||
return (
|
||||
<div className="inline-block" onContextMenu={(e) => e.stopPropagation()}>
|
||||
<Image
|
||||
style={{
|
||||
...imageStyle,
|
||||
...parseImageAttr(node.attrs as any),
|
||||
}}
|
||||
preview={{
|
||||
getContainer: getPopupContainer,
|
||||
}}
|
||||
src={url}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ImgTag.displayName = 'ImgTag';
|
||||
10
client/web/plugins/com.msgbyte.bbcode/src/tags/ItalicTag.tsx
Normal file
10
client/web/plugins/com.msgbyte.bbcode/src/tags/ItalicTag.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const ItalicTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const text = node.content.join('');
|
||||
|
||||
return <i>{text}</i>;
|
||||
});
|
||||
ItalicTag.displayName = 'ItalicTag';
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Markdown } from '@capital/component';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const MarkdownTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const text = node.content.join('');
|
||||
|
||||
return <Markdown raw={text} />;
|
||||
});
|
||||
MarkdownTag.displayName = 'MarkdownTag';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { UserName } from '@capital/component';
|
||||
import React from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const MentionTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const fallbackName = node.content.join('');
|
||||
const userId = node.attrs.at;
|
||||
|
||||
return (
|
||||
<span className="plugin-bbcode-mention-tag" data-userid={userId}>
|
||||
@{<UserName userId={userId} fallbackName={fallbackName} />}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
MentionTag.displayName = 'MentionTag';
|
||||
11
client/web/plugins/com.msgbyte.bbcode/src/tags/PlainText.tsx
Normal file
11
client/web/plugins/com.msgbyte.bbcode/src/tags/PlainText.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const PlainText: React.FC<PropsWithChildren<TagProps>> = React.memo(
|
||||
(props) => (
|
||||
<pre style={{ display: 'inline', whiteSpace: 'break-spaces' }}>
|
||||
{props.children}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
PlainText.displayName = 'PlainText';
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
export const UnderlinedTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const text = node.content.join('');
|
||||
|
||||
return <ins>{text}</ins>;
|
||||
});
|
||||
UnderlinedTag.displayName = 'UnderlinedTag';
|
||||
49
client/web/plugins/com.msgbyte.bbcode/src/tags/UrlTag.tsx
Normal file
49
client/web/plugins/com.msgbyte.bbcode/src/tags/UrlTag.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Link } from '@capital/component';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { TagProps } from '../bbcode/type';
|
||||
|
||||
const UnderlineSpan = styled.span`
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
`;
|
||||
|
||||
export const UrlTag: React.FC<TagProps> = React.memo((props) => {
|
||||
const { node } = props;
|
||||
const text = node.content.join('');
|
||||
const url = node.attrs.url ?? text;
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
// 内部地址,使用 react-router 进行导航
|
||||
return (
|
||||
<Link to={url} onContextMenu={(e) => e.stopPropagation()}>
|
||||
<UnderlineSpan>{text}</UnderlineSpan>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (url.startsWith(window.location.origin)) {
|
||||
// 内部地址,使用 react-router 进行导航
|
||||
return (
|
||||
<Link
|
||||
to={url.replace(window.location.origin, '')}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
<UnderlineSpan>{text}</UnderlineSpan>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
title={text}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
<UnderlineSpan>{text}</UnderlineSpan>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
UrlTag.displayName = 'UrlTag';
|
||||
32
client/web/plugins/com.msgbyte.bbcode/src/tags/__all__.ts
Normal file
32
client/web/plugins/com.msgbyte.bbcode/src/tags/__all__.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { registerBBCodeTag } from '../bbcode/parser';
|
||||
import { CodeTag } from './CodeTag';
|
||||
import { ImgTag } from './ImgTag';
|
||||
import { MentionTag } from './MentionTag';
|
||||
import { PlainText } from './PlainText';
|
||||
import { UrlTag } from './UrlTag';
|
||||
import { EmojiTag } from './EmojiTag';
|
||||
import { MarkdownTag } from './MarkdownTag';
|
||||
import { BoldTag } from './BoldTag';
|
||||
import { ItalicTag } from './ItalicTag';
|
||||
import { UnderlinedTag } from './UnderlinedTag';
|
||||
import { DeleteTag } from './DeleteTag';
|
||||
import { CardTag } from './CardTag';
|
||||
|
||||
import './styles.less';
|
||||
|
||||
/**
|
||||
* Reference: https://en.wikipedia.org/wiki/BBCode
|
||||
*/
|
||||
registerBBCodeTag('_text', PlainText);
|
||||
registerBBCodeTag('b', BoldTag);
|
||||
registerBBCodeTag('i', ItalicTag);
|
||||
registerBBCodeTag('u', UnderlinedTag);
|
||||
registerBBCodeTag('s', DeleteTag);
|
||||
registerBBCodeTag('url', UrlTag);
|
||||
registerBBCodeTag('img', ImgTag);
|
||||
registerBBCodeTag('code', CodeTag);
|
||||
registerBBCodeTag('at', MentionTag);
|
||||
registerBBCodeTag('emoji', EmojiTag);
|
||||
registerBBCodeTag('markdown', MarkdownTag);
|
||||
registerBBCodeTag('md', MarkdownTag); // alias
|
||||
registerBBCodeTag('card', CardTag);
|
||||
@@ -0,0 +1,9 @@
|
||||
.plugin-bbcode-mention-tag {
|
||||
background-color: rgba(88, 101, 242, 0.3);
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(88, 101, 242);
|
||||
}
|
||||
}
|
||||
10
client/web/plugins/com.msgbyte.bbcode/tsconfig.json
Normal file
10
client/web/plugins/com.msgbyte.bbcode/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"paths": {
|
||||
"@capital/*": ["../../src/plugin/*"],
|
||||
}
|
||||
}
|
||||
}
|
||||
1
client/web/plugins/com.msgbyte.bbcode/types/index.d.ts
vendored
Normal file
1
client/web/plugins/com.msgbyte.bbcode/types/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '@bbob/parser';
|
||||
Reference in New Issue
Block a user