@liminis/editor 0.1.0-rc.1
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/LICENSE +34 -0
- package/README.md +376 -0
- package/dist/annotations/anchor-align.d.ts +50 -0
- package/dist/annotations/anchor-align.js +74 -0
- package/dist/annotations/anchor-model.d.ts +81 -0
- package/dist/annotations/anchor-model.js +80 -0
- package/dist/annotations/anchor-resolver.d.ts +92 -0
- package/dist/annotations/anchor-resolver.js +231 -0
- package/dist/annotations/block-structure.d.ts +65 -0
- package/dist/annotations/block-structure.js +113 -0
- package/dist/annotations/types.d.ts +159 -0
- package/dist/annotations/types.js +50 -0
- package/dist/annotations.d.ts +25 -0
- package/dist/annotations.js +27 -0
- package/dist/app/App.d.ts +64 -0
- package/dist/app/App.js +180 -0
- package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
- package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
- package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
- package/dist/app/editor/AnchorScrollPlugin.js +134 -0
- package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
- package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
- package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
- package/dist/app/editor/AnnotationPlugin.js +135 -0
- package/dist/app/editor/AnnotationSurface.d.ts +34 -0
- package/dist/app/editor/AnnotationSurface.js +115 -0
- package/dist/app/editor/AssetContext.d.ts +20 -0
- package/dist/app/editor/AssetContext.js +81 -0
- package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
- package/dist/app/editor/BlockClickPlugin.js +126 -0
- package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
- package/dist/app/editor/CodeBlockPlugin.js +219 -0
- package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
- package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
- package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
- package/dist/app/editor/DragHandlePlugin.js +300 -0
- package/dist/app/editor/Editor.d.ts +84 -0
- package/dist/app/editor/Editor.js +539 -0
- package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
- package/dist/app/editor/FrontmatterPlugin.js +301 -0
- package/dist/app/editor/ImageModal.d.ts +13 -0
- package/dist/app/editor/ImageModal.js +267 -0
- package/dist/app/editor/ImagePlugin.d.ts +3 -0
- package/dist/app/editor/ImagePlugin.js +187 -0
- package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
- package/dist/app/editor/LinkClickPlugin.js +90 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
- package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
- package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
- package/dist/app/editor/SearchPlugin.d.ts +1 -0
- package/dist/app/editor/SearchPlugin.js +379 -0
- package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
- package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
- package/dist/app/editor/SlashMenu.d.ts +11 -0
- package/dist/app/editor/SlashMenu.js +494 -0
- package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
- package/dist/app/editor/SlashMenuPlugin.js +168 -0
- package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
- package/dist/app/editor/TableActionsPlugin.js +398 -0
- package/dist/app/editor/TogglePlugin.d.ts +1 -0
- package/dist/app/editor/TogglePlugin.js +159 -0
- package/dist/app/editor/Toolbar.d.ts +10 -0
- package/dist/app/editor/Toolbar.js +263 -0
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
- package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
- package/dist/app/editor/annotation-marks.d.ts +177 -0
- package/dist/app/editor/annotation-marks.js +750 -0
- package/dist/app/editor/annotationCommands.d.ts +13 -0
- package/dist/app/editor/annotationCommands.js +11 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
- package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
- package/dist/app/editor/c4/edge-clipping.js +139 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
- package/dist/app/editor/c4/layout.d.ts +25 -0
- package/dist/app/editor/c4/layout.js +839 -0
- package/dist/app/editor/c4/parser.d.ts +19 -0
- package/dist/app/editor/c4/parser.js +410 -0
- package/dist/app/editor/c4/render-to-string.d.ts +24 -0
- package/dist/app/editor/c4/render-to-string.js +34 -0
- package/dist/app/editor/c4/renderer.d.ts +64 -0
- package/dist/app/editor/c4/renderer.js +569 -0
- package/dist/app/editor/c4/types.d.ts +203 -0
- package/dist/app/editor/c4/types.js +43 -0
- package/dist/app/editor/correction-yaml.d.ts +10 -0
- package/dist/app/editor/correction-yaml.js +40 -0
- package/dist/app/editor/editorNodes.d.ts +9 -0
- package/dist/app/editor/editorNodes.js +36 -0
- package/dist/app/editor/index.d.ts +1 -0
- package/dist/app/editor/index.js +1 -0
- package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
- package/dist/app/editor/mark-overlap-resolver.js +45 -0
- package/dist/app/editor/nodes/C4Component.d.ts +14 -0
- package/dist/app/editor/nodes/C4Component.js +284 -0
- package/dist/app/editor/nodes/C4Node.d.ts +32 -0
- package/dist/app/editor/nodes/C4Node.js +112 -0
- package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
- package/dist/app/editor/nodes/CalloutNode.js +94 -0
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
- package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
- package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
- package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
- package/dist/app/editor/nodes/CustomListNode.js +54 -0
- package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
- package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
- package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
- package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
- package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
- package/dist/app/editor/nodes/EquationComponent.js +130 -0
- package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
- package/dist/app/editor/nodes/EquationNode.js +179 -0
- package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
- package/dist/app/editor/nodes/FootnoteNode.js +151 -0
- package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
- package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
- package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
- package/dist/app/editor/nodes/HtmlNode.js +121 -0
- package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
- package/dist/app/editor/nodes/ImageComponent.js +228 -0
- package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
- package/dist/app/editor/nodes/ImageNode.js +183 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
- package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
- package/dist/app/editor/nodes/MermaidComponent.js +216 -0
- package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
- package/dist/app/editor/nodes/MermaidNode.js +96 -0
- package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
- package/dist/app/editor/nodes/ToggleNode.js +237 -0
- package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
- package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
- package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
- package/dist/app/editor/nodes/diagram-utils.js +29 -0
- package/dist/app/editor/nodes/index.d.ts +30 -0
- package/dist/app/editor/nodes/index.js +17 -0
- package/dist/app/editor/utils.d.ts +2 -0
- package/dist/app/editor/utils.js +17 -0
- package/dist/app/mapper/index.d.ts +3 -0
- package/dist/app/mapper/index.js +2 -0
- package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
- package/dist/app/mapper/lexicalToMdast.js +1937 -0
- package/dist/app/mapper/mdastToLexical.d.ts +31 -0
- package/dist/app/mapper/mdastToLexical.js +1075 -0
- package/dist/contract.d.ts +8 -0
- package/dist/contract.js +8 -0
- package/dist/headless.d.ts +40 -0
- package/dist/headless.js +45 -0
- package/dist/host/context.d.ts +20 -0
- package/dist/host/context.js +24 -0
- package/dist/host/defaults.d.ts +21 -0
- package/dist/host/defaults.js +47 -0
- package/dist/host/messages.d.ts +26 -0
- package/dist/host/messages.js +47 -0
- package/dist/host/types.d.ts +75 -0
- package/dist/host/types.js +13 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +51 -0
- package/dist/markdown/annotate-sentinels.d.ts +42 -0
- package/dist/markdown/annotate-sentinels.js +86 -0
- package/dist/markdown/parse.d.ts +56 -0
- package/dist/markdown/parse.js +516 -0
- package/dist/markdown/stringify.d.ts +7 -0
- package/dist/markdown/stringify.js +439 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
- package/dist/markdown.d.ts +28 -0
- package/dist/markdown.js +44 -0
- package/dist/mathjax-config.d.ts +89 -0
- package/dist/mathjax-config.js +133 -0
- package/dist/nodes.d.ts +34 -0
- package/dist/nodes.js +34 -0
- package/dist/stores/correctionStore.d.ts +15 -0
- package/dist/stores/correctionStore.js +12 -0
- package/dist/stores/editorStore.d.ts +16 -0
- package/dist/stores/editorStore.js +27 -0
- package/dist/styles.css +2477 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.js +91 -0
- package/dist/utils/file-types.d.ts +10 -0
- package/dist/utils/file-types.js +46 -0
- package/docs/annotations.md +134 -0
- package/docs/decisions/README.md +72 -0
- package/docs/decisions/adr-002.md +27 -0
- package/docs/decisions/adr-007.md +27 -0
- package/docs/decisions/adr-010.md +26 -0
- package/docs/decisions/adr-024.md +124 -0
- package/docs/decisions/adr-024b.md +0 -0
- package/docs/decisions/adr-025.md +103 -0
- package/docs/decisions/adr-027.md +66 -0
- package/docs/decisions/adr-057.md +67 -0
- package/docs/decisions/adr-070.md +131 -0
- package/docs/decisions/adr-075.md +410 -0
- package/docs/decisions/adr-076.md +183 -0
- package/docs/decisions/adr-077.md +410 -0
- package/docs/decisions/adr-078.md +243 -0
- package/docs/decisions/adr-079.md +167 -0
- package/docs/decisions/adr-080.md +325 -0
- package/docs/decisions/adr-081.md +108 -0
- package/docs/decisions/adr-082.md +123 -0
- package/docs/decisions/adr-083.md +204 -0
- package/docs/decisions/adr-084.md +179 -0
- package/docs/editor-api.md +106 -0
- package/docs/markdown-pipeline.md +282 -0
- package/docs/provenance.md +106 -0
- package/docs/zusammen-editor-capability-map.md +232 -0
- package/package.json +175 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* FrontmatterPlugin - Shows frontmatter in a collapsible tray overlay
|
|
4
|
+
*
|
|
5
|
+
* The actual FrontmatterNode is hidden via CSS but remains in the Lexical tree.
|
|
6
|
+
* This plugin provides a UI to view/edit frontmatter in a sliding tray.
|
|
7
|
+
*
|
|
8
|
+
* The toggle button is rendered externally (in the editor header).
|
|
9
|
+
* This plugin communicates via Zustand store for state management.
|
|
10
|
+
*
|
|
11
|
+
* For .mdc files (Cursor rules), a specialized form UI is shown with:
|
|
12
|
+
* - alwaysApply dropdown (true/false)
|
|
13
|
+
* - description text input
|
|
14
|
+
* - globs array editor
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
|
17
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
18
|
+
import { $getRoot, $getNodeByKey, $createTextNode, $isElementNode } from 'lexical';
|
|
19
|
+
import { $isFrontmatterNode } from './nodes/index.js';
|
|
20
|
+
import { useEditorStore } from '../../stores/editorStore.js';
|
|
21
|
+
function parseMdcFrontmatter(content) {
|
|
22
|
+
const lines = content.split('\n');
|
|
23
|
+
const result = {
|
|
24
|
+
description: '',
|
|
25
|
+
alwaysApply: null,
|
|
26
|
+
globs: [],
|
|
27
|
+
otherLines: [],
|
|
28
|
+
};
|
|
29
|
+
let inGlobs = false;
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
// Check for description
|
|
33
|
+
const descMatch = /^description:\s*(.*)$/.exec(line);
|
|
34
|
+
if (descMatch) {
|
|
35
|
+
result.description = descMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
36
|
+
inGlobs = false;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Check for alwaysApply
|
|
40
|
+
const alwaysApplyMatch = /^alwaysApply:\s*(.*)$/.exec(line);
|
|
41
|
+
if (alwaysApplyMatch) {
|
|
42
|
+
const value = alwaysApplyMatch[1].trim().toLowerCase();
|
|
43
|
+
result.alwaysApply = value === 'true';
|
|
44
|
+
inGlobs = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Check for globs array start
|
|
48
|
+
if (/^globs:\s*$/.exec(line)) {
|
|
49
|
+
inGlobs = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Check for globs inline array: globs: ["*.ts", "*.tsx"]
|
|
53
|
+
const inlineGlobsMatch = /^globs:\s*\[(.*)\]$/.exec(line);
|
|
54
|
+
if (inlineGlobsMatch) {
|
|
55
|
+
const items = inlineGlobsMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(s => s);
|
|
56
|
+
result.globs = items;
|
|
57
|
+
inGlobs = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// If we're inside globs array, parse array items
|
|
61
|
+
if (inGlobs && trimmed.startsWith('-')) {
|
|
62
|
+
const item = trimmed.slice(1).trim().replace(/^["']|["']$/g, '');
|
|
63
|
+
if (item) {
|
|
64
|
+
result.globs.push(item);
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Any other non-empty line that's not a continuation of globs
|
|
69
|
+
if (trimmed && !inGlobs) {
|
|
70
|
+
inGlobs = false;
|
|
71
|
+
result.otherLines.push(line);
|
|
72
|
+
}
|
|
73
|
+
else if (trimmed && inGlobs && !trimmed.startsWith('-')) {
|
|
74
|
+
// Non-array item while in globs means globs section ended
|
|
75
|
+
inGlobs = false;
|
|
76
|
+
result.otherLines.push(line);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
function serializeMdcFrontmatter(fm) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
// Description first if set
|
|
84
|
+
if (fm.description) {
|
|
85
|
+
// Quote the description if it contains special characters
|
|
86
|
+
const needsQuotes = /[:#[\]{}|>&*!?,]/.test(fm.description);
|
|
87
|
+
lines.push(`description: ${needsQuotes ? `"${fm.description}"` : fm.description}`);
|
|
88
|
+
}
|
|
89
|
+
// alwaysApply if set
|
|
90
|
+
if (fm.alwaysApply !== null) {
|
|
91
|
+
lines.push(`alwaysApply: ${fm.alwaysApply}`);
|
|
92
|
+
}
|
|
93
|
+
// Globs array
|
|
94
|
+
if (fm.globs.length > 0) {
|
|
95
|
+
lines.push('globs:');
|
|
96
|
+
for (const glob of fm.globs) {
|
|
97
|
+
lines.push(` - ${glob}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Preserve other lines
|
|
101
|
+
for (const line of fm.otherLines) {
|
|
102
|
+
lines.push(line);
|
|
103
|
+
}
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
// Find frontmatter node in the tree (should only be one, at the start)
|
|
107
|
+
function findFrontmatterNode(node) {
|
|
108
|
+
if ($isFrontmatterNode(node)) {
|
|
109
|
+
return node;
|
|
110
|
+
}
|
|
111
|
+
if ($isElementNode(node)) {
|
|
112
|
+
for (const child of node.getChildren()) {
|
|
113
|
+
const found = findFrontmatterNode(child);
|
|
114
|
+
if (found)
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function getRuleType(fm) {
|
|
121
|
+
if (fm.alwaysApply === true)
|
|
122
|
+
return 'always';
|
|
123
|
+
if (fm.globs.length > 0)
|
|
124
|
+
return 'auto-attach';
|
|
125
|
+
if (fm.description)
|
|
126
|
+
return 'intelligent';
|
|
127
|
+
return 'manual';
|
|
128
|
+
}
|
|
129
|
+
const RULE_TYPE_INFO = {
|
|
130
|
+
'always': {
|
|
131
|
+
label: 'Always Apply',
|
|
132
|
+
description: 'Included in every chat session automatically',
|
|
133
|
+
cursorLabel: 'Always Apply'
|
|
134
|
+
},
|
|
135
|
+
'auto-attach': {
|
|
136
|
+
label: 'Auto-Attach to Files',
|
|
137
|
+
description: 'Applied when files matching glob patterns are in context',
|
|
138
|
+
cursorLabel: 'Apply to Specific Files'
|
|
139
|
+
},
|
|
140
|
+
'intelligent': {
|
|
141
|
+
label: 'Agent Decides',
|
|
142
|
+
description: 'Agent decides relevance based on description',
|
|
143
|
+
cursorLabel: 'Apply Intelligently'
|
|
144
|
+
},
|
|
145
|
+
'manual': {
|
|
146
|
+
label: 'Manual Only',
|
|
147
|
+
description: 'Only applied when @-mentioned (e.g., @rule-name)',
|
|
148
|
+
cursorLabel: 'Apply Manually'
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
function MdcFrontmatterForm({ content, onContentChange }) {
|
|
152
|
+
const parsed = useMemo(() => parseMdcFrontmatter(content), [content]);
|
|
153
|
+
const [newGlob, setNewGlob] = useState('');
|
|
154
|
+
const currentRuleType = useMemo(() => getRuleType(parsed), [parsed]);
|
|
155
|
+
const handleDescriptionChange = useCallback((e) => {
|
|
156
|
+
const updated = { ...parsed, description: e.target.value };
|
|
157
|
+
onContentChange(serializeMdcFrontmatter(updated));
|
|
158
|
+
}, [parsed, onContentChange]);
|
|
159
|
+
const handleRuleTypeChange = useCallback((e) => {
|
|
160
|
+
const newType = e.target.value;
|
|
161
|
+
const updated = { ...parsed };
|
|
162
|
+
switch (newType) {
|
|
163
|
+
case 'always':
|
|
164
|
+
updated.alwaysApply = true;
|
|
165
|
+
// Keep existing globs and description, they're just ignored
|
|
166
|
+
break;
|
|
167
|
+
case 'auto-attach':
|
|
168
|
+
updated.alwaysApply = null; // Remove alwaysApply
|
|
169
|
+
// If no globs, add a placeholder to show intent
|
|
170
|
+
if (updated.globs.length === 0) {
|
|
171
|
+
updated.globs = ['**/*.md']; // Example pattern
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
case 'intelligent':
|
|
175
|
+
updated.alwaysApply = false;
|
|
176
|
+
updated.globs = []; // Clear globs
|
|
177
|
+
// Ensure description exists
|
|
178
|
+
if (!updated.description) {
|
|
179
|
+
updated.description = 'Describe when this rule should apply...';
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
case 'manual':
|
|
183
|
+
updated.alwaysApply = false;
|
|
184
|
+
updated.globs = [];
|
|
185
|
+
updated.description = ''; // Clear description
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
onContentChange(serializeMdcFrontmatter(updated));
|
|
189
|
+
}, [parsed, onContentChange]);
|
|
190
|
+
const handleRemoveGlob = useCallback((index) => {
|
|
191
|
+
const newGlobs = parsed.globs.filter((_, i) => i !== index);
|
|
192
|
+
const updated = { ...parsed, globs: newGlobs };
|
|
193
|
+
onContentChange(serializeMdcFrontmatter(updated));
|
|
194
|
+
}, [parsed, onContentChange]);
|
|
195
|
+
const handleAddGlob = useCallback(() => {
|
|
196
|
+
if (!newGlob.trim())
|
|
197
|
+
return;
|
|
198
|
+
const updated = { ...parsed, globs: [...parsed.globs, newGlob.trim()] };
|
|
199
|
+
onContentChange(serializeMdcFrontmatter(updated));
|
|
200
|
+
setNewGlob('');
|
|
201
|
+
}, [parsed, newGlob, onContentChange]);
|
|
202
|
+
const handleGlobKeyDown = useCallback((e) => {
|
|
203
|
+
if (e.key === 'Enter') {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
handleAddGlob();
|
|
206
|
+
}
|
|
207
|
+
}, [handleAddGlob]);
|
|
208
|
+
const info = RULE_TYPE_INFO[currentRuleType];
|
|
209
|
+
return (_jsxs("div", { className: "mdc-form", children: [_jsxs("div", { className: "mdc-form-field", children: [_jsxs("label", { htmlFor: "mdc-rule-type", className: "mdc-form-label", children: ["Rule Type", _jsxs("span", { className: "mdc-form-label-cursor", children: ["(Cursor: ", info.cursorLabel, ")"] })] }), _jsxs("select", { id: "mdc-rule-type", className: "mdc-form-select", value: currentRuleType, onChange: handleRuleTypeChange, children: [_jsx("option", { value: "always", children: "Always Apply \u2014 every chat session" }), _jsx("option", { value: "auto-attach", children: "Auto-Attach \u2014 when files match globs" }), _jsx("option", { value: "intelligent", children: "Agent Decides \u2014 based on description" }), _jsx("option", { value: "manual", children: "Manual Only \u2014 requires @mention" })] }), _jsx("span", { className: "mdc-form-hint mdc-form-hint-info", children: info.description })] }), _jsxs("div", { className: "mdc-form-field", children: [_jsxs("label", { htmlFor: "mdc-description", className: "mdc-form-label", children: ["Description", currentRuleType === 'intelligent' && _jsx("span", { className: "mdc-form-required", children: "*" })] }), _jsx("input", { id: "mdc-description", type: "text", className: "mdc-form-input", value: parsed.description, onChange: handleDescriptionChange, placeholder: currentRuleType === 'intelligent'
|
|
210
|
+
? "Required: describe when this rule should apply..."
|
|
211
|
+
: "Optional: describe what this rule does" }), currentRuleType === 'intelligent' && (_jsx("span", { className: "mdc-form-hint mdc-form-hint-warn", children: "\u26A0\uFE0F Agent uses this to decide relevance \u2014 be specific!" }))] }), currentRuleType === 'auto-attach' && (_jsxs("div", { className: "mdc-form-field", children: [_jsxs("label", { className: "mdc-form-label", children: ["File Patterns (globs)", _jsx("span", { className: "mdc-form-required", children: "*" })] }), _jsx("div", { className: "mdc-globs-list", children: parsed.globs.map((glob, index) => (_jsxs("div", { className: "mdc-glob-item", children: [_jsx("code", { className: "mdc-glob-pattern", children: glob }), _jsx("button", { type: "button", className: "mdc-glob-remove", onClick: () => handleRemoveGlob(index), title: "Remove glob", children: _jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }) })] }, index))) }), _jsxs("div", { className: "mdc-glob-add", children: [_jsx("input", { type: "text", className: "mdc-form-input", value: newGlob, onChange: (e) => setNewGlob(e.target.value), onKeyDown: handleGlobKeyDown, placeholder: "e.g., **/*.ts or src/components/**" }), _jsx("button", { type: "button", className: "mdc-glob-add-btn", onClick: handleAddGlob, disabled: !newGlob.trim(), children: "Add" })] }), _jsx("span", { className: "mdc-form-hint", children: "Rule auto-attaches when files matching these patterns are in context" })] })), parsed.otherLines.length > 0 && (_jsxs("div", { className: "mdc-form-field", children: [_jsx("label", { className: "mdc-form-label", children: "Other Fields (raw YAML)" }), _jsx("pre", { className: "mdc-other-yaml", children: parsed.otherLines.join('\n') })] }))] }));
|
|
212
|
+
}
|
|
213
|
+
function FrontmatterTray({ isOpen, content, onClose, onContentChange, isMdcFile }) {
|
|
214
|
+
const textareaRef = useRef(null);
|
|
215
|
+
const [showRaw, setShowRaw] = useState(false);
|
|
216
|
+
// Auto-resize textarea to fit content
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (textareaRef.current) {
|
|
219
|
+
textareaRef.current.style.height = 'auto';
|
|
220
|
+
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 300)}px`;
|
|
221
|
+
}
|
|
222
|
+
}, [content, isOpen, showRaw]);
|
|
223
|
+
// Focus textarea when tray opens (only in raw mode)
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (isOpen && textareaRef.current && (!isMdcFile || showRaw)) {
|
|
226
|
+
textareaRef.current.focus();
|
|
227
|
+
}
|
|
228
|
+
}, [isOpen, isMdcFile, showRaw]);
|
|
229
|
+
return (_jsxs("div", { className: `frontmatter-tray ${isOpen ? 'frontmatter-tray-open' : ''}`, children: [_jsxs("div", { className: "frontmatter-tray-header", children: [_jsx("span", { className: "frontmatter-tray-title", children: isMdcFile ? 'Cursor Rule Config' : 'Frontmatter' }), _jsxs("div", { className: "frontmatter-tray-actions", children: [isMdcFile && (_jsx("button", { className: "frontmatter-tray-toggle-raw", onClick: () => setShowRaw(!showRaw), title: showRaw ? 'Show form view' : 'Show raw YAML', children: showRaw ? 'Form' : 'Raw' })), _jsx("button", { className: "frontmatter-tray-close", onClick: onClose, title: "Close", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }) })] })] }), _jsx("div", { className: "frontmatter-tray-content", children: isMdcFile && !showRaw ? (_jsx(MdcFrontmatterForm, { content: content, onContentChange: onContentChange })) : (_jsx("textarea", { ref: textareaRef, className: "frontmatter-tray-textarea", value: content, onChange: (e) => onContentChange(e.target.value), placeholder: "---\ntitle: Document Title\n---", spellCheck: false })) })] }));
|
|
230
|
+
}
|
|
231
|
+
export function FrontmatterPlugin({ filePath }) {
|
|
232
|
+
const [editor] = useLexicalComposerContext();
|
|
233
|
+
const [frontmatterContent, setFrontmatterContent] = useState('');
|
|
234
|
+
const [frontmatterNodeKey, setFrontmatterNodeKey] = useState(null);
|
|
235
|
+
// Use Zustand store for state management
|
|
236
|
+
const setHasFrontmatter = useEditorStore((state) => state.setHasFrontmatter);
|
|
237
|
+
const setFrontmatterOpen = useEditorStore((state) => state.setFrontmatterOpen);
|
|
238
|
+
const isFrontmatterOpen = useEditorStore((state) => state.isFrontmatterOpen);
|
|
239
|
+
// Detect if we're editing an MDC file (Cursor rules)
|
|
240
|
+
const isMdcFile = useMemo(() => {
|
|
241
|
+
return filePath?.endsWith('.mdc') ?? false;
|
|
242
|
+
}, [filePath]);
|
|
243
|
+
// Track frontmatter node and content
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const updateFrontmatter = () => {
|
|
246
|
+
editor.getEditorState().read(() => {
|
|
247
|
+
const root = $getRoot();
|
|
248
|
+
const frontmatterNode = findFrontmatterNode(root);
|
|
249
|
+
if (frontmatterNode) {
|
|
250
|
+
setHasFrontmatter(true);
|
|
251
|
+
setFrontmatterContent(frontmatterNode.getTextContent());
|
|
252
|
+
setFrontmatterNodeKey(frontmatterNode.getKey());
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
setHasFrontmatter(false);
|
|
256
|
+
setFrontmatterContent('');
|
|
257
|
+
setFrontmatterNodeKey(null);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
// Initial check
|
|
262
|
+
updateFrontmatter();
|
|
263
|
+
// Listen for updates
|
|
264
|
+
const unsubscribe = editor.registerUpdateListener(() => {
|
|
265
|
+
updateFrontmatter();
|
|
266
|
+
});
|
|
267
|
+
return unsubscribe;
|
|
268
|
+
}, [editor, setHasFrontmatter]);
|
|
269
|
+
// Listen for toggle commands from the UI
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
// Subscribe to store changes to detect toggle requests
|
|
272
|
+
// The toggle function in the store handles the state change
|
|
273
|
+
// We don't need to do anything here as the state is already managed
|
|
274
|
+
}, []);
|
|
275
|
+
// Update frontmatter content in the Lexical node
|
|
276
|
+
const handleContentChange = useCallback((newContent) => {
|
|
277
|
+
setFrontmatterContent(newContent);
|
|
278
|
+
if (!frontmatterNodeKey)
|
|
279
|
+
return;
|
|
280
|
+
editor.update(() => {
|
|
281
|
+
const node = $getNodeByKey(frontmatterNodeKey);
|
|
282
|
+
if (node && $isFrontmatterNode(node)) {
|
|
283
|
+
// Clear existing children and add new content
|
|
284
|
+
node.clear();
|
|
285
|
+
if (newContent) {
|
|
286
|
+
node.append($createTextNode(newContent));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}, [editor, frontmatterNodeKey]);
|
|
291
|
+
const handleClose = useCallback(() => {
|
|
292
|
+
setFrontmatterOpen(false);
|
|
293
|
+
}, [setFrontmatterOpen]);
|
|
294
|
+
// Get hasFrontmatter from store
|
|
295
|
+
const hasFrontmatter = useEditorStore((state) => state.hasFrontmatter);
|
|
296
|
+
// Don't render anything if no frontmatter
|
|
297
|
+
if (!hasFrontmatter) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return (_jsx(FrontmatterTray, { isOpen: isFrontmatterOpen, content: frontmatterContent, onClose: handleClose, onContentChange: handleContentChange, isMdcFile: isMdcFile }));
|
|
301
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ImageData {
|
|
2
|
+
src: string;
|
|
3
|
+
alt: string;
|
|
4
|
+
isLocal: boolean;
|
|
5
|
+
dataUri?: string;
|
|
6
|
+
}
|
|
7
|
+
interface ImageModalProps {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
onInsert: (data: ImageData) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function ImageModal({ isOpen, onClose, onInsert }: ImageModalProps): import("react").JSX.Element | null;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
3
|
+
// SECURITY: Maximum file size for uploaded images (5MB)
|
|
4
|
+
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
|
|
5
|
+
// SECURITY: Maximum URL length
|
|
6
|
+
const MAX_URL_LENGTH = 2048;
|
|
7
|
+
/**
|
|
8
|
+
* Check if a path is a relative/local path (not an absolute URL)
|
|
9
|
+
*/
|
|
10
|
+
function isRelativePath(path) {
|
|
11
|
+
// Relative paths don't start with a protocol
|
|
12
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
// Common relative path patterns
|
|
16
|
+
return path.startsWith('./') ||
|
|
17
|
+
path.startsWith('../') ||
|
|
18
|
+
path.startsWith('assets/') ||
|
|
19
|
+
path.startsWith('images/') ||
|
|
20
|
+
// Any path that looks like a file path (contains / but no protocol)
|
|
21
|
+
(/^[^:]+\/[^:]*$/.test(path) && !path.includes('://'));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* SECURITY: Validate image URL or path
|
|
25
|
+
* - Allows http/https protocols for remote images
|
|
26
|
+
* - Allows relative paths for local images (e.g., assets/image.png)
|
|
27
|
+
* - Blocks dangerous protocols (javascript:, data:, file:)
|
|
28
|
+
*/
|
|
29
|
+
function validateImageUrl(url) {
|
|
30
|
+
if (!url) {
|
|
31
|
+
return { valid: false, error: 'URL or path is required' };
|
|
32
|
+
}
|
|
33
|
+
if (url.length > MAX_URL_LENGTH) {
|
|
34
|
+
return { valid: false, error: `Path is too long. Maximum ${MAX_URL_LENGTH} characters.` };
|
|
35
|
+
}
|
|
36
|
+
// Check if it's a relative/local path
|
|
37
|
+
if (isRelativePath(url)) {
|
|
38
|
+
// Basic validation for relative paths
|
|
39
|
+
// SECURITY: Block path traversal attempts that try to escape the workspace
|
|
40
|
+
const normalizedPath = url.replace(/\\/g, '/');
|
|
41
|
+
// Count how many directories up we go vs down
|
|
42
|
+
const parts = normalizedPath.split('/');
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (const part of parts) {
|
|
45
|
+
if (part === '..') {
|
|
46
|
+
depth--;
|
|
47
|
+
if (depth < -2) {
|
|
48
|
+
// Allow some traversal (e.g., ../assets/) but not excessive
|
|
49
|
+
return { valid: false, error: 'Path traversal not allowed' };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (part && part !== '.') {
|
|
53
|
+
depth++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { valid: true, isLocal: true };
|
|
57
|
+
}
|
|
58
|
+
// It's an absolute URL - validate it
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(url);
|
|
61
|
+
// SECURITY: Block dangerous protocols
|
|
62
|
+
const blockedProtocols = ['javascript:', 'data:', 'file:', 'vbscript:'];
|
|
63
|
+
if (blockedProtocols.includes(parsed.protocol)) {
|
|
64
|
+
return { valid: false, error: 'This URL protocol is not allowed' };
|
|
65
|
+
}
|
|
66
|
+
// Only allow http/https protocols for remote URLs
|
|
67
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
68
|
+
return { valid: false, error: 'Only http:// and https:// URLs are allowed for remote images' };
|
|
69
|
+
}
|
|
70
|
+
return { valid: true, isLocal: false };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// If URL parsing fails but it looks like a path, treat as relative
|
|
74
|
+
if (url.includes('/') || url.includes('.')) {
|
|
75
|
+
return { valid: true, isLocal: true };
|
|
76
|
+
}
|
|
77
|
+
return { valid: false, error: 'Invalid URL or path format' };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function ImageModal({ isOpen, onClose, onInsert }) {
|
|
81
|
+
const [activeTab, setActiveTab] = useState('upload');
|
|
82
|
+
const [url, setUrl] = useState('');
|
|
83
|
+
const [alt, setAlt] = useState('');
|
|
84
|
+
const [previewUrl, setPreviewUrl] = useState(null);
|
|
85
|
+
const [dragOver, setDragOver] = useState(false);
|
|
86
|
+
const [error, setError] = useState(null);
|
|
87
|
+
const fileInputRef = useRef(null);
|
|
88
|
+
const modalRef = useRef(null);
|
|
89
|
+
const selectedFileRef = useRef(null);
|
|
90
|
+
// Reset state when modal opens - track previous isOpen to detect open transition
|
|
91
|
+
const prevIsOpenRef = useRef(isOpen);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
// Only reset when transitioning from closed to open
|
|
94
|
+
if (isOpen && !prevIsOpenRef.current) {
|
|
95
|
+
// Use microtask to avoid setState during render
|
|
96
|
+
queueMicrotask(() => {
|
|
97
|
+
setUrl('');
|
|
98
|
+
setAlt('');
|
|
99
|
+
setPreviewUrl(null);
|
|
100
|
+
setError(null);
|
|
101
|
+
setActiveTab('upload');
|
|
102
|
+
selectedFileRef.current = null;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
prevIsOpenRef.current = isOpen;
|
|
106
|
+
}, [isOpen]);
|
|
107
|
+
// Keyboard handling: Escape to close, Enter to submit
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!isOpen)
|
|
110
|
+
return;
|
|
111
|
+
const handleKeyDown = (e) => {
|
|
112
|
+
if (e.key === 'Escape') {
|
|
113
|
+
onClose();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
117
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
118
|
+
}, [isOpen, onClose]);
|
|
119
|
+
// Handle click outside to close
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!isOpen)
|
|
122
|
+
return;
|
|
123
|
+
const handleClickOutside = (e) => {
|
|
124
|
+
if (modalRef.current && !modalRef.current.contains(e.target)) {
|
|
125
|
+
onClose();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
// Delay adding the listener to avoid immediate close
|
|
129
|
+
const timeoutId = setTimeout(() => {
|
|
130
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
131
|
+
}, 0);
|
|
132
|
+
return () => {
|
|
133
|
+
clearTimeout(timeoutId);
|
|
134
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
135
|
+
};
|
|
136
|
+
}, [isOpen, onClose]);
|
|
137
|
+
const handleFileSelect = useCallback((file) => {
|
|
138
|
+
if (!file.type.startsWith('image/')) {
|
|
139
|
+
setError('Please select an image file');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// SECURITY: Check file size before reading
|
|
143
|
+
if (file.size > MAX_IMAGE_SIZE) {
|
|
144
|
+
setError(`Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const reader = new FileReader();
|
|
148
|
+
reader.onload = (e) => {
|
|
149
|
+
const dataUri = e.target?.result;
|
|
150
|
+
selectedFileRef.current = { dataUri, name: file.name };
|
|
151
|
+
setPreviewUrl(dataUri);
|
|
152
|
+
setError(null);
|
|
153
|
+
// Auto-generate alt text from filename (without extension)
|
|
154
|
+
if (!alt) {
|
|
155
|
+
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
|
|
156
|
+
setAlt(nameWithoutExt);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
reader.onerror = () => {
|
|
160
|
+
setError('Failed to read file');
|
|
161
|
+
};
|
|
162
|
+
reader.readAsDataURL(file);
|
|
163
|
+
}, [alt]);
|
|
164
|
+
const handleDrop = useCallback((e) => {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
e.stopPropagation();
|
|
167
|
+
setDragOver(false);
|
|
168
|
+
const file = e.dataTransfer.files[0];
|
|
169
|
+
if (file) {
|
|
170
|
+
handleFileSelect(file);
|
|
171
|
+
}
|
|
172
|
+
}, [handleFileSelect]);
|
|
173
|
+
const handleDragOver = useCallback((e) => {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
e.stopPropagation();
|
|
176
|
+
setDragOver(true);
|
|
177
|
+
}, []);
|
|
178
|
+
const handleDragLeave = useCallback((e) => {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
setDragOver(false);
|
|
182
|
+
}, []);
|
|
183
|
+
const handleFileInputChange = useCallback((e) => {
|
|
184
|
+
const file = e.target.files?.[0];
|
|
185
|
+
if (file) {
|
|
186
|
+
handleFileSelect(file);
|
|
187
|
+
}
|
|
188
|
+
}, [handleFileSelect]);
|
|
189
|
+
const handleUrlChange = useCallback((e) => {
|
|
190
|
+
const newUrl = e.target.value;
|
|
191
|
+
setUrl(newUrl);
|
|
192
|
+
setError(null);
|
|
193
|
+
// SECURITY: Validate URL before showing preview
|
|
194
|
+
if (newUrl) {
|
|
195
|
+
const validation = validateImageUrl(newUrl);
|
|
196
|
+
if (validation.valid) {
|
|
197
|
+
// For local paths, we can't preview them directly in the modal
|
|
198
|
+
// (they need to be resolved through the asset context)
|
|
199
|
+
// For remote URLs, show the preview
|
|
200
|
+
if (!validation.isLocal) {
|
|
201
|
+
setPreviewUrl(newUrl);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// Local path - no preview available, but it's valid
|
|
205
|
+
setPreviewUrl(null);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
setPreviewUrl(null);
|
|
210
|
+
// Don't show error while typing, only on submit
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
setPreviewUrl(null);
|
|
215
|
+
}
|
|
216
|
+
}, []);
|
|
217
|
+
const handleInsert = useCallback(() => {
|
|
218
|
+
if (activeTab === 'url') {
|
|
219
|
+
if (!url) {
|
|
220
|
+
setError('Please enter an image URL or local path');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// SECURITY: Validate URL before inserting
|
|
224
|
+
const validation = validateImageUrl(url);
|
|
225
|
+
if (!validation.valid) {
|
|
226
|
+
setError(validation.error || 'Invalid URL or path');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
onInsert({
|
|
230
|
+
src: url,
|
|
231
|
+
alt: alt || '',
|
|
232
|
+
isLocal: validation.isLocal || false,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
if (!selectedFileRef.current) {
|
|
237
|
+
setError('Please select an image');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
onInsert({
|
|
241
|
+
src: '', // Will be set after upload
|
|
242
|
+
alt: alt || '',
|
|
243
|
+
isLocal: true,
|
|
244
|
+
dataUri: selectedFileRef.current.dataUri,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
onClose();
|
|
248
|
+
}, [activeTab, url, alt, onInsert, onClose]);
|
|
249
|
+
// Handle Enter key to submit
|
|
250
|
+
const handleKeyPress = useCallback((e) => {
|
|
251
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
handleInsert();
|
|
254
|
+
}
|
|
255
|
+
}, [handleInsert]);
|
|
256
|
+
const handleTabChange = useCallback((tab) => {
|
|
257
|
+
setActiveTab(tab);
|
|
258
|
+
setError(null);
|
|
259
|
+
setPreviewUrl(null);
|
|
260
|
+
if (tab === 'url') {
|
|
261
|
+
selectedFileRef.current = null;
|
|
262
|
+
}
|
|
263
|
+
}, []);
|
|
264
|
+
if (!isOpen)
|
|
265
|
+
return null;
|
|
266
|
+
return (_jsx("div", { className: "image-modal-overlay", children: _jsxs("div", { className: "image-modal", ref: modalRef, onKeyDown: handleKeyPress, children: [_jsxs("div", { className: "image-modal-header", children: [_jsx("h3", { children: "Insert Image" }), _jsx("button", { className: "image-modal-close", onClick: onClose, "aria-label": "Close", children: "\u00D7" })] }), _jsxs("div", { className: "image-modal-tabs", children: [_jsx("button", { className: `image-modal-tab ${activeTab === 'upload' ? 'active' : ''}`, onClick: () => handleTabChange('upload'), children: "Upload" }), _jsx("button", { className: `image-modal-tab ${activeTab === 'url' ? 'active' : ''}`, onClick: () => handleTabChange('url'), children: "URL / Path" })] }), _jsxs("div", { className: "image-modal-content", children: [activeTab === 'upload' ? (_jsxs("div", { className: `image-modal-dropzone ${dragOver ? 'drag-over' : ''} ${previewUrl ? 'has-preview' : ''}`, onDrop: handleDrop, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onClick: () => fileInputRef.current?.click(), children: [previewUrl ? (_jsx("img", { src: previewUrl, alt: "Preview", className: "image-modal-preview" })) : (_jsxs("div", { className: "image-modal-dropzone-content", children: [_jsx("span", { className: "image-modal-dropzone-icon", children: "\uD83D\uDDBC" }), _jsx("span", { children: "Drop an image here or click to browse" }), _jsxs("span", { className: "image-modal-size-hint", children: ["Maximum size: ", MAX_IMAGE_SIZE / (1024 * 1024), "MB"] })] })), _jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileInputChange, style: { display: 'none' } })] })) : (_jsxs("div", { className: "image-modal-url-section", children: [_jsx("input", { type: "text", className: "image-modal-input", placeholder: "assets/image.png or https://example.com/image.png", value: url, onChange: handleUrlChange, autoFocus: true }), previewUrl && (_jsx("div", { className: "image-modal-url-preview", children: _jsx("img", { src: previewUrl, alt: "Preview", className: "image-modal-preview", onError: () => setPreviewUrl(null) }) }))] })), _jsxs("div", { className: "image-modal-alt-section", children: [_jsx("label", { htmlFor: "image-alt", className: "image-modal-label", children: "Alt text (optional)" }), _jsx("input", { id: "image-alt", type: "text", className: "image-modal-input", placeholder: "Describe the image for accessibility", value: alt, onChange: (e) => setAlt(e.target.value) })] }), error && _jsx("div", { className: "image-modal-error", children: error })] }), _jsxs("div", { className: "image-modal-footer", children: [_jsx("button", { className: "image-modal-button secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { className: "image-modal-button primary", onClick: handleInsert, children: "Insert" })] })] }) }));
|
|
267
|
+
}
|