feat: Wiki 自動リンクをテキスト領域のみに制限(#93) (#218)
Merge branch 'main' into #93 #93 Merge remote-tracking branch 'origin/main' into #93 #93 #93 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #218
This commit was merged in pull request #218.
This commit is contained in:
@@ -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 }) => <SectionTitle>{children}</SectionT
|
||||
|
||||
export default (({ title, body }: Props) => {
|
||||
const [pageNames, setPageNames] = useState<string[]> ([])
|
||||
const [realBody, setRealBody] = useState<string> ('')
|
||||
|
||||
const remarkPlugins = useMemo (
|
||||
() => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(body))
|
||||
return
|
||||
|
||||
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 (
|
||||
<ReactMarkdown components={mdComponents} remarkPlugins={[remarkGFM]}>
|
||||
{realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
|
||||
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
|
||||
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
|
||||
</ReactMarkdown>)
|
||||
}) satisfies FC<Props>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 <Forbidden/>
|
||||
|
||||
@@ -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) => {
|
||||
<div className="max-w-xl mx-auto p-4 space-y-4">
|
||||
<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>
|
||||
{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>
|
||||
{/* 本文 */}
|
||||
<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>
|
||||
{/* 送信 */}
|
||||
<button onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
|
||||
編輯
|
||||
</button>
|
||||
</>)}
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
}) satisfies FC<Props>
|
||||
|
||||
Reference in New Issue
Block a user