@@ -33,7 +33,6 @@ 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)], [pageNames]) | |||
@@ -53,52 +52,10 @@ export default ({ title, body }: Props) => { | |||
setPageNames ([]) | |||
} | |||
}) () | |||
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={remarkPlugins}> | |||
{realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||
</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, '\\$&') | |||
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) | |||
{ | |||
@@ -20,47 +16,86 @@ export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) | |||
const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g') | |||
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 | |||
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 [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 +52,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 +69,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> | |||
{/* 本文 */} | |||
<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> | |||
</MainArea>) | |||
} |