diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46c04cd..88baed3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,8 @@ "react-router-dom": "^6.30.0", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "unist-util-visit-parents": "^6.0.1" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/frontend/package.json b/frontend/package.json index 8de6bae..69c112e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,8 @@ "react-router-dom": "^6.30.0", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "unist-util-visit-parents": "^6.0.1" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index 7e96488..0cb5cef 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -1,6 +1,6 @@ import axios from 'axios' import toCamel from 'camelcase-keys' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import ReactMarkdown from 'react-markdown' import { Link } from 'react-router-dom' import remarkGFM from 'remark-gfm' @@ -8,6 +8,7 @@ import remarkGFM from 'remark-gfm' import SectionTitle from '@/components/common/SectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle' import { API_BASE_URL } from '@/config' +import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' import type { FC } from 'react' import type { Components } from 'react-markdown' @@ -34,17 +35,16 @@ const mdComponents = { h1: ({ children }) => {children} { const [pageNames, setPageNames] = useState ([]) - const [realBody, setRealBody] = useState ('') - useEffect (() => { - if (!(body)) - return + const remarkPlugins = useMemo ( + () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) + useEffect (() => { void (async () => { try { const res = await axios.get (`${ API_BASE_URL }/wiki`) - const data = toCamel (res.data as any, { deep: true }) as WikiPage[] + const data: WikiPage[] = toCamel (res.data as any, { deep: true }) setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length)) } catch @@ -54,52 +54,8 @@ export default (({ title, body }: Props) => { }) () }, []) - useEffect (() => { - setRealBody ('') - }, [body]) - - useEffect (() => { - if (!(body)) - return - - const matchIndices = (target: string, keyword: string) => { - const indices: number[] = [] - let pos = 0 - let idx - while ((idx = target.indexOf (keyword, pos)) >= 0) - { - indices.push (idx) - pos = idx + keyword.length - } - - return indices - } - - const linkIndices = (text: string, names: string[]): [string, [number, number]][] => { - const result: [string, [number, number]][] = [] - - names.forEach (name => { - matchIndices (text, name).forEach (idx => { - const start = idx - const end = idx + name.length - const overlaps = result.some (([, [st, ed]]) => start < ed && end > st) - if (!(overlaps)) - result.push ([name, [start, end]]) - }) - }) - - return result.sort (([, [a]], [, [b]]) => b - a) - } - - setRealBody ( - linkIndices (body, pageNames).reduce ((acc, [name, [start, end]]) => ( - acc.slice (0, start) - + `[${ name }](/wiki/${ encodeURIComponent (name) })` - + acc.slice (end)), body)) - }, [body, pageNames]) - return ( - - {realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} + + {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} ) }) satisfies FC diff --git a/frontend/src/lib/remark-wiki-autolink.ts b/frontend/src/lib/remark-wiki-autolink.ts new file mode 100644 index 0000000..a37772b --- /dev/null +++ b/frontend/src/lib/remark-wiki-autolink.ts @@ -0,0 +1,101 @@ +import type { Content, Parent, Root, RootContent } from 'mdast' + +const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&') + + +export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) => { + const names = [...pageNames].sort ((a, b) => b.length - a.length) + + if (names.length === 0) + { + return () => { + ; + } + } + + const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g') + + return (tree: Root) => { + const edits: { parent: Parent; index: number; parts: RootContent[] }[] = [] + + const walk = (node: Content | Root, ancestors: Parent[]) => { + if (!(node) || (typeof node !== 'object')) + return + + if (!(ancestors.some (ancestor => ['link', + 'linkReference', + 'image', + 'imageReference', + 'code', + 'inlineCode'].includes (ancestor?.type))) + && (node.type === 'text')) + { + const value = node.value ?? '' + if (value) + { + re.lastIndex = 0 + let m: RegExpExecArray | null + let last = 0 + const parts: RootContent[] = [] + + while (m = re.exec (value)) + { + const start = m.index + const end = start + m[0].length + + if (start > last) + parts.push ({ type: 'text', value: value.slice (last, start) }) + + const name = m[1] + parts.push ({ type: 'link', + url: `${ basePath }/${ encodeURIComponent (name) }`, + title: null, + children: [{ type: 'text', value: name }], + data: { hProperties: { 'data-wiki': '1' } } }) + last = end + } + + if (parts.length) + { + if (last < value.length) + parts.push ({ type: 'text', value: value.slice (last) }) + const parent = ancestors[ancestors.length - 1] + if (parent && Array.isArray (parent.children)) + { + const index = parent.children.indexOf (node) + if (index >= 0) + edits.push ({ parent, index, parts }) + } + } + } + } + + const maybeChidren = (node as any).children + if (Array.isArray (maybeChidren)) + { + const parent = node as Parent + + for (let i = 0; i < maybeChidren.length; ++i) + { + const child: Content | undefined = maybeChidren[i] + if (!(child)) + continue + walk (child, ancestors.concat (parent)) + } + } + } + + walk (tree, []) + + for (let i = edits.length - 1; i >= 0; --i) + { + const { parent, index, parts } = edits[i] + + if (!(parent) || !(Array.isArray (parent.children))) + continue + + if (0 <= index && index < parent.children.length) + parent.children.splice (index, 1, ...parts) + } + } +} diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index b5ebd79..6a68656 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -36,6 +36,7 @@ export default () => { if (/^\d+$/.test (title)) { void (async () => { + setWikiPage (undefined) try { const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) @@ -52,6 +53,7 @@ export default () => { } void (async () => { + setWikiPage (undefined) try { const res = await axios.get ( diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index 62a2f06..2bda3be 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -12,6 +12,8 @@ import Forbidden from '@/pages/Forbidden' import 'react-markdown-editor-lite/lib/index.css' +import type { FC } from 'react' + import type { User, WikiPage } from '@/types' const mdParser = new MarkdownIt @@ -19,7 +21,7 @@ const mdParser = new MarkdownIt type Props = { user: User | null } -export default ({ user }: Props) => { +export default (({ user }: Props) => { if (!(['admin', 'member'].some (r => user?.role === r))) return @@ -27,8 +29,9 @@ export default ({ user }: Props) => { const navigate = useNavigate () - const [title, setTitle] = useState ('') const [body, setBody] = useState ('') + const [loading, setLoading] = useState (true) + const [title, setTitle] = useState ('') const handleSubmit = async () => { const formData = new FormData () @@ -51,10 +54,12 @@ export default ({ user }: Props) => { useEffect (() => { void (async () => { + setLoading (true) const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`) const data = res.data as WikiPage setTitle (data.title) setBody (data.body) + setLoading (false) }) () }, [id]) @@ -66,30 +71,33 @@ export default ({ user }: Props) => {

Wiki ページを編輯

- {/* タイトル */} - {/* TODO: タグ補完 */} -
- - setTitle (e.target.value)} - className="w-full border p-2 rounded"/> -
- - {/* 本文 */} -
- - mdParser.render (text)} - onChange={({ text }) => setBody (text)}/> -
- - {/* 送信 */} - + {loading ? 'Loading...' : ( + <> + {/* タイトル */} + {/* TODO: タグ補完 */} +
+ + setTitle (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* 本文 */} +
+ + mdParser.render (text)} + onChange={({ text }) => setBody (text)}/> +
+ + {/* 送信 */} + + )}
) -} +}) satisfies FC