This is an automated email from the ASF dual-hosted git repository. robin0716 pushed a commit to branch feat/1.3.5/embed in repository https://gitbox.apache.org/repos/asf/incubator-answer.git
commit 05d34446e0808e3011c032a05f8d72e374fb298a Author: robin <[email protected]> AuthorDate: Mon May 27 11:18:30 2024 +0800 feat(plugin/embed): add embed plugin --- ui/src/plugins/builtin/EditorEmbed/Component.tsx | 45 ++++ ui/src/plugins/builtin/EditorEmbed/hooks.ts | 227 +++++++++++++++++++++ ui/src/plugins/builtin/EditorEmbed/i18n/en_US.yaml | 10 + ui/src/plugins/builtin/EditorEmbed/i18n/index.ts | 7 + ui/src/plugins/builtin/EditorEmbed/i18n/zh_CN.yaml | 10 + ui/src/plugins/builtin/EditorEmbed/index.ts | 15 ++ ui/src/plugins/builtin/EditorEmbed/modal.tsx | 126 ++++++++++++ ui/src/plugins/builtin/index.ts | 6 +- 8 files changed, 444 insertions(+), 2 deletions(-) diff --git a/ui/src/plugins/builtin/EditorEmbed/Component.tsx b/ui/src/plugins/builtin/EditorEmbed/Component.tsx new file mode 100644 index 00000000..e9ccd147 --- /dev/null +++ b/ui/src/plugins/builtin/EditorEmbed/Component.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { Button } from 'react-bootstrap'; + +import EmbedModal from './modal'; +import { useRenderEmbed } from './hooks'; + +const Component = ({ editor, previewElement }) => { + const [show, setShowState] = useState(false); + + useRenderEmbed(previewElement); + + const handleShow = () => { + setShowState(true); + }; + + const handleConfirm = ({ title, url }) => { + setShowState(false); + // 判断光标是否在行首 + const cursor = editor.getCursor(); + if (cursor.ch !== 0) { + editor.replaceSelection('\n'); + } + const embed = `\n[${title}](${url} "@embed")\n`; + editor.replaceSelection(embed); + editor.focus(); + }; + return ( + <> + <Button + variant="link" + className="p-0 b-0 btn-no-border d-flex justify-content-center align-items-center text-black" + style={{ width: '1.5rem', height: '1.5rem' }} + onClick={handleShow}> + <i className="bi bi-window" /> + </Button> + <EmbedModal + show={show} + setShowState={setShowState} + onConfirm={handleConfirm} + /> + </> + ); +}; + +export default Component; diff --git a/ui/src/plugins/builtin/EditorEmbed/hooks.ts b/ui/src/plugins/builtin/EditorEmbed/hooks.ts new file mode 100644 index 00000000..2e2b05a1 --- /dev/null +++ b/ui/src/plugins/builtin/EditorEmbed/hooks.ts @@ -0,0 +1,227 @@ +import { useEffect, useState } from 'react'; + +interface Config { + platform: string; + enable: boolean; +} +const useRenderEmbed = (element: HTMLElement) => { + const [configs, setConfigs] = useState<Config[] | null>(null); + + const embeds = [ + { + name: 'YouTube', + regexs: [ + /https:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})/, + /https:\/\/www\.youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/, + /https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/, + ], + embed: (videoId: string) => { + return `<iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`; + }, + }, + { + name: 'Twitter', + regexs: [ + /https:\/\/twitter\.com\/[a-zA-Z0-9_]+\/status\/([a-zA-Z0-9_]+)/, + /https:\/\/x\.com\/[a-zA-Z0-9_]+\/status\/([a-zA-Z0-9_]+)/, + ], + embed: (_, url, title = '') => { + const blockquoteElement = document.createElement('blockquote'); + blockquoteElement.classList.add('twitter-tweet'); + + const anchorElement = document.createElement('a'); + anchorElement.href = url.replace('x.com', 'twitter.com'); + + anchorElement.textContent = title; + blockquoteElement.appendChild(anchorElement); + const scriptElement = document.createElement('script'); + scriptElement.src = 'https://platform.twitter.com/widgets.js'; + scriptElement.async = true; + + const styleElement = document.createElement('style'); + styleElement.innerHTML = ` + .twitter-tweet { + display: block; + margin: 0 auto; + } + `; + + return [styleElement, blockquoteElement, scriptElement]; + }, + }, + { + name: 'CodePen', + regexs: [ + /https:\/\/codepen\.io\/[a-zA-Z0-9_]+\/pen\/([a-zA-Z0-9_]+)/, + /https:\/\/codepen\.io\/[a-zA-Z0-9_]+\/full\/([a-zA-Z0-9_]+)/, + ], + embed: (penId) => { + return `<iframe height="265" style="width: 100%;" scrolling="no" title="CodePen Embed" src="https://codepen.io/${penId}/embed/preview/${penId}?height=265&theme-id=0&default-tab=result" frameborder="no" allowtransparency="true" allowfullscreen="true"></iframe>`; + }, + }, + { + name: 'JSFiddle', + regexs: [ + /https:\/\/jsfiddle\.net\/[a-zA-Z0-9_]+\/([a-zA-Z0-9_]+)/, + /https:\/\/jsfiddle\.net\/[a-zA-Z0-9_]+\/([a-zA-Z0-9_]+)\/embed/, + ], + embed: (fiddleId: string) => { + return `<iframe width="100%" height="300" src="https://jsfiddle.net/${fiddleId}/embedded/" allowfullscreen="allowfullscreen" allowpaymentrequest frameborder="0"></iframe>`; + }, + }, + { + name: 'GithubGist', + regexs: [ + /https:\/\/gist\.github\.com\/[a-zA-Z0-9_]+\/([a-zA-Z0-9_]+)/, + /https:\/\/gist\.github\.com\/[a-zA-Z0-9_]+\/([a-zA-Z0-9_]+)\.js/, + ], + embed: (_, url) => { + const scriptUrl = url.indexOf('.js') > -1 ? url : `${url}.js`; + console.log(scriptUrl); + return `<iframe + width="100%" + height="350" + src="data:text/html;charset=utf-8, + <head><base target='_blank' /></head> + <body style='margin:0;'><script src='${scriptUrl}'></script> + </body>">`; + }, + }, + { + name: 'Figma', + regexs: [ + /https:\/\/www\.figma\.com\/design\/[a-zA-Z0-9_]+\/([a-zA-Z0-9_]+)/, + /https:\/\/www\.figma\.com\/file\/[a-zA-Z0-9_]+\/([a-zA-Z0-9_]+)/, + ], + embed: (_, url) => { + return `<iframe style="border: none;" width="100%" height="450 +" src="https://www.figma.com/embed?embed_host=share&url=${url}" allowfullscreen></iframe>`; + }, + }, + { + name: 'Excalidraw', + regexs: [ + /https:\/\/excalidraw\.com\/#json=([a-zA-Z0-9_,-]+)/, + /https:\/\/excalidraw\.com\/([a-zA-Z0-9_,-]+)/, + ], + embed: (excalidrawId: string) => { + return `<iframe width="100%" height="300" src="https://excalidraw.com/${excalidrawId}/embed" frameborder="0"></iframe>`; + }, + }, + { + name: 'Loom', + regexs: [ + /https:\/\/www\.loom\.com\/embed\/([a-zA-Z0-9_]+)/, + /https:\/\/www\.loom\.com\/share\/([a-zA-Z0-9_]+)/, + ], + embed: (loomId: string) => { + return `<iframe width="100%" height="300" src="https://www.loom.com/embed/${loomId}" frameborder="0"></iframe>`; + }, + }, + { + name: 'Dropbox', + regexs: [ + /https:\/\/www\.dropbox\.com\/s\/([a-zA-Z0-9_]+)\/[a-zA-Z0-9_]+/, + ], + embed: (dropboxId: string) => { + return `<iframe width="100%" height="300" src="https://www.dropbox.com/s/${dropboxId}?raw=1" frameborder="0"></iframe>`; + }, + }, + ]; + + const filteredEmbeds = embeds.filter((embed) => { + const finded = configs?.find( + (config) => config.platform === embed.name && config.enable, + ); + return finded; + }); + + const renderEmbed = ( + url: string, + title: string, + ): string | HTMLElement | HTMLElement[] => { + let html: string | HTMLElement | HTMLElement[] = ''; + + filteredEmbeds.forEach((embed) => { + if (html) return; + embed.regexs.forEach((regex) => { + if (html) return; + const match = url.match(regex); + if (match) { + html = embed.embed(match[1], url, title); + } + }); + }); + + return html; + }; + + const render = () => { + if (!element) { + return; + } + + const links = element.querySelectorAll('a'); + links.forEach((link) => { + const url = link.getAttribute('href') || ''; + const title = link.getAttribute('title') || ''; + if (!url) { + return; + } + if (title !== '@embed') { + return; + } + const embed = renderEmbed(url, link?.textContent || ''); + if (embed) { + if (typeof embed === 'string') { + link.innerHTML = embed; + } else if (Array.isArray(embed)) { + link.innerHTML = ''; + embed.forEach((item) => { + link.appendChild(item); + }); + } else { + link.innerHTML = ''; + link.appendChild(embed); + } + } else { + link.innerHTML = ` + <div class="border rounded p-3"> + <div class="text-secondary">${url}</div> + <div class="text-body">${link.textContent}</div> + </div> + `; + } + }); + }; + + const getConfig = () => { + fetch('/answer/api/v1/embed/config') + .then((response) => response.json()) + .then((result) => setConfigs(result.data)); + }; + useEffect(() => { + getConfig(); + }, []); + + useEffect(() => { + if (!element) { + return; + } + + if (!configs) { + return; + } + + render(); + const observer = new MutationObserver(() => { + render(); + }); + + observer.observe(element, { + childList: true, + }); + }, [element, configs]); +}; + +export { useRenderEmbed }; diff --git a/ui/src/plugins/builtin/EditorEmbed/i18n/en_US.yaml b/ui/src/plugins/builtin/EditorEmbed/i18n/en_US.yaml new file mode 100644 index 00000000..94356de3 --- /dev/null +++ b/ui/src/plugins/builtin/EditorEmbed/i18n/en_US.yaml @@ -0,0 +1,10 @@ +plugin: + editor-embed: + header: Add Embed + title: Title + url: URL + cancel: Cancel + add: Add + required_title: Title is required + required_url: URL is required + invalid_url: Invalid URL diff --git a/ui/src/plugins/builtin/EditorEmbed/i18n/index.ts b/ui/src/plugins/builtin/EditorEmbed/i18n/index.ts new file mode 100644 index 00000000..89fd0712 --- /dev/null +++ b/ui/src/plugins/builtin/EditorEmbed/i18n/index.ts @@ -0,0 +1,7 @@ +import en_US from './en_US.yaml'; +import zh_CN from './zh_CN.yaml'; + +export default { + en_US, + zh_CN, +}; diff --git a/ui/src/plugins/builtin/EditorEmbed/i18n/zh_CN.yaml b/ui/src/plugins/builtin/EditorEmbed/i18n/zh_CN.yaml new file mode 100644 index 00000000..cd49eb0e --- /dev/null +++ b/ui/src/plugins/builtin/EditorEmbed/i18n/zh_CN.yaml @@ -0,0 +1,10 @@ +plugin: + editor-embed: + header: 添加嵌入 + title: 标题 + url: URL + cancel: 取消 + add: 添加 + required_title: 标题是必填 + required_url: URL 是必填 + invalid_url: 无效的 URL diff --git a/ui/src/plugins/builtin/EditorEmbed/index.ts b/ui/src/plugins/builtin/EditorEmbed/index.ts new file mode 100644 index 00000000..4583fae3 --- /dev/null +++ b/ui/src/plugins/builtin/EditorEmbed/index.ts @@ -0,0 +1,15 @@ +import Component from './Component'; +import { useRenderEmbed } from './hooks'; +import i18nConfig from './i18n'; + +export default { + info: { + slug_name: 'basic_embed', + type: 'editor', + }, + component: Component, + i18nConfig, + hooks: { + useRender: [useRenderEmbed], + }, +}; diff --git a/ui/src/plugins/builtin/EditorEmbed/modal.tsx b/ui/src/plugins/builtin/EditorEmbed/modal.tsx new file mode 100644 index 00000000..6637cfaf --- /dev/null +++ b/ui/src/plugins/builtin/EditorEmbed/modal.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { Modal, Button, Form } from 'react-bootstrap'; + +const EmbedModal = ({ show, setShowState, onConfirm }) => { + const [title, setTitle] = useState({ + value: '', + isInvalid: false, + errorMsg: '', + }); + const [url, setUrl] = useState({ + value: '', + isInvalid: false, + errorMsg: '', + }); + + const handleHide = () => { + setShowState(false); + }; + + const handleChangeTitle = (e) => { + setTitle({ + value: e.target.value, + isInvalid: false, + errorMsg: '', + }); + }; + + const handleChangeUrl = (e) => { + setUrl({ + value: e.target.value, + isInvalid: false, + errorMsg: '', + }); + }; + + const handleSubmit = () => { + if (!title.value) { + setTitle({ + ...title, + isInvalid: true, + errorMsg: 'Title is required', + }); + return; + } + if (!url.value) { + setUrl({ + ...url, + isInvalid: true, + errorMsg: 'URL is required', + }); + return; + } + const urlRegex = + /^(https?:\/\/)?([\da-z\\.-]+)\.([a-z\\.]{2,6})([\\/\w \\.-]*)*\/?$/; + if (!urlRegex.test(url.value)) { + setUrl({ + ...url, + isInvalid: true, + errorMsg: 'Invalid URL', + }); + return; + } + onConfirm({ + title: title.value, + url: url.value, + }); + setShowState(false); + + setTitle({ + value: '', + isInvalid: false, + errorMsg: '', + }); + + setUrl({ + value: '', + isInvalid: false, + errorMsg: '', + }); + }; + return ( + <Modal show={show} onHide={handleHide}> + <Modal.Header closeButton> + <Modal.Title>Add embed</Modal.Title> + </Modal.Header> + <Modal.Body> + <Form> + <Form.Group className="mb-3" controlId="editor.plugin.embed.title"> + <Form.Label>Title</Form.Label> + <Form.Control + type="text" + value={title.value} + isInvalid={title.isInvalid} + onChange={handleChangeTitle} + /> + <Form.Control.Feedback type="invalid"> + {title.errorMsg} + </Form.Control.Feedback> + </Form.Group> + <Form.Group className="mb-3" controlId="editor.plugin.embed.url"> + <Form.Label>URL</Form.Label> + <Form.Control + type="url" + value={url.value} + isInvalid={url.isInvalid} + onChange={handleChangeUrl} + /> + <Form.Control.Feedback type="invalid"> + {url.errorMsg} + </Form.Control.Feedback> + </Form.Group> + </Form> + </Modal.Body> + <Modal.Footer> + <Button variant="link" onClick={handleHide}> + Cancel + </Button> + <Button variant="primary" onClick={handleSubmit}> + Add + </Button> + </Modal.Footer> + </Modal> + ); +}; + +export default EmbedModal; diff --git a/ui/src/plugins/builtin/index.ts b/ui/src/plugins/builtin/index.ts index eaf6cf39..c907977a 100644 --- a/ui/src/plugins/builtin/index.ts +++ b/ui/src/plugins/builtin/index.ts @@ -19,10 +19,12 @@ import ThirdPartyConnector from './ThirdPartyConnector'; import HostingConnector from './HostingConnector'; -import SerarchInfo from './SearchInfo'; +import SearchInfo from './SearchInfo'; +import EditorEmbed from './EditorEmbed'; export default { ThirdPartyConnector, HostingConnector, - SerarchInfo, + SearchInfo, + EditorEmbed, };
