269 lines
6.4 KiB
Markdown
269 lines
6.4 KiB
Markdown
|
|
# 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)
|