From b725b250d332ff1649e49a927dde33e73ea04fbe Mon Sep 17 00:00:00 2001
From: Kohei Watanabe <kou029w@gmail.com>
Date: Wed, 7 Sep 2022 19:09:24 +0900
Subject: [PATCH] auto link (experimental)

---
 app/package-lock.json           | 20 +++++++++++++++
 app/package.json                |  1 +
 app/views/components/editor.tsx | 44 +++++++++++++++++++++++++++------
 3 files changed, 57 insertions(+), 8 deletions(-)

diff --git a/app/package-lock.json b/app/package-lock.json
index 8fec273..2da2aa5 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -11,6 +11,7 @@
         "@exampledev/new.css": "^1.1.3",
         "@fastify/http-proxy": "^8.2.2",
         "@lexical/history": "^0.4.1",
+        "@lexical/link": "^0.4.1",
         "@lexical/rich-text": "^0.4.1",
         "@tsconfig/node18-strictest-esm": "^1.0.1",
         "esbuild": "^0.15.7",
@@ -138,6 +139,17 @@
         "lexical": "0.4.1"
       }
     },
+    "node_modules/@lexical/link": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.4.1.tgz",
+      "integrity": "sha512-566lQymmuBe3Y7UDyaaTs+VDlElbu1WhnjT9lVDk0BXag7MA8tv/f60XptWnTK1pv/Dobm/CyLmyLae55OuflQ==",
+      "dependencies": {
+        "@lexical/utils": "0.4.1"
+      },
+      "peerDependencies": {
+        "lexical": "0.4.1"
+      }
+    },
     "node_modules/@lexical/list": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.4.1.tgz",
@@ -1199,6 +1211,14 @@
         "@lexical/selection": "0.4.1"
       }
     },
+    "@lexical/link": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.4.1.tgz",
+      "integrity": "sha512-566lQymmuBe3Y7UDyaaTs+VDlElbu1WhnjT9lVDk0BXag7MA8tv/f60XptWnTK1pv/Dobm/CyLmyLae55OuflQ==",
+      "requires": {
+        "@lexical/utils": "0.4.1"
+      }
+    },
     "@lexical/list": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.4.1.tgz",
diff --git a/app/package.json b/app/package.json
index 702e862..6ff0bb3 100644
--- a/app/package.json
+++ b/app/package.json
@@ -14,6 +14,7 @@
     "@exampledev/new.css": "^1.1.3",
     "@fastify/http-proxy": "^8.2.2",
     "@lexical/history": "^0.4.1",
+    "@lexical/link": "^0.4.1",
     "@lexical/rich-text": "^0.4.1",
     "@tsconfig/node18-strictest-esm": "^1.0.1",
     "esbuild": "^0.15.7",
diff --git a/app/views/components/editor.tsx b/app/views/components/editor.tsx
index a0c8ddc..2e18d2d 100644
--- a/app/views/components/editor.tsx
+++ b/app/views/components/editor.tsx
@@ -11,11 +11,13 @@ import {
   HeadingNode,
   registerRichText,
 } from "@lexical/rich-text";
+import { $createAutoLinkNode, AutoLinkNode } from "@lexical/link";
 import { onCleanup, onMount } from "solid-js";
 import type Pages from "../../protocol/pages";
 import "./editor.css";
 
-const editor = createEditor({ nodes: [HeadingNode] });
+const urlMatcher = /https?:\/\/[^\s]+/;
+const editor = createEditor({ nodes: [HeadingNode, AutoLinkNode] });
 
 function ref(el: HTMLElement) {
   editor.setRootElement(el);
@@ -29,12 +31,26 @@ export default (props: {
 }) => {
   const initialEditorState = () => {
     const root = $getRoot();
-    for (const [i, text] of props.text.split("\n").entries()) {
-      const line = i === 0 ? $createHeadingNode("h2") : $createParagraphNode();
-      const indent = text.match(/^\s*/)?.[0]?.length ?? 0;
-      line.setIndent(indent);
-      line.append($createTextNode(text.slice(indent)));
-      root.append(line);
+    const [title, ...lines] = props.text.split("\n");
+    const titleNode = $createHeadingNode("h2");
+    titleNode.append($createTextNode(title));
+    root.append(titleNode);
+    for (const line of lines) {
+      const lineNode = $createParagraphNode();
+      const indent = line.match(/^\s*/)?.[0]?.length ?? 0;
+      lineNode.setIndent(indent);
+      let text = line.slice(indent);
+      let match: RegExpMatchArray | null = null;
+      while ((match = text.match(urlMatcher))) {
+        const offset = text.slice(0, match.index!);
+        const input = match[0]!;
+        const link = $createAutoLinkNode(input);
+        link.append($createTextNode(match[0]));
+        lineNode.append($createTextNode(offset), link);
+        text = text.slice(offset.length + input.length);
+      }
+      lineNode.append($createTextNode(text));
+      root.append(lineNode);
     }
   };
   onCleanup(registerRichText(editor, initialEditorState));
@@ -64,5 +80,17 @@ export default (props: {
     )
   );
   onMount(() => editor.focus());
-  return <article ref={ref} class="editor" contenteditable />;
+  return (
+    <article
+      ref={ref}
+      onclick={(e) => {
+        const el = e.target.parentElement;
+        if (el instanceof HTMLAnchorElement) {
+          window.open(el.href, "_blank", "noreferrer");
+        }
+      }}
+      class="editor"
+      contenteditable
+    />
+  );
 };