Skip to content

Commit 0f0996c

Browse files
committed
feat: drag and drop image upload
1 parent 032b679 commit 0f0996c

File tree

11 files changed

+106
-67
lines changed

11 files changed

+106
-67
lines changed

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@
2424
out
2525
# Turborepo
2626
.turbo
27+
28+
29+
# Claude
30+
.gpt-runner

packages/nextra-editor/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
"tsx": "^4.11.2",
9494
"unist-util-remove": "^4.0.0",
9595
"unist-util-visit": "^5.0.0",
96+
"use-debounce": "^10.0.1",
9697
"zod": "^3.22.3"
9798
},
9899
"devDependencies": {

packages/nextra-editor/src/components/sidebar/index.tsx

+2-6
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,10 @@ export function Sidebar({
9999
const hasMenu = config.darkMode || hasI18n || config.sidebar.toggleButton
100100
const [route] = routeOriginal.split('#')
101101

102-
const initSotableItems: SortableItem[] = useMemo(
103-
() => initilizeDirectories(docsDirectories, route, collapsedTree),
104-
[docsDirectories],
105-
)
106-
107102
useEffect(() => {
103+
const initSotableItems = initilizeDirectories(docsDirectories, route, collapsedTree)
108104
setSortableItems(initSotableItems)
109-
}, [initSotableItems])
105+
}, [docsDirectories])
110106

111107
const initFullDirectories: SortableItem[] = useMemo(
112108
() => initilizeDirectories(fullDirectories, route, collapsedTree),

packages/nextra-editor/src/components/sidebar/sidebar-controller/control-icon.tsx

+4-12
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,19 @@ const ControlIcon = ({ className, type }: Props) => {
1919
const timeoutRef = useRef<NodeJS.Timeout>()
2020
const [originSortableItems, setOriginSortableItems] = useState<SortableItem[]>([])
2121

22-
useEffect(() => {
23-
if (sidebar.actionType !== type) return
24-
if (!sidebar.actionComplete) return
25-
sidebar.reset(originSortableItems)
26-
return () => {
27-
if (!timeoutRef.current) return
28-
clearTimeout(timeoutRef.current)
29-
}
30-
}, [sidebar.actionComplete])
31-
3222
const onClick = () => {
3323
if (sidebar.actionActive) {
34-
sidebar.reset(originSortableItems)
24+
sidebar.reset()
25+
sidebar.setSortableItems(originSortableItems)
3526
return
3627
}
3728

3829
sidebar.setActionType(type)
3930
const timeout = setTimeout(() => sidebar.setActionActive(true), 100)
4031
timeoutRef.current = timeout
4132

33+
setOriginSortableItems(sidebar.sortableItems)
34+
4235
// remove code
4336
let targetRoute = pageUrlSlug
4437
const isFolder = !!findFolder(sidebar.sortableItems, fullUrlSlug)
@@ -48,7 +41,6 @@ const ControlIcon = ({ className, type }: Props) => {
4841
targetRoute = targetRoute.split('/').slice(0, -1).join('/') || '/'
4942
}
5043

51-
setOriginSortableItems(sidebar.sortableItems)
5244
const coppeidPageMap = structuredClone(sidebar.sortableItems)
5345

5446
function addNewFolderToItem(sortableItems: SortableItem[]) {

packages/nextra-editor/src/components/sidebar/sidebar-controller/control-input.tsx

+73-34
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { ReactElement, useState } from 'react'
1+
import React, { ReactElement, useEffect, useState } from 'react'
22
import cn from 'clsx'
33

44
import { ActionType, useSidebar } from '@/contexts/sidebar'
@@ -7,71 +7,92 @@ import { EmptyFolderIcon } from '@/nextra/icons/empty-folder'
77
import { EmptyFileIcon } from '@/nextra/icons/empty-file'
88
import { SeparatorIcon } from '@/nextra/icons/separator'
99
import { type CustomEventDetail, nextraCustomEventName } from '@/index'
10+
import { useDebouncedCallback } from 'use-debounce'
1011

1112
type Props = {
1213
type: ActionType
1314
}
1415

1516
function ControlInput({ type }: Props): ReactElement {
1617
const sidebar = useSidebar()
17-
const [title, setTitle] = useState('')
18+
const [title, setTitle] = useState<string | null>(null)
19+
const [error, setError] = useState('')
1820

19-
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
20-
e.preventDefault()
21-
setTitle(e.target.value)
21+
const nameMapper: Record<ActionType, string> = {
22+
folder: '폴더',
23+
page: '페이지',
24+
separator: '구분선',
25+
'': '',
2226
}
2327

24-
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
25-
e.preventDefault()
26-
if ((e.target as any).tagName !== 'INPUT') {
27-
onComplete()
28-
}
28+
const forbiddenCharsRegex = /[<>#%"{}|\^~\[\]`\/:@=&+$,;!*()\\']/
29+
const typeName = nameMapper[type]
2930

30-
if (title) {
31-
dispatchEvent()
32-
return
31+
useEffect(() => {
32+
if (title === null) return
33+
if (forbiddenCharsRegex.test(title)) {
34+
setError(`${typeName} 이름에 사용해서는 안 되는 기호가 포함되어 있습니다.`)
35+
} else if (title === '') {
36+
setError(`${typeName} 이름을 입력해야 합니다.`)
37+
} else {
38+
setError('')
3339
}
34-
onCancel()
40+
}, [title])
41+
42+
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
43+
e.preventDefault()
44+
// 두개 이상의 공백은 하나의 공백으로 변경
45+
const value = e.target.value.replace(/\s\s+/g, ' ').trim()
46+
setTitle(value)
3547
}
3648

3749
const onComplete = () => {
3850
if (!sidebar.actionActive) return
39-
sidebar.setActionComplete(true)
40-
}
41-
42-
const onCancel = () => {
4351
setTitle('')
52+
dispatchEvent()
4453
}
4554

4655
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
56+
if (e.key === ' ') {
57+
e.preventDefault()
58+
setTitle((title) => `${title} `)
59+
return
60+
}
4761
if (e.key !== 'Enter') return
4862
e.preventDefault()
49-
onComplete()
5063
dispatchEvent()
5164
}
5265

53-
const dispatchEvent = () => {
54-
const { parentUrlSlug, bookUrlSlug, index, type } = sidebar.actionInfo
55-
if (type === '') return
56-
const event = new CustomEvent<CustomEventDetail['addActionEvent']>(
57-
nextraCustomEventName.addActionEvent,
58-
{
59-
detail: { title, parentUrlSlug, index, bookUrlSlug, type },
60-
},
61-
)
62-
window.dispatchEvent(event)
63-
}
66+
const dispatchEvent = useDebouncedCallback(
67+
() => {
68+
const { parentUrlSlug, bookUrlSlug, index, type } = sidebar.actionInfo
69+
if (type === '') return
70+
if (!title) return
71+
if (error) return
72+
const event = new CustomEvent<CustomEventDetail['addActionEvent']>(
73+
nextraCustomEventName.addActionEvent,
74+
{
75+
detail: { title, parentUrlSlug, index, bookUrlSlug, type },
76+
},
77+
)
78+
window.dispatchEvent(event)
79+
sidebar.reset()
80+
},
81+
1000,
82+
{ leading: true },
83+
)
6484

6585
const { ref } = useOutsideClick<HTMLDivElement>(onComplete)
86+
6687
if (type === '') return <></>
6788
return (
6889
<div
6990
ref={ref}
7091
className={cn(
92+
'nextra-control-input',
7193
'nx-flex nx-w-full nx-items-center nx-px-2 nx-py-1.5 [word-break:break-word]',
72-
'nx-transition-colors',
94+
'nx-relative nx-transition-colors',
7395
)}
74-
onClick={onClick}
7596
>
7697
<span>
7798
{type === 'folder' && <EmptyFolderIcon />}
@@ -85,14 +106,32 @@ function ControlInput({ type }: Props): ReactElement {
85106
'nx-bg-gray-100 nx-text-gray-600 ',
86107
'dark:nx-bg-primary-100/5 dark:nx-text-gray-400',
87108
)}
88-
value={title}
109+
value={title === null ? '' : title}
89110
onChange={onChange}
90-
autoFocus={true}
91111
onKeyDown={onKeyDown}
112+
autoFocus={true}
92113
style={{
93114
boxShadow: 'none',
94115
}}
95116
/>
117+
{error && (
118+
<div
119+
className={cn(
120+
'nx-absolute nx-left-0 nx-top-[35px] nx-z-10 nx-w-full',
121+
'nx-bg-white nx-text-[12px] nx-font-medium nx-text-gray-500',
122+
'nx-px-2',
123+
)}
124+
>
125+
<div
126+
className={cn(
127+
'nx-border dark:nx-border-gray-100/20 dark:nx-bg-dark/50 ',
128+
'nx-px-2 nx-py-1',
129+
)}
130+
>
131+
{error}
132+
</div>
133+
</div>
134+
)}
96135
</div>
97136
)
98137
}

packages/nextra-editor/src/components/sidebar/sidebar-controller/control-menu.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const ControlMenu = forwardRef<HTMLDivElement, Props>(({ isOpen, position }, ref
2626
'nx-bg-white nx-text-gray-600',
2727
'dark:nx-bg-neutral-800 dark:nx-text-gray-300',
2828
)}
29-
style={{ top: position.top, left: 280, display: isOpen ? 'block' : 'none' }}
29+
style={{ top: position.top, left: 260, display: isOpen ? 'block' : 'none' }}
3030
>
3131
<ul>
3232
<li className={style.list}>

packages/nextra-editor/src/components/sidebar/sortable-tree/sortable-item.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export const SortableItem = forwardRef<HTMLDivElement, SortableItemProps>((props
4242
!isDragging && !isControlAction && !isGhost && [route, `${route}/`].includes(item.route + '/')
4343
// const isLink = 'withIndexPage' in item && item.withIndexPage
4444

45+
const isShowMenu = showMenuId === item.id
46+
4547
useEffect(() => {
4648
if (isGhost) return
4749
if (!isOver) return
@@ -109,6 +111,7 @@ export const SortableItem = forwardRef<HTMLDivElement, SortableItemProps>((props
109111
!isControlAction && !isDragging && !active && classes.inactiveBgColor,
110112
isGhost && classes.ghost,
111113
clone && classes.clone,
114+
isShowMenu && classes.showMenuActive,
112115
isControlAction && '!nx-pr-0',
113116
)}
114117
onClick={() => {

packages/nextra-editor/src/components/sidebar/style.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const classes = {
3333
'nx-mb-2 nx-mt-5 nx-text-sm nx-font-semibold nx-text-gray-900 first:nx-mt-0 dark:nx-text-gray-100',
3434
),
3535
over: cn('nx-bg-red-100 dark:nx-bg-primary-100/5'),
36+
showMenuActive: cn('nx-bg-gray-100 dark:nx-bg-primary-100/5'),
3637
}
3738

3839
export const indentStyle = (depth: number, indentationWidth: number): string =>

packages/nextra-editor/src/contexts/sidebar.tsx

+3-13
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@ export type ActionType = 'folder' | 'page' | 'separator' | ''
1313
type Sidebar = {
1414
sortableItems: SortableItem[]
1515
setSortableItems: (item: SortableItem[]) => void
16-
reset: (pageMap: SortableItem[]) => void
16+
reset: (pageMap?: SortableItem[]) => void
1717
actionActive: boolean
1818
setActionActive: (value: boolean) => void
19-
actionComplete: boolean
20-
setActionComplete: (value: boolean) => void
2119
actionInfo: ActionInfo
2220
setActionInfo: (args: ActionInfo) => void
2321
isFolding: boolean
@@ -50,8 +48,6 @@ const SidebarContext = createContext<Sidebar>({
5048
reset: () => {},
5149
actionActive: false,
5250
setActionActive: () => {},
53-
actionComplete: false,
54-
setActionComplete: () => {},
5551
actionInfo: { parentUrlSlug: '/', index: 0, bookUrlSlug: '/', type: 'page' },
5652
setActionInfo: () => {},
5753
isFolding: false,
@@ -73,7 +69,6 @@ export function useSidebar() {
7369
export const SidebarProvider = ({ children }: { children: ReactNode }): ReactElement => {
7470
const [sortableItems, setSortableItems] = useState<SortableItem[]>([])
7571
const [isFolding, setFolding] = useState(false)
76-
const [actionComplete, setActionComplete] = useState(false)
7772
const [actionActive, setActionActive] = useState(false)
7873
const [showMenuId, setShowMenuId] = useState<string | null>(null)
7974

@@ -86,12 +81,9 @@ export const SidebarProvider = ({ children }: { children: ReactNode }): ReactEle
8681
type: 'page',
8782
})
8883

89-
const reset = (originSortableItem: SortableItem[]) => {
90-
if (originSortableItem.length > 0) {
91-
setSortableItems(originSortableItem)
92-
}
84+
const reset = () => {
85+
setSortableItems(sortableItems)
9386
setActionActive(false)
94-
setActionComplete(false)
9587
setActionType('')
9688
setFocusedItem(null)
9789
}
@@ -107,8 +99,6 @@ export const SidebarProvider = ({ children }: { children: ReactNode }): ReactEle
10799
sortableItems,
108100
setSortableItems,
109101
reset,
110-
actionComplete,
111-
setActionComplete,
112102
actionActive,
113103
setActionActive,
114104
actionInfo,

packages/nextra-editor/style.css

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-lock.yaml

+13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)