✨ chapter re-ordering
This commit is contained in:
@@ -5,6 +5,8 @@ import type { FileNode } from '../renderer/types/editor'
|
|||||||
const DRAFT_ROOT =
|
const DRAFT_ROOT =
|
||||||
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
|
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
|
||||||
|
|
||||||
|
const ORDER_FILE = join(DRAFT_ROOT, '.order.json')
|
||||||
|
|
||||||
const PART_ORDER = ['Prologue', 'Content Warning', 'Part I', 'Part II', 'Part III', 'Part IV', 'Epilogue', 'The first time']
|
const PART_ORDER = ['Prologue', 'Content Warning', 'Part I', 'Part II', 'Part III', 'Part IV', 'Epilogue', 'The first time']
|
||||||
|
|
||||||
function sortDraftNodes(a: FileNode, b: FileNode): number {
|
function sortDraftNodes(a: FileNode, b: FileNode): number {
|
||||||
@@ -16,8 +18,30 @@ function sortDraftNodes(a: FileNode, b: FileNode): number {
|
|||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readOrderFile(): Promise<Record<string, string[]>> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(ORDER_FILE, 'utf-8'))
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOrderFile(order: Record<string, string[]>): Promise<void> {
|
||||||
|
await writeFile(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOrder(nodes: FileNode[], savedNames: string[]): FileNode[] {
|
||||||
|
const map = new Map(nodes.map((n) => [n.name, n]))
|
||||||
|
const ordered = savedNames.filter((n) => map.has(n)).map((n) => map.get(n)!)
|
||||||
|
const rest = nodes.filter((n) => !savedNames.includes(n.name))
|
||||||
|
return [...ordered, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
export async function listDraftFiles(): Promise<FileNode[]> {
|
export async function listDraftFiles(): Promise<FileNode[]> {
|
||||||
const entries = await readdir(DRAFT_ROOT, { withFileTypes: true })
|
const [entries, order] = await Promise.all([
|
||||||
|
readdir(DRAFT_ROOT, { withFileTypes: true }),
|
||||||
|
readOrderFile()
|
||||||
|
])
|
||||||
const nodes: FileNode[] = []
|
const nodes: FileNode[] = []
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -26,14 +50,19 @@ export async function listDraftFiles(): Promise<FileNode[]> {
|
|||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const children = await readdir(fullPath, { withFileTypes: true })
|
const children = await readdir(fullPath, { withFileTypes: true })
|
||||||
const childNodes: FileNode[] = children
|
let childNodes: FileNode[] = children
|
||||||
.filter((c) => c.name.endsWith('.md') && !c.name.startsWith('.'))
|
.filter((c) => c.name.endsWith('.md') && !c.name.startsWith('.'))
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
name: c.name.replace(/\.md$/, ''),
|
name: c.name.replace(/\.md$/, ''),
|
||||||
path: join(fullPath, c.name),
|
path: join(fullPath, c.name),
|
||||||
type: 'file' as const
|
type: 'file' as const
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
|
if (order[fullPath]) {
|
||||||
|
childNodes = applyOrder(childNodes, order[fullPath])
|
||||||
|
} else {
|
||||||
|
childNodes = childNodes.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
@@ -50,6 +79,9 @@ export async function listDraftFiles(): Promise<FileNode[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (order['__root__']) {
|
||||||
|
return applyOrder(nodes, order['__root__'])
|
||||||
|
}
|
||||||
return nodes.sort(sortDraftNodes)
|
return nodes.sort(sortDraftNodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount } from './fileSystem'
|
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile } from './fileSystem'
|
||||||
import { streamMessage } from './aiService'
|
import { streamMessage } from './aiService'
|
||||||
import type { AIPayload } from '../renderer/types/editor'
|
import type { AIPayload } from '../renderer/types/editor'
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ export function registerIpcHandlers(): void {
|
|||||||
return await getProjectWordCount()
|
return await getProjectWordCount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('fs:saveOrder', async (_event, order: Record<string, string[]>) => {
|
||||||
|
await saveOrderFile(order)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||||
try {
|
try {
|
||||||
await streamMessage(payload, (chunk: string) => {
|
await streamMessage(payload, (chunk: string) => {
|
||||||
|
|||||||
@@ -45,5 +45,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.removeAllListeners('ai:error')
|
ipcRenderer.removeAllListeners('ai:error')
|
||||||
},
|
},
|
||||||
|
|
||||||
getProjectWordCount: (): Promise<number> => ipcRenderer.invoke('fs:projectWordCount')
|
getProjectWordCount: (): Promise<number> => ipcRenderer.invoke('fs:projectWordCount'),
|
||||||
|
|
||||||
|
saveOrder: (order: Record<string, string[]>): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('fs:saveOrder', order)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,3 +78,17 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--active-bg);
|
background: var(--active-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-file.dragging,
|
||||||
|
.tree-dir-header.dragging {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-file.drag-over {
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-dir-header.drag-over {
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export function FileTree(): JSX.Element {
|
|||||||
<div className="file-tree-header">HOHOFF</div>
|
<div className="file-tree-header">HOHOFF</div>
|
||||||
<div className="file-tree-list">
|
<div className="file-tree-list">
|
||||||
{fileTree.map((node) => (
|
{fileTree.map((node) => (
|
||||||
<FileTreeNode key={node.path} node={node} depth={0} />
|
<FileTreeNode
|
||||||
|
key={node.path}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
dirPath="__root__"
|
||||||
|
siblings={fileTree}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ import { useState } from 'react'
|
|||||||
import { useEditorStore } from '../../store/editorStore'
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
import type { FileNode } from '../../types/editor'
|
import type { FileNode } from '../../types/editor'
|
||||||
|
|
||||||
|
interface DndPayload {
|
||||||
|
name: string
|
||||||
|
dirPath: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: FileNode
|
node: FileNode
|
||||||
depth: number
|
depth: number
|
||||||
|
dirPath: string
|
||||||
|
siblings: FileNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileTreeNode({ node, depth }: Props): JSX.Element {
|
export function FileTreeNode({ node, depth, dirPath, siblings }: Props): JSX.Element {
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [expanded, setExpanded] = useState(true)
|
||||||
const { activeFilePath, setActiveFile } = useEditorStore()
|
const { activeFilePath, setActiveFile, moveNode } = useEditorStore()
|
||||||
|
|
||||||
const openFile = async (): Promise<void> => {
|
const openFile = async (): Promise<void> => {
|
||||||
if (node.type === 'file') {
|
if (node.type === 'file') {
|
||||||
@@ -18,6 +25,40 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDragStart(e: React.DragEvent<HTMLElement>): void {
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('dnd', JSON.stringify({ name: node.name, dirPath } satisfies DndPayload))
|
||||||
|
e.currentTarget.classList.add('dragging')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(e: React.DragEvent<HTMLElement>): void {
|
||||||
|
e.currentTarget.classList.remove('dragging')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: React.DragEvent<HTMLElement>): void {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
e.currentTarget.classList.add('drag-over')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: React.DragEvent<HTMLElement>): void {
|
||||||
|
e.currentTarget.classList.remove('drag-over')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: React.DragEvent<HTMLElement>): void {
|
||||||
|
e.preventDefault()
|
||||||
|
e.currentTarget.classList.remove('drag-over')
|
||||||
|
const raw = e.dataTransfer.getData('dnd')
|
||||||
|
if (!raw) return
|
||||||
|
const payload: DndPayload = JSON.parse(raw)
|
||||||
|
if (payload.dirPath !== dirPath) return
|
||||||
|
const fromIdx = siblings.findIndex((n) => n.name === payload.name)
|
||||||
|
const toIdx = siblings.findIndex((n) => n.name === node.name)
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
|
||||||
|
moveNode(dirPath, fromIdx, toIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === 'directory') {
|
if (node.type === 'directory') {
|
||||||
return (
|
return (
|
||||||
<div className="tree-dir">
|
<div className="tree-dir">
|
||||||
@@ -25,12 +66,24 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element {
|
|||||||
className="tree-dir-header"
|
className="tree-dir-header"
|
||||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<span className="tree-arrow">{expanded ? '▾' : '▸'}</span>
|
<span className="tree-arrow">{expanded ? '▾' : '▸'}</span>
|
||||||
{node.name}
|
{node.name}
|
||||||
</button>
|
</button>
|
||||||
{expanded && node.children?.map((child) => (
|
{expanded && node.children?.map((child) => (
|
||||||
<FileTreeNode key={child.path} node={child} depth={depth + 1} />
|
<FileTreeNode
|
||||||
|
key={child.path}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
dirPath={node.path}
|
||||||
|
siblings={node.children!}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -44,6 +97,12 @@ export function FileTreeNode({ node, depth }: Props): JSX.Element {
|
|||||||
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
||||||
onClick={openFile}
|
onClick={openFile}
|
||||||
title={node.name}
|
title={node.name}
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ interface EditorState {
|
|||||||
analysisMode: AnalysisMode
|
analysisMode: AnalysisMode
|
||||||
setAnalysisMode: (mode: AnalysisMode) => void
|
setAnalysisMode: (mode: AnalysisMode) => void
|
||||||
|
|
||||||
|
// File tree reordering
|
||||||
|
moveNode: (dirPath: string, fromIdx: number, toIdx: number) => void
|
||||||
|
|
||||||
// Word counts
|
// Word counts
|
||||||
projectWordCount: number
|
projectWordCount: number
|
||||||
setProjectWordCount: (count: number) => void
|
setProjectWordCount: (count: number) => void
|
||||||
@@ -52,6 +55,37 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
fileTree: [],
|
fileTree: [],
|
||||||
setFileTree: (fileTree) => set({ fileTree }),
|
setFileTree: (fileTree) => set({ fileTree }),
|
||||||
|
|
||||||
|
moveNode: (dirPath, fromIdx, toIdx) => {
|
||||||
|
set((s) => {
|
||||||
|
let newTree: typeof s.fileTree
|
||||||
|
|
||||||
|
if (dirPath === '__root__') {
|
||||||
|
newTree = [...s.fileTree]
|
||||||
|
const [moved] = newTree.splice(fromIdx, 1)
|
||||||
|
newTree.splice(toIdx, 0, moved)
|
||||||
|
} else {
|
||||||
|
newTree = s.fileTree.map((node) => {
|
||||||
|
if (node.path !== dirPath || !node.children) return node
|
||||||
|
const children = [...node.children]
|
||||||
|
const [moved] = children.splice(fromIdx, 1)
|
||||||
|
children.splice(toIdx, 0, moved)
|
||||||
|
return { ...node, children }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderMap: Record<string, string[]> = {}
|
||||||
|
orderMap['__root__'] = newTree.map((n) => n.name)
|
||||||
|
for (const node of newTree) {
|
||||||
|
if (node.type === 'directory' && node.children) {
|
||||||
|
orderMap[node.path] = node.children.map((c) => c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.api.saveOrder(orderMap).catch(console.error)
|
||||||
|
|
||||||
|
return { fileTree: newTree }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
activeFilePath: null,
|
activeFilePath: null,
|
||||||
activeFileContent: '',
|
activeFileContent: '',
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
|
|||||||
1
src/renderer/types/global.d.ts
vendored
1
src/renderer/types/global.d.ts
vendored
@@ -12,6 +12,7 @@ declare global {
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
removeAIListener: () => void
|
removeAIListener: () => void
|
||||||
getProjectWordCount: () => Promise<number>
|
getProjectWordCount: () => Promise<number>
|
||||||
|
saveOrder: (order: Record<string, string[]>) => Promise<void>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user