| @@ -33,7 +33,6 @@ 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]) | const remarkPlugins = useMemo (() => [remarkWikiAutoLink (pageNames)], [pageNames]) | ||||
| @@ -53,52 +52,10 @@ export default ({ title, body }: Props) => { | |||||
| setPageNames ([]) | setPageNames ([]) | ||||
| } | } | ||||
| }) () | }) () | ||||
| 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} remarkPlugins={remarkPlugins}> | <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | ||||
| {realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||||
| </ReactMarkdown>) | </ReactMarkdown>) | ||||
| } | } | ||||
| @@ -1,14 +1,10 @@ | |||||
| import { visitParents } from 'unist-util-visit-parents' | |||||
| import type { Parent, Root, RootContent, Text } from 'mdast' | |||||
| import type { Content, Parent, Root, RootContent } from 'mdast' | |||||
| const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&') | const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&') | ||||
| export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) => { | export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) => { | ||||
| const names = ([...(new Set(pageNames))] | |||||
| .filter (Boolean) | |||||
| .sort ((a, b) => b.length - a.length)) | |||||
| const names = pageNames.sort ((a, b) => b.length - a.length) | |||||
| if (names.length === 0) | if (names.length === 0) | ||||
| { | { | ||||
| @@ -20,47 +16,86 @@ export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) | |||||
| const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g') | const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g') | ||||
| return (tree: Root) => { | return (tree: Root) => { | ||||
| visitParents (tree, 'text', (node: Text, ancestors: Parent[]) => { | |||||
| if (ancestors.some (ancestor => ['link', | |||||
| 'linkReference', | |||||
| 'image', | |||||
| 'imageReference', | |||||
| 'code', | |||||
| 'inlineCode'].includes (ancestor.type))) | |||||
| const edits: { parent: Parent; index: number; parts: RootContent[] }[] = [] | |||||
| const walk = (node: Content | Root, ancestors: Parent[]) => { | |||||
| if (!(node) || (typeof node !== 'object')) | |||||
| return | return | ||||
| const value = node.value | |||||
| re.lastIndex = 0 | |||||
| let match: RegExpExecArray | null | |||||
| let last = 0 | |||||
| const parts: RootContent[] = [] | |||||
| 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 | |||||
| while (match = re.exec (value)) | |||||
| 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 start = match.index | |||||
| const end = start + match[0].length | |||||
| if (start > last) | |||||
| parts.push ({ type: 'text', value: value.slice (last, start) }) | |||||
| const name = match[0] | |||||
| parts.push ({ | |||||
| type: 'link', | |||||
| url: `${ basePath }/${ encodeURIComponent (name) }`, | |||||
| title: null, | |||||
| children: [{ type: 'text', value: name }], | |||||
| data: { hProperties: { 'data-wiki': '1' } } }) | |||||
| last = end | |||||
| 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)) | |||||
| } | |||||
| } | } | ||||
| } | |||||
| if (parts.length === 0) | |||||
| return | |||||
| walk (tree, []) | |||||
| for (let i = edits.length - 1; i >= 0; --i) | |||||
| { | |||||
| const { parent, index, parts } = edits[i] | |||||
| if (last < value.length) | |||||
| parts.push ({ type: 'text', value: value.slice (last) }) | |||||
| if (!(parent) || !(Array.isArray (parent.children))) | |||||
| continue | |||||
| const parent: Parent = ancestors[ancestors.length - 1] | |||||
| const idx = parent.children.indexOf (node) | |||||
| parent.children.splice (idx, 1, ...parts) | |||||
| }) | |||||
| 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>) | ||||
| } | } | ||||