mirror of
https://gitee.com/nocobase/nocobase.git
synced 2024-11-29 18:58:26 +08:00
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:
parent
2aa46171b2
commit
45614c8d72
@ -2,9 +2,7 @@
|
||||
"version": "1.2.1-alpha",
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"npmClientArgs": [
|
||||
"--ignore-engines"
|
||||
],
|
||||
"npmClientArgs": ["--ignore-engines"],
|
||||
"command": {
|
||||
"version": {
|
||||
"forcePublish": true,
|
||||
|
@ -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' },
|
||||
);
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}`,
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -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} />;
|
||||
};
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"Workbench": "工作台"
|
||||
"Workbench": "工作台",
|
||||
"Scan QR code": "扫描二维码"
|
||||
}
|
@ -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==
|
||||
|
Loading…
Reference in New Issue
Block a user