feat: support qrcode embed in markdown and scan in mobile (#4638)

* feat: workbench block

* feat: mobilePage

* fix: update WorkbenchAction

* feat: support qrcode embed in markdown and scan in mobile

* fix: fix markdown button be covered problem

* fix: fix unit test error

* fix: fix unit test errors

* refactor: use react router in qrcode scanner

* feat: markdown add loading

* fix: fix blank content in print page

* refactor: change plugin dependencies to devDependencies

* feat: add some padding in markdown editor

* chore: improve some code

* feat: improve code

* fix: add QRCodeScanner

* fix: iconColor

* fix: Improve code

* feat: Improve code

* fix: version

* chore: improve some code

* chore: improve some code

* fix: i18n

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Sheldon Guo 2024-06-12 17:47:43 +08:00 committed by GitHub
parent 2aa46171b2
commit 45614c8d72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 288 additions and 37 deletions

View File

@ -2,9 +2,7 @@
"version": "1.2.1-alpha",
"npmClient": "yarn",
"useWorkspaces": true,
"npmClientArgs": [
"--ignore-engines"
],
"npmClientArgs": ["--ignore-engines"],
"command": {
"version": {
"forcePublish": true,

View File

@ -9,36 +9,83 @@
import { observer, useField, useFieldSchema } from '@formily/react';
import { Input as AntdInput, Button, Space, Spin, theme } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import cls from 'classnames';
import React, { useState } from 'react';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGlobalTheme } from '../../../global-theme';
import { useDesignable } from '../../hooks/useDesignable';
import { MarkdownVoidDesigner } from './Markdown.Void.Designer';
import { useStyles } from './style';
import { useParseMarkdown } from './util';
import { parseMarkdown } from './util';
import { TextAreaProps } from 'antd/es/input';
import { useBlockHeight } from '../../hooks/useBlockSize';
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { useCollectionRecord } from '../../../data-source';
import { useVariableOptions } from '../../../schema-settings/VariableInput/hooks/useVariableOptions';
import { VariableSelect } from '../variable/VariableSelect';
import { replaceVariableValue } from '../../../block-provider/hooks';
import { useLocalVariables, useVariables } from '../../../variables';
import { registerQrcodeWebComponent } from './qrcode-webcom';
export interface MarkdownEditorProps extends Omit<TextAreaProps, 'onSubmit'> {
scope: any[];
defaultValue?: string;
onSubmit?: (value: string) => void;
onCancel?: (e: React.MouseEvent) => void;
}
const MarkdownEditor = (props: MarkdownEditorProps) => {
const { scope } = props;
const { t } = useTranslation();
const [value, setValue] = useState(props.defaultValue);
const inputRef = useRef<TextAreaRef>(null);
const [options, setOptions] = useState([]);
const [curPos, setCurPos] = useState(null);
useEffect(() => {
setOptions(scope);
}, [scope]);
useEffect(() => {
const inputEle = inputRef?.current?.resizableTextArea?.textArea;
if (curPos && inputEle) {
inputEle.setSelectionRange(curPos, curPos);
}
}, [curPos]);
const onInsert = useCallback(
function (paths: string[]) {
const variable: string[] = paths.filter((key) => Boolean(key.trim()));
const { current } = inputRef;
const inputEle = current?.resizableTextArea?.textArea;
if (!inputEle || !variable) {
return;
}
current.focus();
const templateVar = `{{${paths.join('.')}}}`;
const startPos = inputEle.selectionStart || 0;
const newVal = value.substring(0, startPos) + templateVar + value.substring(startPos, value.length);
const newPos = startPos + templateVar.length;
setValue(newVal);
setCurPos(newPos);
},
[value],
);
return (
<div className={'mb-markdown'} style={{ position: 'relative' }}>
<div className={'mb-markdown'} style={{ position: 'relative', paddingTop: '20px' }}>
<AntdInput.TextArea
ref={inputRef}
autoSize={{ minRows: 3 }}
{...(props as any)}
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
style={{ paddingBottom: '40px' }}
/>
<div style={{ position: 'absolute', top: 21, right: 1 }}>
<VariableSelect options={options} setOptions={setOptions} onInsert={onInsert} />
</div>
<Space style={{ position: 'absolute', bottom: 5, right: 5 }}>
<Button
onClick={(e) => {
@ -69,22 +116,46 @@ const useMarkdownHeight = () => {
return height - 2 * token.paddingLG;
};
export const MarkdownVoid: any = observer(
(props: any) => {
export const MarkdownVoid: any = withDynamicSchemaProps(
observer((props: any) => {
const { isDarkTheme } = useGlobalTheme();
const { componentCls, hashId } = useStyles({ isDarkTheme });
const { content, className } = props;
const field = useField();
const schema = useFieldSchema();
const { dn } = useDesignable();
const { onSave, onCancel } = props;
const { html, loading } = useParseMarkdown(content);
const { onSave, onCancel, form } = props;
const record = useCollectionRecord();
const [html, setHtml] = useState('');
const variables = useVariables();
const localVariables = useLocalVariables();
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
const cvtContentToHTML = async () => {
const replacedContent = await replaceVariableValue(content, variables, localVariables);
const html = await parseMarkdown(replacedContent);
setHtml(html);
setLoading(false);
};
cvtContentToHTML();
}, [content, variables, localVariables]);
const height = useMarkdownHeight();
if (loading) {
return <Spin />;
}
const scope = useVariableOptions({
collectionField: { uiSchema: schema },
form,
record,
uiSchema: schema,
noDisabled: true,
});
useEffect(() => {
registerQrcodeWebComponent();
}, []);
if (loading) return <Spin />;
return field?.editable ? (
<MarkdownEditor
scope={scope}
{...props}
className
defaultValue={content}
@ -115,7 +186,7 @@ export const MarkdownVoid: any = observer(
dangerouslySetInnerHTML={{ __html: html }}
/>
);
},
}),
{ displayName: 'MarkdownVoid' },
);

View File

@ -11,10 +11,15 @@ import { act, fireEvent, render } from '@nocobase/test/client';
import React from 'react';
import App1 from '../demos/demo1';
import App2 from '../demos/demo2';
import { MemoryRouter } from 'react-router-dom';
describe('Markdown', () => {
it('should display the value of user input', () => {
const { container } = render(<App1 />);
it('should display the value of user input', function () {
const { container } = render(
<MemoryRouter>
<App1 />
</MemoryRouter>,
);
const textarea = container.querySelector('.ant-input') as HTMLTextAreaElement;
act(() => {
fireEvent.change(textarea, { target: { value: '## Hello World' } });
@ -23,9 +28,13 @@ describe('Markdown', () => {
});
});
describe('Markdown.Void', () => {
describe('Markdown.Void', function () {
it('should display the value of user input', async () => {
const { container } = render(<App2 />);
const { container } = render(
<MemoryRouter>
<App2 />
</MemoryRouter>,
);
const button = container.querySelector('.ant-btn') as HTMLButtonElement;
expect(button).not.toBeNull();

View File

@ -0,0 +1,59 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { QRCode, type QRCodeProps } from 'antd';
import { createRoot } from 'react-dom/client';
// ReactDOM.render(
// <QRCodeCanvas value="https://reactjs.org/" />,
// document.getElementById('mountNode')
// );
class QRCodeWebComponent extends HTMLElement {
root: any;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.root = null;
}
getProps(attributes, propTypes = {}) {
return [...attributes]
.filter((attr) => attr.name !== 'style')
.map((attr) => this.convert(propTypes, attr.name, attr.value))
.reduce((props, prop) => ({ ...props, [prop.name]: prop.value }), {});
}
convert(propTypes, attrName, attrValue) {
const propName = Object.keys(propTypes).find((key) => key.toLowerCase() == attrName);
let value = attrValue;
if (attrValue === 'true' || attrValue === 'false') value = attrValue == 'true';
else if (!isNaN(attrValue) && attrValue !== '') value = +attrValue;
else if (/^{.*}/.exec(attrValue)) value = JSON.parse(attrValue);
return {
name: propName ? propName : attrName,
value: value,
};
}
connectedCallback() {
const props = {
...this.getProps(this.attributes),
} as QRCodeProps;
this.root = createRoot(this.shadowRoot as ShadowRoot);
this.root.render(<QRCode {...props} />);
}
disconnectedCallback() {
this.root.unmount();
}
}
export function registerQrcodeWebComponent() {
if (!customElements.get('qr-code')) {
customElements.define('qr-code', QRCodeWebComponent);
}
}

View File

@ -19,7 +19,7 @@ export const useDetailPrintActionProps = () => {
* {
margin: 0;
}
:not(.ant-formily-item-control-content-component) > div.ant-formily-layout>div:first-child {
:not(.ant-formily-item-control-content-component) > div.ant-formily-layout div.nb-action-bar { {
overflow: hidden; height: 0;
}
}`,

View File

@ -6,5 +6,9 @@
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x"
},
"devDependencies": {
"html5-qrcode": "^2.3.8",
"react-router-dom": "^6.x"
}
}

View File

@ -7,7 +7,14 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ButtonEditor, SchemaSettings, useSchemaInitializer, useSchemaInitializerItem } from '@nocobase/client';
import {
ButtonEditor,
ISchema,
SchemaSettings,
useActionContext,
useSchemaInitializer,
useSchemaInitializerItem,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ModalActionSchemaInitializerItem } from './ModalActionSchemaInitializerItem';
@ -38,11 +45,20 @@ export function WorkbenchScanActionSchemaInitializerItem(props) {
// 调用插入功能
const { insert } = useSchemaInitializer();
const { t } = useTranslation();
const useCancelAction = () => {
const { setVisible } = useActionContext();
return {
run() {
setVisible(false);
},
};
};
return (
<ModalActionSchemaInitializerItem
title={itemConfig.title}
title={t('Scan QR code', { ns: 'block-workbench' })}
modalSchema={{
title: 'Add Scan Qr code',
title: t('Scan QR code', { ns: 'block-workbench' }),
properties: {
title: {
title: t('Title'),
@ -66,7 +82,6 @@ export function WorkbenchScanActionSchemaInitializerItem(props) {
},
}}
onSubmit={(values) => {
console.log('values', values);
insert({
type: 'void',
title: values.title,
@ -76,8 +91,41 @@ export function WorkbenchScanActionSchemaInitializerItem(props) {
'x-component-props': {
icon: values.icon,
iconColor: values.iconColor,
openMode: 'modal',
},
});
properties: {
modal: {
type: 'void',
'x-component': 'Action.Modal',
title: t('Scan QR code', { ns: 'block-workbench' }),
'x-decorator': 'FormV2',
properties: {
scanner: {
'x-component': 'QRCodeScanner',
'x-component-props': {
fps: 10,
qrbox: 250,
disableFlip: false,
},
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
close: {
title: 'Close',
'x-component': 'Action',
'x-component-props': {
type: 'default',
useAction: useCancelAction,
},
},
},
},
},
},
},
} as ISchema);
}}
/>
);

View File

@ -0,0 +1,56 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Html5QrcodeScanner } from 'html5-qrcode';
import { useNavigate } from 'react-router-dom';
import React, { useEffect } from 'react';
const qrcodeRegionId = 'html5qr-code-full-region';
// Creates the configuration object for Html5QrcodeScanner.
const createConfig = (props) => {
const config: any = {};
if (props.fps) {
config.fps = props.fps;
}
if (props.qrbox) {
config.qrbox = props.qrbox;
}
if (props.aspectRatio) {
config.aspectRatio = props.aspectRatio;
}
if (props.disableFlip !== undefined) {
config.disableFlip = props.disableFlip;
}
return config;
};
export const QRCodeScanner = (props) => {
const navigate = useNavigate();
useEffect(() => {
// when component mounts
const config = createConfig(props);
const verbose = props.verbose === true;
// Suceess callback is required.
const qrCodeSuccessCallback = (decodedText, decodedResult) => {
navigate(decodedText);
};
const html5QrcodeScanner = new Html5QrcodeScanner(qrcodeRegionId, config, verbose);
html5QrcodeScanner.render(qrCodeSuccessCallback, props.qrCodeErrorCallback);
// cleanup function when component will unmount
return () => {
html5QrcodeScanner.clear().catch((error) => {
console.error('Failed to clear html5QrcodeScanner. ', error);
});
};
}, [navigate, props]);
return <div id={qrcodeRegionId} />;
};

View File

@ -10,17 +10,18 @@
import { Plugin } from '@nocobase/client';
import { WorkbenchBlock } from './WorkbenchBlock';
import { workbenchActionSettingsLink } from './WorkbenchLinkActionSchemaInitializerItem';
// import {
// WorkbenchScanActionSchemaInitializerItem,
// workbenchActionSettingsScanQrCode,
// } from './WorkbenchScanActionSchemaInitializerItem';
import {
WorkbenchScanActionSchemaInitializerItem,
workbenchActionSettingsScanQrCode,
} from './WorkbenchScanActionSchemaInitializerItem';
import { QRCodeScanner } from './components/qrcode-scanner';
import { workbenchBlockInitializerItem } from './workbenchBlockInitializerItem';
import { workbenchBlockSettings } from './workbenchBlockSettings';
import { workbenchConfigureActions } from './workbenchConfigureActions';
export class PluginBlockWorkbenchClient extends Plugin {
async load() {
this.app.addComponents({ WorkbenchBlock });
this.app.addComponents({ WorkbenchBlock, QRCodeScanner });
// 新增工作台区块的设置器
this.app.schemaSettingsManager.add(workbenchBlockSettings);
@ -46,11 +47,10 @@ export class PluginBlockWorkbenchClient extends Plugin {
this.app.schemaSettingsManager.add(workbenchActionSettingsLink);
// 扫码操作
// this.app.schemaSettingsManager.add(workbenchActionSettingsScanQrCode);
// this.app.schemaInitializerManager.addItem('workbench:configureActions', `qrcode`, {
// title: 'Scan Qr code',
// Component: WorkbenchScanActionSchemaInitializerItem,
// });
this.app.schemaSettingsManager.add(workbenchActionSettingsScanQrCode);
this.app.schemaInitializerManager.addItem('workbench:configureActions', `qrcode`, {
Component: WorkbenchScanActionSchemaInitializerItem,
});
}
}

View File

@ -1,3 +1,4 @@
{
"Workbench": "工作台"
"Workbench": "工作台",
"Scan QR code": "扫描二维码"
}

View File

@ -19605,6 +19605,11 @@ html2sketch@^1.0.2:
transformation-matrix "^2.11.1"
uuid "^8.2.0"
html5-qrcode@^2.3.8:
version "2.3.8"
resolved "https://registry.npmmirror.com/html5-qrcode/-/html5-qrcode-2.3.8.tgz#0b0cdf7a9926cfd4be530e13a51db47592adfa0d"
integrity sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==
htmlparser2@^6.1.0:
version "6.1.0"
resolved "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
@ -29059,7 +29064,7 @@ react-refresh@0.14.0, react-refresh@^0.14.0:
resolved "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"
integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==
react-router-dom@6.3.0, react-router-dom@6.x, react-router-dom@^6.11.2:
react-router-dom@6.3.0, react-router-dom@6.x, react-router-dom@^6.11.2, react-router-dom@^6.23.1:
version "6.21.0"
resolved "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.21.0.tgz#aa4c6bc046a8e8723095bc09b3c0ab2254532712"
integrity sha512-1dUdVj3cwc1npzJaf23gulB562ESNvxf7E4x8upNJycqyUm5BRRZ6dd3LrlzhtLaMrwOCO8R0zoiYxdaJx4LlQ==