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,
 };

Reply via email to