Author | SHA1 | Message | Date |
---|---|---|---|
|
e5f2d4ceb5 | #93 | 1 month ago |
|
2550d31e3b | #93 | 1 month ago |
@@ -25,7 +25,8 @@ | |||||
"react-markdown": "^10.1.0", | "react-markdown": "^10.1.0", | ||||
"react-markdown-editor-lite": "^1.3.4", | "react-markdown-editor-lite": "^1.3.4", | ||||
"react-router-dom": "^6.30.0", | "react-router-dom": "^6.30.0", | ||||
"tailwind-merge": "^3.3.0" | |||||
"tailwind-merge": "^3.3.0", | |||||
"unist-util-visit-parents": "^6.0.1" | |||||
}, | }, | ||||
"devDependencies": { | "devDependencies": { | ||||
"@eslint/js": "^9.25.0", | "@eslint/js": "^9.25.0", | ||||
@@ -27,7 +27,8 @@ | |||||
"react-markdown": "^10.1.0", | "react-markdown": "^10.1.0", | ||||
"react-markdown-editor-lite": "^1.3.4", | "react-markdown-editor-lite": "^1.3.4", | ||||
"react-router-dom": "^6.30.0", | "react-router-dom": "^6.30.0", | ||||
"tailwind-merge": "^3.3.0" | |||||
"tailwind-merge": "^3.3.0", | |||||
"unist-util-visit-parents": "^6.0.1" | |||||
}, | }, | ||||
"devDependencies": { | "devDependencies": { | ||||
"@eslint/js": "^9.25.0", | "@eslint/js": "^9.25.0", | ||||
@@ -1,12 +1,13 @@ | |||||
import axios from 'axios' | import axios from 'axios' | ||||
import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
import { useEffect, useState } from 'react' | |||||
import { useEffect, useMemo, useState } from 'react' | |||||
import ReactMarkdown from 'react-markdown' | import ReactMarkdown from 'react-markdown' | ||||
import { Link } from 'react-router-dom' | import { Link } from 'react-router-dom' | ||||
import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
import SubsectionTitle from '@/components/common/SubsectionTitle' | import SubsectionTitle from '@/components/common/SubsectionTitle' | ||||
import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' | |||||
import type { Components } from 'react-markdown' | import type { Components } from 'react-markdown' | ||||
@@ -32,7 +33,8 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT | |||||
export default ({ title, body }: Props) => { | export default ({ title, body }: Props) => { | ||||
const [pageNames, setPageNames] = useState<string[]> ([]) | const [pageNames, setPageNames] = useState<string[]> ([]) | ||||
const [realBody, setRealBody] = useState<string> ('') | |||||
const remarkPlugins = useMemo (() => [remarkWikiAutoLink (pageNames)], [pageNames]) | |||||
useEffect (() => { | useEffect (() => { | ||||
if (!(body)) | if (!(body)) | ||||
@@ -42,7 +44,7 @@ export default ({ title, body }: Props) => { | |||||
try | try | ||||
{ | { | ||||
const res = await axios.get (`${ API_BASE_URL }/wiki`) | 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)) | setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length)) | ||||
} | } | ||||
catch | catch | ||||
@@ -50,54 +52,10 @@ export default ({ title, body }: Props) => { | |||||
setPageNames ([]) | setPageNames ([]) | ||||
} | } | ||||
}) () | }) () | ||||
}, []) | |||||
useEffect (() => { | |||||
setRealBody ('') | |||||
}, [body]) | }, [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 ( | return ( | ||||
<ReactMarkdown components={mdComponents}> | |||||
{realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||||
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | |||||
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||||
</ReactMarkdown>) | </ReactMarkdown>) | ||||
} | } |
@@ -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[0] | |||||
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) | |||||
} | |||||
} | |||||
} |
@@ -27,8 +27,9 @@ export default ({ user }: Props) => { | |||||
const navigate = useNavigate () | const navigate = useNavigate () | ||||
const [title, setTitle] = useState ('') | |||||
const [body, setBody] = useState ('') | const [body, setBody] = useState ('') | ||||
const [loading, setLoading] = useState (true) | |||||
const [title, setTitle] = useState ('') | |||||
const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
const formData = new FormData () | const formData = new FormData () | ||||
@@ -51,10 +52,12 @@ export default ({ user }: Props) => { | |||||
useEffect (() => { | useEffect (() => { | ||||
void (async () => { | void (async () => { | ||||
setLoading (true) | |||||
const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`) | const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`) | ||||
const data = res.data as WikiPage | const data = res.data as WikiPage | ||||
setTitle (data.title) | setTitle (data.title) | ||||
setBody (data.body) | setBody (data.body) | ||||
setLoading (false) | |||||
}) () | }) () | ||||
}, [id]) | }, [id]) | ||||
@@ -66,30 +69,33 @@ export default ({ user }: Props) => { | |||||
<div className="max-w-xl mx-auto p-4 space-y-4"> | <div className="max-w-xl mx-auto p-4 space-y-4"> | ||||
<h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> | <h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> | ||||
{/* タイトル */} | |||||
{/* TODO: タグ補完 */} | |||||
<div> | |||||
<label className="block font-semibold mb-1">タイトル</label> | |||||
<input type="text" | |||||
value={title} | |||||
onChange={e => setTitle (e.target.value)} | |||||
className="w-full border p-2 rounded" /> | |||||
</div> | |||||
{/* 本文 */} | |||||
<div> | |||||
<label className="block font-semibold mb-1">本文</label> | |||||
<MdEditor value={body} | |||||
style={{ height: '500px' }} | |||||
renderHTML={text => mdParser.render (text)} | |||||
onChange={({ text }) => setBody (text)} /> | |||||
</div> | |||||
{/* 送信 */} | |||||
<button onClick={handleSubmit} | |||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
追加 | |||||
</button> | |||||
{loading ? 'Loading...' : ( | |||||
<> | |||||
{/* タイトル */} | |||||
{/* TODO: タグ補完 */} | |||||
<div> | |||||
<label className="block font-semibold mb-1">タイトル</label> | |||||
<input type="text" | |||||
value={title} | |||||
onChange={e => setTitle (e.target.value)} | |||||
className="w-full border p-2 rounded" /> | |||||
</div> | |||||
{/* 本文 */} | |||||
<div> | |||||
<label className="block font-semibold mb-1">本文</label> | |||||
<MdEditor value={body} | |||||
style={{ height: '500px' }} | |||||
renderHTML={text => mdParser.render (text)} | |||||
onChange={({ text }) => setBody (text)} /> | |||||
</div> | |||||
{/* 送信 */} | |||||
<button onClick={handleSubmit} | |||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
追加 | |||||
</button> | |||||
</>)} | |||||
</div> | </div> | ||||
</MainArea>) | </MainArea>) | ||||
} | } |