idea-manager 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/build-manifest.json +2 -2
- package/.next/prerender-manifest.json +3 -3
- package/.next/required-server-files.js +5 -0
- package/.next/required-server-files.json +5 -0
- package/.next/routes-manifest.json +10 -0
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +2 -2
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/archive/route.js +34 -4
- package/.next/server/app/api/archive/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/filesystem/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/filesystem/tree/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/global-memo/route.js +34 -4
- package/.next/server/app/api/global-memo/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/health/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/apply-distribute/route.js +6 -82
- package/.next/server/app/api/projects/[id]/apply-distribute/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/auto-distribute/route.js +6 -6
- package/.next/server/app/api/projects/[id]/auto-distribute/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/brainstorm/route.js +1 -77
- package/.next/server/app/api/projects/[id]/brainstorm/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/git-sync/route.js +1 -77
- package/.next/server/app/api/projects/[id]/git-sync/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/route.js +1 -77
- package/.next/server/app/api/projects/[id]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/route.js +38 -8
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.js +15 -10
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.js +34 -4
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route.js +26 -0
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.js +34 -4
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/route.js +34 -4
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/route.js +38 -8
- package/.next/server/app/api/projects/[id]/sub-projects/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/route.js +1 -77
- package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/sync/route.js +34 -4
- package/.next/server/app/api/sync/route_client-reference-manifest.js +1 -1
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +3 -3
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +15 -6
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/projects/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +9 -8
- package/.next/server/chunks/117.js +107 -0
- package/.next/server/pages/404.html +2 -2
- package/.next/server/pages/500.html +2 -2
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/363642f4-9eb39e0bc542c65b.js +1 -0
- package/.next/static/chunks/374-23189d7e246ad164.js +1 -0
- package/.next/static/chunks/app/_global-error/page-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/archive/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/filesystem/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/filesystem/tree/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/global-memo/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/health/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/git-sync/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/projects/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/api/sync/route-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/app/page-6a511af64da7531f.js +28 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-6ec0e723e471f87a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-6ec0e723e471f87a.js +1 -0
- package/.next/static/css/cc32379d0efa7d1d.css +3 -0
- package/next.config.mjs +3 -0
- package/package.json +11 -6
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +9 -5
- package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route.ts +76 -0
- package/src/components/dashboard/DashboardPanel.tsx +1 -1
- package/src/components/dashboard/SubProjectCard.tsx +1 -0
- package/src/components/task/CommandPalette.tsx +137 -0
- package/src/components/task/NoteEditor.tsx +411 -0
- package/src/components/task/ProjectTree.tsx +1 -1
- package/src/components/task/StatusFlow.tsx +43 -20
- package/src/components/task/TaskChat.tsx +7 -7
- package/src/components/task/TaskDetail.tsx +270 -89
- package/src/components/task/TaskList.tsx +1 -1
- package/src/components/workspace/WorkspacePanel.tsx +8 -3
- package/src/lib/ai/agents.ts +3 -3
- package/src/lib/ai/client.ts +3 -1
- package/src/lib/db/index.ts +4 -1
- package/src/lib/db/queries/sub-projects.ts +3 -3
- package/src/lib/db/queries/tasks.ts +1 -1
- package/src/lib/db/schema.ts +60 -1
- package/src/types/index.ts +3 -1
- package/.next/server/chunks/806.js +0 -77
- package/.next/static/chunks/151-332d463cd8bd4db6.js +0 -1
- package/.next/static/chunks/app/_global-error/page-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/archive/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/filesystem/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/filesystem/tree/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/global-memo/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/health/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/git-sync/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/projects/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/api/sync/route-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/app/page-d0d563bda0034c18.js +0 -19
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-fd75b71b49e9729e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-fd75b71b49e9729e.js +0 -1
- package/.next/static/css/22a3bf63fb41db4f.css +0 -3
- /package/.next/static/{3dIOxF31xgLe9pGE0yrsa → 63zinfEtSLCdG9nUZ3W-E}/_buildManifest.js +0 -0
- /package/.next/static/{3dIOxF31xgLe9pGE0yrsa → 63zinfEtSLCdG9nUZ3W-E}/_ssgManifest.js +0 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { getTaskConversations, addTaskConversation } from '@/lib/db/queries/task-conversations';
|
|
3
3
|
import { getTask } from '@/lib/db/queries/tasks';
|
|
4
|
-
import { getTaskPrompt } from '@/lib/db/queries/task-prompts';
|
|
5
4
|
import { getBrainstorm } from '@/lib/db/queries/brainstorms';
|
|
6
5
|
import { getProject } from '@/lib/db/queries/projects';
|
|
7
6
|
import { runAgent } from '@/lib/ai/client';
|
|
@@ -39,18 +38,23 @@ export async function POST(
|
|
|
39
38
|
|
|
40
39
|
// Build context for AI
|
|
41
40
|
const history = getTaskConversations(taskId);
|
|
42
|
-
const prompt = getTaskPrompt(taskId);
|
|
43
41
|
const brainstorm = getBrainstorm(projectId);
|
|
44
42
|
const project = getProject(projectId);
|
|
45
43
|
|
|
46
44
|
const aiPolicy = project?.ai_context ? `\n\nProject AI Policy:\n${project.ai_context}` : '';
|
|
47
45
|
|
|
48
|
-
const systemPrompt =
|
|
46
|
+
const systemPrompt = `당신은 사용자가 자기 태스크 "노트"를 다듬는 것을 돕는 보조자입니다.
|
|
47
|
+
사용자는 터미널 Claude Code에서 실제 작업을 수행하며, IM에서는 태스크의 맥락·배경·결정사항·질문 등을 자유롭게 메모합니다.
|
|
48
|
+
당신의 역할:
|
|
49
|
+
- 사용자가 질문하면 간결하게 답한다 (긴 설교 금지)
|
|
50
|
+
- 사용자가 "이 부분 정리해줘" 같은 요청을 하면 노트에 바로 삽입 가능한 형태(마크다운)로 답한다
|
|
51
|
+
- 공식 프롬프트를 만들려 하지 말 것. 사용자의 생각을 **정리·명확화**하는 역할만
|
|
52
|
+
응답은 한국어로.
|
|
49
53
|
${aiPolicy}
|
|
50
54
|
Task: ${task.title}
|
|
51
|
-
|
|
55
|
+
Note(현재):
|
|
56
|
+
${task.description || '(비어있음)'}
|
|
52
57
|
Status: ${task.status}
|
|
53
|
-
${prompt?.content ? `Current prompt:\n${prompt.content}` : ''}
|
|
54
58
|
${brainstorm?.content ? `\nBrainstorming context:\n${brainstorm.content.slice(0, 3000)}` : ''}`;
|
|
55
59
|
|
|
56
60
|
const conversationText = history
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getTask } from '@/lib/db/queries/tasks';
|
|
3
|
+
import { getProject } from '@/lib/db/queries/projects';
|
|
4
|
+
import { runAgent } from '@/lib/ai/client';
|
|
5
|
+
import { ensureDb } from '@/lib/db';
|
|
6
|
+
|
|
7
|
+
type RefineCommand = 'continue' | 'tidy' | 'split' | 'to-questions' | 'summarize' | 'custom';
|
|
8
|
+
|
|
9
|
+
function buildInstruction(cmd: RefineCommand, customText?: string): string {
|
|
10
|
+
switch (cmd) {
|
|
11
|
+
case 'continue':
|
|
12
|
+
return '아래 노트의 흐름을 자연스럽게 이어서 덧붙일 한 단락(또는 bullet 몇 개)을 마크다운으로 작성하세요. 설명은 빼고 이어질 내용만 출력.';
|
|
13
|
+
case 'tidy':
|
|
14
|
+
return '아래 선택 영역의 뜻을 바꾸지 않고 깔끔하게 다듬어 주세요. 설명 없이 다듬어진 본문만 마크다운으로 출력.';
|
|
15
|
+
case 'split':
|
|
16
|
+
return '아래 선택 영역(또는 노트 전체)을 구체적인 할 일 단위의 체크박스 리스트로 변환하세요. 각 항목은 "- [ ] "로 시작. 설명 없이 리스트만 출력.';
|
|
17
|
+
case 'to-questions':
|
|
18
|
+
return '아래 내용에서 애매하거나 결정이 필요한 부분을 찾아 명확하게 해줄 질문 목록으로 바꿔주세요. "- Q. "로 시작하는 bullet로 출력. 설명 생략.';
|
|
19
|
+
case 'summarize':
|
|
20
|
+
return '아래 내용을 3줄 이내로 요약하세요. bullet 3개. 설명 없이 요약만.';
|
|
21
|
+
case 'custom':
|
|
22
|
+
return customText?.trim() || '아래 내용을 개선해주세요.';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function POST(
|
|
27
|
+
request: NextRequest,
|
|
28
|
+
{ params }: { params: Promise<{ id: string; subId: string; taskId: string }> },
|
|
29
|
+
) {
|
|
30
|
+
await ensureDb();
|
|
31
|
+
const { id: projectId, taskId } = await params;
|
|
32
|
+
const body = await request.json() as { command?: RefineCommand; customText?: string; selection?: string; note?: string };
|
|
33
|
+
|
|
34
|
+
if (!body.command) {
|
|
35
|
+
return NextResponse.json({ error: 'command is required' }, { status: 400 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const task = getTask(taskId);
|
|
39
|
+
if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 });
|
|
40
|
+
|
|
41
|
+
const project = getProject(projectId);
|
|
42
|
+
const aiPolicy = project?.ai_context ? `\n\nProject AI Policy:\n${project.ai_context}` : '';
|
|
43
|
+
const instruction = buildInstruction(body.command, body.customText);
|
|
44
|
+
const selection = body.selection?.trim();
|
|
45
|
+
const note = body.note ?? task.description ?? '';
|
|
46
|
+
|
|
47
|
+
const prompt = `당신은 사용자의 노트 작성을 돕는 보조자입니다. 한국어로 답하세요.
|
|
48
|
+
${aiPolicy}
|
|
49
|
+
|
|
50
|
+
[태스크]
|
|
51
|
+
제목: ${task.title}
|
|
52
|
+
|
|
53
|
+
[명령]
|
|
54
|
+
${instruction}
|
|
55
|
+
|
|
56
|
+
${selection
|
|
57
|
+
? `[선택 영역]\n${selection}\n\n[노트 전체 컨텍스트]\n${note}`
|
|
58
|
+
: `[노트 전체]\n${note}`}
|
|
59
|
+
|
|
60
|
+
중요: 답변은 노트에 그대로 삽입될 마크다운 텍스트만 출력하세요. 설명·전제·서론·결론 금지.`;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const agentType = project?.agent_type || 'claude';
|
|
64
|
+
// Refine is a pure text-tidying task — no repo exploration or tool use.
|
|
65
|
+
// Skip cwd so the project's CLAUDE.md isn't loaded into context, and run
|
|
66
|
+
// against a faster model. Keeps latency well under the 90s budget.
|
|
67
|
+
const result = await runAgent(agentType, prompt, undefined, undefined, {
|
|
68
|
+
timeoutMs: 90000,
|
|
69
|
+
model: 'sonnet',
|
|
70
|
+
});
|
|
71
|
+
return NextResponse.json({ result: result.trim() });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const msg = err instanceof Error ? err.message : 'AI 호출 실패';
|
|
74
|
+
return NextResponse.json({ error: msg }, { status: 500 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -276,7 +276,7 @@ export default function DashboardPanel() {
|
|
|
276
276
|
|
|
277
277
|
const STATUS_FILTERS: Record<string, (t: ITask) => boolean> = {
|
|
278
278
|
total: () => true,
|
|
279
|
-
active: (t) => t.status === 'submitted' || t.status === 'testing',
|
|
279
|
+
active: (t) => t.status === 'doing' || t.status === 'submitted' || t.status === 'testing',
|
|
280
280
|
pending: (t) => t.status === 'idea' || t.status === 'writing',
|
|
281
281
|
done: (t) => t.status === 'done',
|
|
282
282
|
problem: (t) => t.status === 'problem',
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type RefineCommand =
|
|
6
|
+
| 'continue'
|
|
7
|
+
| 'tidy'
|
|
8
|
+
| 'split'
|
|
9
|
+
| 'to-questions'
|
|
10
|
+
| 'summarize'
|
|
11
|
+
| 'custom';
|
|
12
|
+
|
|
13
|
+
const COMMANDS: { key: RefineCommand; label: string; hint: string }[] = [
|
|
14
|
+
{ key: 'continue', label: '이어서 써줘', hint: '커서 앞 맥락을 이어서 자연스럽게 덧붙임' },
|
|
15
|
+
{ key: 'tidy', label: '이 부분 정리해줘', hint: '선택 영역을 깔끔히 다듬음 (의미 유지)' },
|
|
16
|
+
{ key: 'split', label: '할 일로 쪼개줘', hint: '선택 영역을 체크박스 리스트로' },
|
|
17
|
+
{ key: 'to-questions', label: '질문으로 바꿔줘', hint: '모호한 부분을 명확하게 하는 질문 목록으로' },
|
|
18
|
+
{ key: 'summarize', label: '요약해줘', hint: '선택 영역을 3줄 이내로' },
|
|
19
|
+
{ key: 'custom', label: '직접 입력…', hint: '임의 명령을 프롬프트로 전달' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export default function CommandPalette({
|
|
23
|
+
open,
|
|
24
|
+
hasSelection,
|
|
25
|
+
onClose,
|
|
26
|
+
onRun,
|
|
27
|
+
}: {
|
|
28
|
+
open: boolean;
|
|
29
|
+
hasSelection: boolean;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
onRun: (cmd: RefineCommand, customText?: string) => void;
|
|
32
|
+
}) {
|
|
33
|
+
const [idx, setIdx] = useState(0);
|
|
34
|
+
const [customMode, setCustomMode] = useState(false);
|
|
35
|
+
const [custom, setCustom] = useState('');
|
|
36
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (open) {
|
|
40
|
+
setIdx(0);
|
|
41
|
+
setCustomMode(false);
|
|
42
|
+
setCustom('');
|
|
43
|
+
}
|
|
44
|
+
}, [open]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (customMode) inputRef.current?.focus();
|
|
48
|
+
}, [customMode]);
|
|
49
|
+
|
|
50
|
+
if (!open) return null;
|
|
51
|
+
|
|
52
|
+
const runAt = (i: number) => {
|
|
53
|
+
const c = COMMANDS[i];
|
|
54
|
+
if (c.key === 'custom') {
|
|
55
|
+
setCustomMode(true);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
onRun(c.key);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const submitCustom = () => {
|
|
62
|
+
const t = custom.trim();
|
|
63
|
+
if (!t) return;
|
|
64
|
+
onRun('custom', t);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
onClick={onClose}
|
|
70
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[18vh]"
|
|
71
|
+
style={{ background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(2px)' }}
|
|
72
|
+
>
|
|
73
|
+
<div
|
|
74
|
+
onClick={(e) => e.stopPropagation()}
|
|
75
|
+
className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md animate-dialog-in"
|
|
76
|
+
>
|
|
77
|
+
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
|
78
|
+
<div className="text-xs text-muted-foreground">
|
|
79
|
+
{hasSelection ? '선택 영역에 명령 실행' : '커서 위치 기준 명령 실행'}
|
|
80
|
+
</div>
|
|
81
|
+
<button onClick={onClose} className="text-xs text-muted-foreground hover:text-foreground">Esc</button>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{!customMode ? (
|
|
85
|
+
<ul
|
|
86
|
+
onKeyDown={(e) => {
|
|
87
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(i + 1, COMMANDS.length - 1)); }
|
|
88
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
|
|
89
|
+
else if (e.key === 'Enter') { e.preventDefault(); runAt(idx); }
|
|
90
|
+
}}
|
|
91
|
+
tabIndex={0}
|
|
92
|
+
ref={(el) => { el?.focus(); }}
|
|
93
|
+
className="py-1 max-h-[50vh] overflow-y-auto focus:outline-none"
|
|
94
|
+
>
|
|
95
|
+
{COMMANDS.map((c, i) => (
|
|
96
|
+
<li
|
|
97
|
+
key={c.key}
|
|
98
|
+
onMouseEnter={() => setIdx(i)}
|
|
99
|
+
onClick={() => runAt(i)}
|
|
100
|
+
className={`px-4 py-2 cursor-pointer flex flex-col gap-0.5 ${
|
|
101
|
+
i === idx ? 'bg-muted' : ''
|
|
102
|
+
}`}
|
|
103
|
+
>
|
|
104
|
+
<span className="text-sm text-foreground">{c.label}</span>
|
|
105
|
+
<span className="text-xs text-muted-foreground">{c.hint}</span>
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
) : (
|
|
110
|
+
<div className="p-4 flex flex-col gap-3">
|
|
111
|
+
<input
|
|
112
|
+
ref={inputRef}
|
|
113
|
+
value={custom}
|
|
114
|
+
onChange={(e) => setCustom(e.target.value)}
|
|
115
|
+
onKeyDown={(e) => {
|
|
116
|
+
if (e.key === 'Enter') submitCustom();
|
|
117
|
+
if (e.key === 'Escape') onClose();
|
|
118
|
+
}}
|
|
119
|
+
placeholder="예: 이 부분 markdown 표로 만들어줘"
|
|
120
|
+
className="w-full bg-input border border-border rounded-md px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
|
121
|
+
/>
|
|
122
|
+
<div className="flex justify-end gap-2">
|
|
123
|
+
<button onClick={() => setCustomMode(false)} className="text-xs text-muted-foreground px-2 py-1">뒤로</button>
|
|
124
|
+
<button
|
|
125
|
+
onClick={submitCustom}
|
|
126
|
+
disabled={!custom.trim()}
|
|
127
|
+
className="text-xs px-3 py-1 bg-primary text-primary-foreground rounded disabled:opacity-40"
|
|
128
|
+
>
|
|
129
|
+
실행
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useMemo, useRef } from 'react';
|
|
4
|
+
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
|
5
|
+
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
6
|
+
import { EditorView, Decoration, keymap, ViewPlugin, WidgetType } from '@codemirror/view';
|
|
7
|
+
import { EditorState, StateEffect, StateField, Prec } from '@codemirror/state';
|
|
8
|
+
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
9
|
+
import { tags as t } from '@lezer/highlight';
|
|
10
|
+
|
|
11
|
+
// ─────────────────────────────────────────────────────────────
|
|
12
|
+
// Ghost-text state: a decoration widget after the cursor.
|
|
13
|
+
// ─────────────────────────────────────────────────────────────
|
|
14
|
+
const setGhost = StateEffect.define<{ from: number; text: string } | null>();
|
|
15
|
+
|
|
16
|
+
class GhostWidget extends WidgetType {
|
|
17
|
+
constructor(public readonly text: string) { super(); }
|
|
18
|
+
eq(other: GhostWidget) { return other.text === this.text; }
|
|
19
|
+
toDOM() {
|
|
20
|
+
const span = document.createElement('span');
|
|
21
|
+
span.className = 'cm-ghost-text';
|
|
22
|
+
span.textContent = this.text;
|
|
23
|
+
span.style.opacity = '0.35';
|
|
24
|
+
span.style.pointerEvents = 'none';
|
|
25
|
+
return span;
|
|
26
|
+
}
|
|
27
|
+
ignoreEvent() { return true; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ghostField = StateField.define<{ from: number; text: string } | null>({
|
|
31
|
+
create: () => null,
|
|
32
|
+
update(value, tr) {
|
|
33
|
+
for (const e of tr.effects) {
|
|
34
|
+
if (e.is(setGhost)) return e.value;
|
|
35
|
+
}
|
|
36
|
+
// Any doc change or selection move clears ghost unless effect explicitly set it
|
|
37
|
+
if (tr.docChanged || tr.selection) return null;
|
|
38
|
+
return value;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const ghostDecorations = EditorView.decorations.compute([ghostField], (state) => {
|
|
43
|
+
const g = state.field(ghostField);
|
|
44
|
+
if (!g) return Decoration.none;
|
|
45
|
+
return Decoration.set([
|
|
46
|
+
Decoration.widget({ widget: new GhostWidget(g.text), side: 1 }).range(g.from),
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ─────────────────────────────────────────────────────────────
|
|
51
|
+
// Local autocomplete: suggest multi-word phrases sourced from the
|
|
52
|
+
// current doc + an optional wider corpus (sibling tasks, brainstorm).
|
|
53
|
+
// Phrases are segmented by punctuation; longer repeated n-grams win
|
|
54
|
+
// over single-word matches when they appear often.
|
|
55
|
+
// ─────────────────────────────────────────────────────────────
|
|
56
|
+
const CURRENT_DOC_WEIGHT = 3;
|
|
57
|
+
const MAX_PHRASE_LEN = 3;
|
|
58
|
+
const MAX_GHOST_CHARS = 40;
|
|
59
|
+
const TOKEN_MIN_LEN = 2;
|
|
60
|
+
const SEGMENT_SPLIT = /[.!?,;:·\n\r()[\]{}"]+/;
|
|
61
|
+
|
|
62
|
+
interface Segment { tokens: string[]; weight: number }
|
|
63
|
+
|
|
64
|
+
function tokenizeSegments(text: string): string[][] {
|
|
65
|
+
const out: string[][] = [];
|
|
66
|
+
for (const raw of text.split(SEGMENT_SPLIT)) {
|
|
67
|
+
const tokens = raw.trim().split(/\s+/).filter(t => t.length >= TOKEN_MIN_LEN);
|
|
68
|
+
if (tokens.length) out.push(tokens);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cache extra-corpus segmentation by array identity to avoid re-splitting
|
|
74
|
+
// a potentially large text blob on every keystroke.
|
|
75
|
+
let cachedExtraRef: string[] | null = null;
|
|
76
|
+
let cachedExtraSegs: Segment[] = [];
|
|
77
|
+
function getExtraSegments(extraCorpus: string[]): Segment[] {
|
|
78
|
+
if (extraCorpus === cachedExtraRef) return cachedExtraSegs;
|
|
79
|
+
const segs: Segment[] = [];
|
|
80
|
+
for (const text of extraCorpus) {
|
|
81
|
+
for (const toks of tokenizeSegments(text)) segs.push({ tokens: toks, weight: 1 });
|
|
82
|
+
}
|
|
83
|
+
cachedExtraRef = extraCorpus;
|
|
84
|
+
cachedExtraSegs = segs;
|
|
85
|
+
return segs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildSegments(doc: string, extraCorpus: string[]): Segment[] {
|
|
89
|
+
const out: Segment[] = [];
|
|
90
|
+
for (const toks of tokenizeSegments(doc)) out.push({ tokens: toks, weight: CURRENT_DOC_WEIGHT });
|
|
91
|
+
return out.concat(getExtraSegments(extraCorpus));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function caretPrefix(state: EditorState): { word: string; from: number } | null {
|
|
95
|
+
const pos = state.selection.main.head;
|
|
96
|
+
const line = state.doc.lineAt(pos);
|
|
97
|
+
const col = pos - line.from;
|
|
98
|
+
const before = line.text.slice(0, col);
|
|
99
|
+
const after = line.text.slice(col);
|
|
100
|
+
// Don't suggest when the caret sits inside a word — would duplicate the
|
|
101
|
+
// trailing part (e.g. "안녕하|세요" + ghost "세요" → "안녕하세요세요").
|
|
102
|
+
if (/^[A-Za-z가-힣\w_-]/.test(after)) return null;
|
|
103
|
+
const m = before.match(/([A-Za-z가-힣][\w가-힣_-]*)$/);
|
|
104
|
+
if (!m) return null;
|
|
105
|
+
return { word: m[1], from: pos - m[1].length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Gather vocabulary already present in the current doc. Segments from the
|
|
109
|
+
// extra corpus that share vocabulary with this set are considered "topically
|
|
110
|
+
// related" and receive a score boost — so if the user is writing about Pods,
|
|
111
|
+
// "Pod"-adjacent completions win over unrelated ones.
|
|
112
|
+
function buildContextVocabulary(doc: string): Set<string> {
|
|
113
|
+
const set = new Set<string>();
|
|
114
|
+
const matches = doc.toLowerCase().match(/[a-z가-힣][\w가-힣_-]{1,}/g);
|
|
115
|
+
if (!matches) return set;
|
|
116
|
+
for (const w of matches) {
|
|
117
|
+
if (w.length >= TOKEN_MIN_LEN) set.add(w);
|
|
118
|
+
}
|
|
119
|
+
return set;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function computeLocalGhost(
|
|
123
|
+
state: EditorState,
|
|
124
|
+
extraCorpus: string[],
|
|
125
|
+
): { from: number; text: string } | null {
|
|
126
|
+
const ctx = caretPrefix(state);
|
|
127
|
+
if (!ctx || ctx.word.length < 2) return null;
|
|
128
|
+
const doc = state.doc.toString();
|
|
129
|
+
const segments = buildSegments(doc, extraCorpus);
|
|
130
|
+
const prefix = ctx.word.toLowerCase();
|
|
131
|
+
const contextVocab = buildContextVocabulary(doc);
|
|
132
|
+
|
|
133
|
+
// Tally candidate completions by the full tail text that would be inserted.
|
|
134
|
+
// Longer phrases get a small length bonus so "Claude Code" beats "Claude"
|
|
135
|
+
// when both appear with equal frequency. Segments that share vocabulary
|
|
136
|
+
// with the current doc get a relevance boost (up to 2×) so topically
|
|
137
|
+
// related completions surface first.
|
|
138
|
+
const scores = new Map<string, number>();
|
|
139
|
+
for (const seg of segments) {
|
|
140
|
+
let overlap = 0;
|
|
141
|
+
for (const tk of seg.tokens) {
|
|
142
|
+
if (contextVocab.has(tk.toLowerCase())) overlap++;
|
|
143
|
+
}
|
|
144
|
+
const relevance = 1 + Math.min(overlap * 0.25, 1.0);
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < seg.tokens.length; i++) {
|
|
147
|
+
const first = seg.tokens[i];
|
|
148
|
+
if (first.length <= ctx.word.length) continue;
|
|
149
|
+
if (!first.toLowerCase().startsWith(prefix)) continue;
|
|
150
|
+
if (first === ctx.word) continue;
|
|
151
|
+
|
|
152
|
+
const maxExtra = Math.min(MAX_PHRASE_LEN - 1, seg.tokens.length - i - 1);
|
|
153
|
+
for (let n = 0; n <= maxExtra; n++) {
|
|
154
|
+
const tail = n === 0
|
|
155
|
+
? first.slice(ctx.word.length)
|
|
156
|
+
: `${first.slice(ctx.word.length)} ${seg.tokens.slice(i + 1, i + 1 + n).join(' ')}`;
|
|
157
|
+
if (tail.length > MAX_GHOST_CHARS) break;
|
|
158
|
+
const lengthBonus = 1 + n * 0.5;
|
|
159
|
+
scores.set(tail, (scores.get(tail) ?? 0) + seg.weight * lengthBonus * relevance);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!scores.size) return null;
|
|
165
|
+
// On exact tie, prefer later-inserted (longer) phrases using `>=`.
|
|
166
|
+
let best: { text: string; score: number } | null = null;
|
|
167
|
+
for (const [text, score] of scores) {
|
|
168
|
+
if (!best || score >= best.score) best = { text, score };
|
|
169
|
+
}
|
|
170
|
+
if (!best) return null;
|
|
171
|
+
return { from: state.selection.main.head, text: best.text };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createLocalCompletionPlugin(corpusRef: { current: string[] }) {
|
|
175
|
+
return ViewPlugin.fromClass(class {
|
|
176
|
+
timer: ReturnType<typeof setTimeout> | null = null;
|
|
177
|
+
constructor(public view: EditorView) {}
|
|
178
|
+
update(u: { docChanged: boolean; selectionSet: boolean; state: EditorState; view: EditorView }) {
|
|
179
|
+
if (!u.docChanged && !u.selectionSet) return;
|
|
180
|
+
if (this.timer) clearTimeout(this.timer);
|
|
181
|
+
this.timer = setTimeout(() => {
|
|
182
|
+
const ghost = computeLocalGhost(u.view.state, corpusRef.current);
|
|
183
|
+
u.view.dispatch({ effects: setGhost.of(ghost) });
|
|
184
|
+
}, 120);
|
|
185
|
+
}
|
|
186
|
+
destroy() {
|
|
187
|
+
if (this.timer) clearTimeout(this.timer);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Accept-ghost command — bound to Tab, only when ghost exists
|
|
193
|
+
function acceptGhost(view: EditorView): boolean {
|
|
194
|
+
const ghost = view.state.field(ghostField, false);
|
|
195
|
+
if (!ghost) return false;
|
|
196
|
+
view.dispatch({
|
|
197
|
+
changes: { from: ghost.from, insert: ghost.text },
|
|
198
|
+
selection: { anchor: ghost.from + ghost.text.length },
|
|
199
|
+
effects: setGhost.of(null),
|
|
200
|
+
});
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function dismissGhost(view: EditorView): boolean {
|
|
205
|
+
const ghost = view.state.field(ghostField, false);
|
|
206
|
+
if (!ghost) return false;
|
|
207
|
+
view.dispatch({ effects: setGhost.of(null) });
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─────────────────────────────────────────────────────────────
|
|
212
|
+
// Markdown list / checkbox continuation on Enter.
|
|
213
|
+
// ─────────────────────────────────────────────────────────────
|
|
214
|
+
function continueList(view: EditorView): boolean {
|
|
215
|
+
const { state } = view;
|
|
216
|
+
const pos = state.selection.main.head;
|
|
217
|
+
const line = state.doc.lineAt(pos);
|
|
218
|
+
// Only when caret is at end of line
|
|
219
|
+
if (pos !== line.to) return false;
|
|
220
|
+
const text = line.text;
|
|
221
|
+
|
|
222
|
+
const bullet = text.match(/^(\s*)([-*+])\s(\[[ xX]\]\s)?(.*)$/);
|
|
223
|
+
const ordered = text.match(/^(\s*)(\d+)\.\s(.*)$/);
|
|
224
|
+
|
|
225
|
+
if (bullet) {
|
|
226
|
+
const [, indent, mark, check, content] = bullet;
|
|
227
|
+
if (!content.trim()) {
|
|
228
|
+
// Empty bullet → exit list
|
|
229
|
+
view.dispatch({
|
|
230
|
+
changes: { from: line.from, to: line.to, insert: indent },
|
|
231
|
+
selection: { anchor: line.from + indent.length },
|
|
232
|
+
});
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
const prefix = check ? `${indent}${mark} [ ] ` : `${indent}${mark} `;
|
|
236
|
+
view.dispatch({
|
|
237
|
+
changes: { from: pos, insert: `\n${prefix}` },
|
|
238
|
+
selection: { anchor: pos + 1 + prefix.length },
|
|
239
|
+
});
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (ordered) {
|
|
243
|
+
const [, indent, numStr, content] = ordered;
|
|
244
|
+
const num = parseInt(numStr, 10);
|
|
245
|
+
if (!content.trim()) {
|
|
246
|
+
view.dispatch({
|
|
247
|
+
changes: { from: line.from, to: line.to, insert: indent },
|
|
248
|
+
selection: { anchor: line.from + indent.length },
|
|
249
|
+
});
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
const prefix = `${indent}${num + 1}. `;
|
|
253
|
+
view.dispatch({
|
|
254
|
+
changes: { from: pos, insert: `\n${prefix}` },
|
|
255
|
+
selection: { anchor: pos + 1 + prefix.length },
|
|
256
|
+
});
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─────────────────────────────────────────────────────────────
|
|
263
|
+
// Markdown syntax highlighting — explicit Lezer-tag mapping so list
|
|
264
|
+
// marks, headings and inline code stand out clearly against plain
|
|
265
|
+
// text. `t.processingInstruction` covers ATX heading `#`s, list
|
|
266
|
+
// bullets (`-` `*` `+`), ordered list `1.`, quote `>`, emphasis `*`
|
|
267
|
+
// and link brackets — all of which we want to visually punctuate.
|
|
268
|
+
// ─────────────────────────────────────────────────────────────
|
|
269
|
+
const mdHighlight = HighlightStyle.define([
|
|
270
|
+
{ tag: t.heading1, color: 'hsl(var(--foreground))', fontWeight: '800', fontSize: '1.35em' },
|
|
271
|
+
{ tag: t.heading2, color: 'hsl(var(--foreground))', fontWeight: '700', fontSize: '1.2em' },
|
|
272
|
+
{ tag: t.heading3, color: 'hsl(var(--foreground))', fontWeight: '700', fontSize: '1.08em' },
|
|
273
|
+
{ tag: [t.heading4, t.heading5, t.heading6], color: 'hsl(var(--foreground))', fontWeight: '700' },
|
|
274
|
+
{ tag: t.processingInstruction, color: 'hsl(var(--accent))', fontWeight: '700' },
|
|
275
|
+
{ tag: t.list, color: 'hsl(var(--foreground))' },
|
|
276
|
+
{ tag: t.emphasis, fontStyle: 'italic', color: 'hsl(var(--foreground))' },
|
|
277
|
+
{ tag: t.strong, fontWeight: '700', color: 'hsl(var(--foreground))' },
|
|
278
|
+
{ tag: [t.link, t.url], color: 'hsl(var(--primary))', textDecoration: 'underline' },
|
|
279
|
+
{ tag: t.monospace, color: 'hsl(var(--warning))' },
|
|
280
|
+
{ tag: t.quote, color: 'hsl(var(--muted-foreground))', fontStyle: 'italic' },
|
|
281
|
+
{ tag: t.contentSeparator, color: 'hsl(var(--border))' },
|
|
282
|
+
{ tag: t.strikethrough, textDecoration: 'line-through', color: 'hsl(var(--muted-foreground))' },
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
// ─────────────────────────────────────────────────────────────
|
|
286
|
+
// Component
|
|
287
|
+
// ─────────────────────────────────────────────────────────────
|
|
288
|
+
export interface NoteEditorProps {
|
|
289
|
+
value: string;
|
|
290
|
+
onChange: (v: string) => void;
|
|
291
|
+
onBlur?: () => void;
|
|
292
|
+
onOpenCommand?: () => void;
|
|
293
|
+
placeholder?: string;
|
|
294
|
+
/** Extra text blobs (sibling tasks, brainstorm, …) to widen the autocomplete corpus. */
|
|
295
|
+
extraCorpus?: string[];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const NoteEditor = forwardRef<ReactCodeMirrorRef, NoteEditorProps>(function NoteEditor(
|
|
299
|
+
{ value, onChange, onBlur, onOpenCommand, placeholder, extraCorpus },
|
|
300
|
+
ref,
|
|
301
|
+
) {
|
|
302
|
+
// Mutable ref keeps the plugin in sync with the latest corpus without
|
|
303
|
+
// rebuilding the extension list (which would re-init CodeMirror).
|
|
304
|
+
const corpusRef = useRef<string[]>(extraCorpus ?? []);
|
|
305
|
+
corpusRef.current = extraCorpus ?? [];
|
|
306
|
+
|
|
307
|
+
const extensions = useMemo(() => [
|
|
308
|
+
markdown({ base: markdownLanguage }),
|
|
309
|
+
syntaxHighlighting(mdHighlight),
|
|
310
|
+
ghostField,
|
|
311
|
+
ghostDecorations,
|
|
312
|
+
createLocalCompletionPlugin(corpusRef),
|
|
313
|
+
Prec.highest(keymap.of([
|
|
314
|
+
{ key: 'Tab', run: acceptGhost },
|
|
315
|
+
{ key: 'Escape', run: dismissGhost },
|
|
316
|
+
{ key: 'Enter', run: continueList },
|
|
317
|
+
{ key: 'Mod-k', run: () => { onOpenCommand?.(); return true; } },
|
|
318
|
+
])),
|
|
319
|
+
EditorView.lineWrapping,
|
|
320
|
+
EditorView.theme({
|
|
321
|
+
'&': {
|
|
322
|
+
fontSize: '13px',
|
|
323
|
+
backgroundColor: 'transparent',
|
|
324
|
+
color: 'hsl(var(--foreground))',
|
|
325
|
+
height: '100%',
|
|
326
|
+
},
|
|
327
|
+
'.cm-editor': { backgroundColor: 'transparent' },
|
|
328
|
+
'&.cm-focused': { outline: 'none' },
|
|
329
|
+
'.cm-scroller': {
|
|
330
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
331
|
+
lineHeight: '1.7',
|
|
332
|
+
backgroundColor: 'transparent',
|
|
333
|
+
},
|
|
334
|
+
'.cm-content': {
|
|
335
|
+
padding: '12px 16px',
|
|
336
|
+
caretColor: 'hsl(var(--primary))',
|
|
337
|
+
color: 'hsl(var(--foreground))',
|
|
338
|
+
},
|
|
339
|
+
'.cm-gutters': { display: 'none' },
|
|
340
|
+
'.cm-activeLine': { backgroundColor: 'transparent' },
|
|
341
|
+
'.cm-activeLineGutter': { backgroundColor: 'transparent' },
|
|
342
|
+
'.cm-line': { backgroundColor: 'transparent' },
|
|
343
|
+
'.cm-selectionLayer .cm-selectionBackground, .cm-content ::selection, ::selection': {
|
|
344
|
+
backgroundColor: 'hsl(var(--primary) / 0.25)',
|
|
345
|
+
},
|
|
346
|
+
'&.cm-focused .cm-selectionBackground': {
|
|
347
|
+
backgroundColor: 'hsl(var(--primary) / 0.3)',
|
|
348
|
+
},
|
|
349
|
+
'.cm-cursor, .cm-dropCursor': {
|
|
350
|
+
borderLeftColor: 'hsl(var(--primary))',
|
|
351
|
+
borderLeftWidth: '2px',
|
|
352
|
+
},
|
|
353
|
+
'.cm-ghost-text': {
|
|
354
|
+
color: 'hsl(var(--muted-foreground))',
|
|
355
|
+
opacity: '0.55',
|
|
356
|
+
fontStyle: 'italic',
|
|
357
|
+
},
|
|
358
|
+
'.cm-placeholder': {
|
|
359
|
+
color: 'hsl(var(--muted-foreground) / 0.55)',
|
|
360
|
+
whiteSpace: 'normal',
|
|
361
|
+
display: 'inline-block',
|
|
362
|
+
maxWidth: '90%',
|
|
363
|
+
lineHeight: '1.6',
|
|
364
|
+
},
|
|
365
|
+
// Markdown syntax coloring (one-dark style tuned to IM palette)
|
|
366
|
+
'.tok-heading, .tok-heading1, .tok-heading2, .tok-heading3, .tok-heading4, .tok-heading5, .tok-heading6': {
|
|
367
|
+
color: 'hsl(var(--foreground))',
|
|
368
|
+
fontWeight: '700',
|
|
369
|
+
},
|
|
370
|
+
'.tok-emphasis': { fontStyle: 'italic', color: 'hsl(var(--foreground))' },
|
|
371
|
+
'.tok-strong': { fontWeight: '700', color: 'hsl(var(--foreground))' },
|
|
372
|
+
'.tok-link': { color: 'hsl(var(--primary))', textDecoration: 'underline' },
|
|
373
|
+
'.tok-url': { color: 'hsl(var(--primary))' },
|
|
374
|
+
'.tok-monospace, .tok-literal': {
|
|
375
|
+
color: 'hsl(var(--warning))',
|
|
376
|
+
backgroundColor: 'hsl(var(--muted) / 0.5)',
|
|
377
|
+
padding: '0 3px',
|
|
378
|
+
borderRadius: '3px',
|
|
379
|
+
},
|
|
380
|
+
'.tok-list': { color: 'hsl(var(--accent))' },
|
|
381
|
+
'.tok-quote': { color: 'hsl(var(--muted-foreground))', fontStyle: 'italic' },
|
|
382
|
+
'.tok-comment, .tok-meta': { color: 'hsl(var(--muted-foreground))' },
|
|
383
|
+
}, { dark: true }),
|
|
384
|
+
], [onOpenCommand]);
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div className="h-full w-full">
|
|
388
|
+
<CodeMirror
|
|
389
|
+
ref={ref}
|
|
390
|
+
value={value}
|
|
391
|
+
onChange={onChange}
|
|
392
|
+
onBlur={onBlur}
|
|
393
|
+
extensions={extensions}
|
|
394
|
+
theme="none"
|
|
395
|
+
basicSetup={{
|
|
396
|
+
lineNumbers: false,
|
|
397
|
+
foldGutter: false,
|
|
398
|
+
highlightActiveLine: false,
|
|
399
|
+
highlightActiveLineGutter: false,
|
|
400
|
+
autocompletion: false,
|
|
401
|
+
searchKeymap: false,
|
|
402
|
+
}}
|
|
403
|
+
placeholder={placeholder}
|
|
404
|
+
height="100%"
|
|
405
|
+
style={{ height: '100%' }}
|
|
406
|
+
/>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
export default NoteEditor;
|