feat(editor): add image insertion with OCR support and size limit handling
Add image button with dropdown menu for uploading local images or inserting from URL. Integrate VLM-based OCR to extract text context from images and include in AI suggestions. Implement document size limits to disable AI when exceeding threshold. Refactor copilot plugin with per-view runtime state and OCR context injection. Add OCR cache utility for managing image metadata. Add code splitting configuration for optimized bundle size.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# Image Button Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add an image button to the MilkdownEditor that allows users to insert images at the cursor position. The button will provide a dropdown menu with two options: upload local file or input image URL.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Existing Image Handling
|
||||
|
||||
The editor already has image support through `@milkdown/crepe`:
|
||||
|
||||
```javascript
|
||||
// From MilkdownEditor.vue lines 217-231
|
||||
features: {
|
||||
[Crepe.Feature.Latex]: true,
|
||||
[Crepe.Feature.ImageBlock]: true,
|
||||
},
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.ImageBlock]: {
|
||||
onUpload: (file) => {
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
objectUrls.add(objectUrl)
|
||||
performOCR(file, objectUrl)
|
||||
return objectUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Editor Access Pattern
|
||||
|
||||
The code uses `editorViewCtx` to access the ProseMirror editor view:
|
||||
|
||||
```javascript
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
// manipulate editor state
|
||||
})
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Template Changes
|
||||
|
||||
Add new button with dropdown menu in the `action-buttons` section:
|
||||
|
||||
```html
|
||||
<!-- Image button with dropdown -->
|
||||
<div class="image-btn-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
aria-label="Insert Image"
|
||||
title="Insert Image"
|
||||
@click="toggleImageDropdown"
|
||||
>
|
||||
<!-- Image SVG icon -->
|
||||
<svg>...</svg>
|
||||
<span class="btn-tooltip">Insert Image</span>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div v-if="showImageDropdown" class="image-dropdown">
|
||||
<button @click="triggerImageUpload">Upload Local Image</button>
|
||||
<button @click="showUrlDialog = true">Insert from URL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input for image upload -->
|
||||
<input type="file" ref="imageInputRef" @change="handleImageUpload" accept="image/*" style="display:none">
|
||||
|
||||
<!-- URL input dialog -->
|
||||
<div v-if="showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false">
|
||||
<div class="url-dialog">
|
||||
<input v-model="imageUrl" placeholder="Enter image URL" />
|
||||
<button @click="insertImageFromUrl">Insert</button>
|
||||
<button @click="showUrlDialog = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Script Changes
|
||||
|
||||
Add new refs and methods:
|
||||
|
||||
```javascript
|
||||
// New refs
|
||||
const imageInputRef = ref(null)
|
||||
const showImageDropdown = ref(false)
|
||||
const showUrlDialog = ref(false)
|
||||
const imageUrl = ref('')
|
||||
|
||||
// Toggle dropdown
|
||||
const toggleImageDropdown = () => {
|
||||
showImageDropdown.value = !showImageDropdown.value
|
||||
}
|
||||
|
||||
// Trigger file input
|
||||
const triggerImageUpload = () => {
|
||||
showImageDropdown.value = false
|
||||
imageInputRef.value?.click()
|
||||
}
|
||||
|
||||
// Handle file upload - reuse existing onUpload logic
|
||||
const handleImageUpload = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
objectUrls.add(objectUrl)
|
||||
performOCR(file, objectUrl)
|
||||
|
||||
// Insert image at cursor
|
||||
insertImageAtCursor(objectUrl)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// Insert image from URL
|
||||
const insertImageFromUrl = () => {
|
||||
if (!imageUrl.value.trim()) return
|
||||
insertImageAtCursor(imageUrl.value.trim())
|
||||
imageUrl.value = ''
|
||||
showUrlDialog.value = false
|
||||
}
|
||||
|
||||
// Core function: insert image at cursor position
|
||||
const insertImageAtCursor = (src) => {
|
||||
if (!crepe) return
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { state } = view
|
||||
const { selection, schema } = state
|
||||
|
||||
// Get image node type from schema
|
||||
const imageType = schema.nodes.image
|
||||
if (!imageType) return
|
||||
|
||||
// Create image node
|
||||
const imageNode = imageType.create({ src })
|
||||
|
||||
// Create transaction to insert at cursor
|
||||
const tr = state.tr
|
||||
tr = tr.replaceSelectionWith(imageNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Style Changes
|
||||
|
||||
Add styles for dropdown and dialog:
|
||||
|
||||
```css
|
||||
/* Image button wrapper */
|
||||
.image-btn-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.image-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
overflow: hidden;
|
||||
z-index: 10000;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.image-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.image-dropdown button:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* URL dialog overlay */
|
||||
.url-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.url-dialog {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.url-dialog input {
|
||||
width: 300px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.url-dialog button {
|
||||
padding: 8px 16px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Click Image Button] --> B{Toggle Dropdown}
|
||||
B --> C[Show Dropdown Menu]
|
||||
C --> D{User Choice}
|
||||
D -->|Upload Local| E[Open File Picker]
|
||||
D -->|From URL| F[Show URL Dialog]
|
||||
E --> G[Select Image File]
|
||||
G --> H[Create Object URL]
|
||||
H --> I[Perform OCR]
|
||||
I --> J[Insert Image at Cursor]
|
||||
F --> K[Enter URL]
|
||||
K --> L[Click Insert]
|
||||
L --> J
|
||||
J --> M[Image Appears in Editor]
|
||||
```
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
1. **Reuse existing logic**: The `onUpload` callback logic for `Crepe.Feature.ImageBlock` should be reused for local file uploads to maintain consistency with OCR processing.
|
||||
|
||||
2. **ProseMirror API**: Use `schema.nodes.image.create()` and `replaceSelectionWith()` to insert images at cursor position.
|
||||
|
||||
3. **Click outside to close**: The dropdown should close when clicking outside. This can be achieved with a click-outside directive or by listening to document clicks.
|
||||
|
||||
4. **Accessibility**: Ensure proper ARIA labels and keyboard navigation support.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- `src/components/MilkdownEditor.vue` - All changes will be in this single file
|
||||
|
||||
## Dependencies
|
||||
|
||||
No new dependencies required. All functionality uses existing:
|
||||
- Vue 3 Composition API
|
||||
- Milkdown/ProseMirror APIs
|
||||
- Native browser APIs (URL.createObjectURL, FileReader)
|
||||
Reference in New Issue
Block a user