tiptap-extension-izzy-resizable-image 0.1.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/README.md +170 -0
- package/npm/index.js +406 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# TipTap Izzy Extension: Resizable Image
|
|
2
|
+
|
|
3
|
+
Resizable inline image node for TipTap v2 with eight resize handles, optional alignment menu, drag-and-drop, and sensible defaults via `.extend()`.
|
|
4
|
+
|
|
5
|
+
Demo: https://bernanr.github.io/TipTap-Extension-Izzy-Resizable-Image
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Inline resizable image with 8 handles (nw, n, ne, e, se, s, sw, w)
|
|
10
|
+
- Drag-and-drop repositioning using browser native drag
|
|
11
|
+
- Optional inline alignment menu (left, center, right, clear)
|
|
12
|
+
- Menu position configurable (`above` or `below`)
|
|
13
|
+
- Keeps aspect ratio while resizing
|
|
14
|
+
- Menu icons accept HTML strings (e.g., `<i class="...">`)
|
|
15
|
+
- Deselects image and hides UI when clicking outside
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
This demo loads TipTap and the extension via ESM in the browser. Include `resizable-image.js` next to your HTML file and import it.
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<script type="module">
|
|
23
|
+
import { Editor } from 'https://esm.sh/@tiptap/core@2';
|
|
24
|
+
import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2';
|
|
25
|
+
import { TiptapIzzyExtensionResizableImage } from './resizable-image.js';
|
|
26
|
+
// ...
|
|
27
|
+
</script>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const editor = new Editor({
|
|
34
|
+
element: document.querySelector('#editor'),
|
|
35
|
+
extensions: [StarterKit, TiptapIzzyExtensionResizableImage],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Insert an image
|
|
39
|
+
editor.commands.insertResizableImage({ src: 'https://picsum.photos/300/200', width: 300, height: 200 });
|
|
40
|
+
|
|
41
|
+
// Align the selected image
|
|
42
|
+
editor.commands.setResizableImageAlignment('center');
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Using from npm (bundler)
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install tiptap-extension-izzy-resizable-image @tiptap/core@^3 prosemirror-state
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { Editor } from '@tiptap/core';
|
|
53
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
54
|
+
import { TiptapIzzyExtensionResizableImage } from 'tiptap-extension-izzy-resizable-image';
|
|
55
|
+
|
|
56
|
+
const editor = new Editor({
|
|
57
|
+
element: document.querySelector('#editor'),
|
|
58
|
+
extensions: [StarterKit, TiptapIzzyExtensionResizableImage],
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Defaults via `.extend()`
|
|
63
|
+
|
|
64
|
+
You can set extension-wide defaults using `.extend()` to override `addOptions()`.
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
const editor = new Editor({
|
|
68
|
+
element: document.querySelector('#editor'),
|
|
69
|
+
extensions: [
|
|
70
|
+
StarterKit,
|
|
71
|
+
TiptapIzzyExtensionResizableImage.extend({
|
|
72
|
+
addOptions() {
|
|
73
|
+
return {
|
|
74
|
+
height: 200,
|
|
75
|
+
showAlignMenu: true,
|
|
76
|
+
alignMenuPosition: 'below',
|
|
77
|
+
alignMenuIcons: {
|
|
78
|
+
left: '<i class="icon-left"></i>',
|
|
79
|
+
center: '⇔',
|
|
80
|
+
right: '⟹',
|
|
81
|
+
clear: 'x',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// With defaults set, you can omit height/menu attrs in insert:
|
|
90
|
+
editor.commands.insertResizableImage({ src: 'https://picsum.photos/300/200', width: 300 });
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Options (via `.extend()`)
|
|
94
|
+
|
|
95
|
+
- `height: number | null` — default height in pixels when node `height` attr is missing.
|
|
96
|
+
- `showAlignMenu: boolean` — whether the inline alignment menu is shown by default.
|
|
97
|
+
- `alignMenuPosition: 'above' | 'below'` — where the menu appears relative to the image.
|
|
98
|
+
- `alignMenuIcons: { left: string, center: string, right: string, clear: string }` — HTML strings for menu buttons.
|
|
99
|
+
|
|
100
|
+
Reference:
|
|
101
|
+
- Defined in `resizable-image.js:387-394`
|
|
102
|
+
- Passed into the NodeView in `resizable-image.js:383-385`
|
|
103
|
+
- Used for insertion defaults in `resizable-image.js:402-414`
|
|
104
|
+
|
|
105
|
+
## Node Attributes
|
|
106
|
+
|
|
107
|
+
These can be set per-node, overriding options.
|
|
108
|
+
|
|
109
|
+
- `src: string` — image URL.
|
|
110
|
+
- `alt: string | null`
|
|
111
|
+
- `title: string | null`
|
|
112
|
+
- `width: number | null` — width in px.
|
|
113
|
+
- `height: number | null` — height in px.
|
|
114
|
+
- `align: 'left' | 'center' | 'right' | null` — alignment.
|
|
115
|
+
- `showAlignMenu: boolean | null` — show/hide menu per node.
|
|
116
|
+
- `alignMenuPosition: 'above' | 'below' | null`
|
|
117
|
+
- `iconLeft: string | null` — HTML or text for the left button.
|
|
118
|
+
- `iconCenter: string | null`
|
|
119
|
+
- `iconRight: string | null`
|
|
120
|
+
- `iconClear: string | null`
|
|
121
|
+
|
|
122
|
+
Reference: `resizable-image.js:358-372`
|
|
123
|
+
|
|
124
|
+
## Commands
|
|
125
|
+
|
|
126
|
+
- `insertResizableImage({ src, alt, title, width, height })`
|
|
127
|
+
- Inserts the resizable image node. Missing attrs fall back to options (e.g., `height`).
|
|
128
|
+
- Reference: `resizable-image.js:398-416`
|
|
129
|
+
|
|
130
|
+
- `setResizableImageAlignment(align)`
|
|
131
|
+
- Sets `align` on the currently selected image node.
|
|
132
|
+
- Reference: `resizable-image.js:417-433`
|
|
133
|
+
|
|
134
|
+
## Keyboard Shortcuts
|
|
135
|
+
|
|
136
|
+
- `Enter` when an image is selected inserts a paragraph after it and moves the cursor there.
|
|
137
|
+
- Reference: `resizable-image.js:412-431`
|
|
138
|
+
|
|
139
|
+
## Alignment Behavior
|
|
140
|
+
|
|
141
|
+
- Alignment is applied via `text-align` on the wrapper and toggling wrapper display.
|
|
142
|
+
- `left | center | right` set `text-align` accordingly; unaligned flows inline.
|
|
143
|
+
- Reference: `resizable-image.js:172-193`
|
|
144
|
+
|
|
145
|
+
## Resizing Behavior
|
|
146
|
+
|
|
147
|
+
- Eight handles adjust width/height; aspect ratio is preserved by default.
|
|
148
|
+
- Live preview updates `style.width`/`style.height`; final size is persisted to node attrs on pointerup.
|
|
149
|
+
- Reference: `resizable-image.js:195-271`
|
|
150
|
+
|
|
151
|
+
## Selection and Focus
|
|
152
|
+
|
|
153
|
+
- Menu and handles overlay appear only when the image node is selected.
|
|
154
|
+
- Clicking outside the image deselects it and hides menu/overlay.
|
|
155
|
+
- Reference: show/hide logic `resizable-image.js:302-321`, `resizable-image.js:324-334`; outside-click handling `resizable-image.js:456-483`.
|
|
156
|
+
|
|
157
|
+
## HTML Icons
|
|
158
|
+
|
|
159
|
+
- Menu buttons render using `innerHTML`, allowing HTML strings for icons or rich content.
|
|
160
|
+
- Use trusted sources; avoid unsanitized user input to prevent XSS.
|
|
161
|
+
- Reference: `resizable-image.js:120-123` and `resizable-image.js:291-294`.
|
|
162
|
+
|
|
163
|
+
## Demo Hooks
|
|
164
|
+
|
|
165
|
+
- Example toolbar integration in the demo (`index.html:37-73`) shows inserting images and alignment controls.
|
|
166
|
+
|
|
167
|
+
## Notes
|
|
168
|
+
|
|
169
|
+
- The extension is inline and draggable; selection toggles menu/handles.
|
|
170
|
+
- Deselecting removes the overlay/menu from the DOM to keep markup clean.
|
package/npm/index.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core'
|
|
2
|
+
import { NodeSelection, TextSelection, Plugin } from 'prosemirror-state'
|
|
3
|
+
|
|
4
|
+
function createHandle(direction) {
|
|
5
|
+
const handle = document.createElement('div')
|
|
6
|
+
handle.className = `resize-handle handle-${direction}`
|
|
7
|
+
handle.dataset.direction = direction
|
|
8
|
+
handle.title = direction
|
|
9
|
+
return handle
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class ResizableImageView {
|
|
13
|
+
constructor(node, view, getPos, options) {
|
|
14
|
+
this.node = node
|
|
15
|
+
this.view = view
|
|
16
|
+
this.getPos = getPos
|
|
17
|
+
this.options = options || {}
|
|
18
|
+
this.aspect = (node.attrs.height && node.attrs.width) ? node.attrs.width / node.attrs.height : null
|
|
19
|
+
|
|
20
|
+
this.dom = document.createElement('span')
|
|
21
|
+
this.dom.className = 'tiptap-izzy-resizable-image'
|
|
22
|
+
this.dom.style.display = ''
|
|
23
|
+
this.dom.style.width = ''
|
|
24
|
+
this.dom.setAttribute('draggable', 'true')
|
|
25
|
+
|
|
26
|
+
this.wrapper = document.createElement('div')
|
|
27
|
+
this.wrapper.className = 'resizable-image-wrapper'
|
|
28
|
+
this.wrapper.style.display = 'inline-block'
|
|
29
|
+
this.wrapper.style.width = ''
|
|
30
|
+
this.dom.appendChild(this.wrapper)
|
|
31
|
+
|
|
32
|
+
this.inner = document.createElement('span')
|
|
33
|
+
this.inner.className = 'resizable-image-inner'
|
|
34
|
+
this.inner.style.position = 'relative'
|
|
35
|
+
this.inner.style.display = 'inline-block'
|
|
36
|
+
this.wrapper.appendChild(this.inner)
|
|
37
|
+
|
|
38
|
+
this.img = document.createElement('img')
|
|
39
|
+
this.img.src = node.attrs.src
|
|
40
|
+
this.img.alt = node.attrs.alt || ''
|
|
41
|
+
this.img.style.display = 'block'
|
|
42
|
+
this.img.style.userSelect = 'none'
|
|
43
|
+
this.img.style.pointerEvents = 'none'
|
|
44
|
+
if (node.attrs.width) this.img.style.width = node.attrs.width + 'px'
|
|
45
|
+
if (node.attrs.height) {
|
|
46
|
+
this.img.style.height = node.attrs.height + 'px'
|
|
47
|
+
} else if (this.options && this.options.height) {
|
|
48
|
+
this.img.style.height = this.options.height + 'px'
|
|
49
|
+
}
|
|
50
|
+
this.img.addEventListener('load', () => {
|
|
51
|
+
if (!this.aspect && this.img.naturalWidth && this.img.naturalHeight) {
|
|
52
|
+
this.aspect = this.img.naturalWidth / this.img.naturalHeight
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
this.inner.appendChild(this.img)
|
|
56
|
+
|
|
57
|
+
this.overlay = document.createElement('div')
|
|
58
|
+
this.overlay.className = 'resize-overlay'
|
|
59
|
+
this.overlay.style.position = 'absolute'
|
|
60
|
+
this.overlay.style.left = '0'
|
|
61
|
+
this.overlay.style.top = '0'
|
|
62
|
+
this.overlay.style.right = '0'
|
|
63
|
+
this.overlay.style.bottom = '0'
|
|
64
|
+
this.overlay.style.pointerEvents = 'none'
|
|
65
|
+
|
|
66
|
+
const directions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
|
67
|
+
this.handles = directions.map(dir => {
|
|
68
|
+
const h = createHandle(dir)
|
|
69
|
+
h.style.pointerEvents = 'auto'
|
|
70
|
+
h.addEventListener('pointerdown', this.onPointerDown.bind(this))
|
|
71
|
+
this.overlay.appendChild(h)
|
|
72
|
+
return h
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
this.menu = document.createElement('div')
|
|
76
|
+
this.menu.className = 'resizable-image-menu'
|
|
77
|
+
this.menu.style.position = 'absolute'
|
|
78
|
+
this.menu.style.left = '50%'
|
|
79
|
+
this.menu.style.transform = 'translateX(-50%)'
|
|
80
|
+
this.menu.style.pointerEvents = 'auto'
|
|
81
|
+
this.menu.style.zIndex = '10'
|
|
82
|
+
|
|
83
|
+
this.btnLeft = document.createElement('button')
|
|
84
|
+
this.btnCenter = document.createElement('button')
|
|
85
|
+
this.btnRight = document.createElement('button')
|
|
86
|
+
this.btnClear = document.createElement('button')
|
|
87
|
+
|
|
88
|
+
this.btnLeft.innerHTML = (node.attrs.iconLeft != null ? node.attrs.iconLeft : (this.options.alignMenuIcons?.left ?? '⟸'))
|
|
89
|
+
this.btnCenter.innerHTML = (node.attrs.iconCenter != null ? node.attrs.iconCenter : (this.options.alignMenuIcons?.center ?? '⇔'))
|
|
90
|
+
this.btnRight.innerHTML = (node.attrs.iconRight != null ? node.attrs.iconRight : (this.options.alignMenuIcons?.right ?? '⟹'))
|
|
91
|
+
this.btnClear.innerHTML = (node.attrs.iconClear != null ? node.attrs.iconClear : (this.options.alignMenuIcons?.clear ?? 'x'))
|
|
92
|
+
|
|
93
|
+
this.btnLeft.addEventListener('click', () => this.applyAlignAttr('left'))
|
|
94
|
+
this.btnCenter.addEventListener('click', () => this.applyAlignAttr('center'))
|
|
95
|
+
this.btnRight.addEventListener('click', () => this.applyAlignAttr('right'))
|
|
96
|
+
this.btnClear.addEventListener('click', () => this.applyAlignAttr(null))
|
|
97
|
+
|
|
98
|
+
this.menu.appendChild(this.btnLeft)
|
|
99
|
+
this.menu.appendChild(this.btnCenter)
|
|
100
|
+
this.menu.appendChild(this.btnRight)
|
|
101
|
+
this.menu.appendChild(this.btnClear)
|
|
102
|
+
|
|
103
|
+
this.updateMenuPosition(node.attrs.alignMenuPosition != null ? node.attrs.alignMenuPosition : (this.options && this.options.alignMenuPosition != null ? this.options.alignMenuPosition : 'below'))
|
|
104
|
+
|
|
105
|
+
this.dom.addEventListener('dragstart', (e) => {
|
|
106
|
+
const dragImg = document.createElement('canvas')
|
|
107
|
+
dragImg.width = 1
|
|
108
|
+
dragImg.height = 1
|
|
109
|
+
e.dataTransfer.setDragImage(dragImg, 0, 0)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
this.dragging = null
|
|
113
|
+
this.applyAlignment(node.attrs.align)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
updateMenuPosition(pos) {
|
|
117
|
+
this.menu.classList.remove('menu-above', 'menu-below')
|
|
118
|
+
if (pos === 'above') {
|
|
119
|
+
this.menu.classList.add('menu-above')
|
|
120
|
+
this.menu.style.top = '-32px'
|
|
121
|
+
this.menu.style.bottom = ''
|
|
122
|
+
} else {
|
|
123
|
+
this.menu.classList.add('menu-below')
|
|
124
|
+
this.menu.style.bottom = '-32px'
|
|
125
|
+
this.menu.style.top = ''
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
applyAlignAttr(align) {
|
|
130
|
+
const pos = this.getPos()
|
|
131
|
+
const tr = this.view.state.tr.setNodeMarkup(pos, null, { ...this.node.attrs, align })
|
|
132
|
+
this.view.dispatch(tr)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
applyAlignment(align) {
|
|
136
|
+
this.inner.style.marginLeft = ''
|
|
137
|
+
this.inner.style.marginRight = ''
|
|
138
|
+
this.wrapper.style.textAlign = ''
|
|
139
|
+
this.inner.style.display = 'inline-block'
|
|
140
|
+
if (align === 'center' || align === 'left' || align === 'right') {
|
|
141
|
+
this.wrapper.style.display = 'block'
|
|
142
|
+
} else {
|
|
143
|
+
this.wrapper.style.display = 'inline-block'
|
|
144
|
+
}
|
|
145
|
+
if (align === 'center') {
|
|
146
|
+
this.wrapper.style.textAlign = 'center'
|
|
147
|
+
} else if (align === 'right') {
|
|
148
|
+
this.wrapper.style.textAlign = 'right'
|
|
149
|
+
} else if (align === 'left') {
|
|
150
|
+
this.wrapper.style.textAlign = 'left'
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onPointerDown(event) {
|
|
155
|
+
event.preventDefault()
|
|
156
|
+
const direction = event.currentTarget.dataset.direction
|
|
157
|
+
const rect = this.img.getBoundingClientRect()
|
|
158
|
+
this.dragging = {
|
|
159
|
+
direction,
|
|
160
|
+
startX: event.clientX,
|
|
161
|
+
startY: event.clientY,
|
|
162
|
+
startWidth: rect.width,
|
|
163
|
+
startHeight: rect.height,
|
|
164
|
+
startLeft: rect.left,
|
|
165
|
+
startTop: rect.top,
|
|
166
|
+
keepAspect: true,
|
|
167
|
+
}
|
|
168
|
+
this._moveHandler = this.onPointerMove.bind(this)
|
|
169
|
+
this._upHandler = this.onPointerUp.bind(this)
|
|
170
|
+
window.addEventListener('pointermove', this._moveHandler)
|
|
171
|
+
window.addEventListener('pointerup', this._upHandler, { once: true })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onPointerMove(event) {
|
|
175
|
+
if (!this.dragging) return
|
|
176
|
+
const d = this.dragging
|
|
177
|
+
const dx = event.clientX - d.startX
|
|
178
|
+
const dy = event.clientY - d.startY
|
|
179
|
+
let newWidth = d.startWidth
|
|
180
|
+
let newHeight = d.startHeight
|
|
181
|
+
const dir = d.direction
|
|
182
|
+
const signX = (dir.includes('e') ? 1 : (dir.includes('w') ? -1 : 0))
|
|
183
|
+
const signY = (dir.includes('s') ? 1 : (dir.includes('n') ? -1 : 0))
|
|
184
|
+
if (signX !== 0) newWidth = Math.max(20, d.startWidth + dx * signX)
|
|
185
|
+
if (signY !== 0) newHeight = Math.max(20, d.startHeight + dy * signY)
|
|
186
|
+
if (d.keepAspect && this.aspect) {
|
|
187
|
+
if (signX !== 0 && signY === 0) {
|
|
188
|
+
newHeight = Math.round(newWidth / this.aspect)
|
|
189
|
+
} else if (signY !== 0 && signX === 0) {
|
|
190
|
+
newWidth = Math.round(newHeight * this.aspect)
|
|
191
|
+
} else {
|
|
192
|
+
newHeight = Math.round(newWidth / this.aspect)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
this.img.style.width = newWidth + 'px'
|
|
196
|
+
this.img.style.height = newHeight + 'px'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
onPointerUp(event) {
|
|
200
|
+
window.removeEventListener('pointermove', this._moveHandler)
|
|
201
|
+
this._moveHandler = null
|
|
202
|
+
if (!this.dragging) return
|
|
203
|
+
const finalRect = this.img.getBoundingClientRect()
|
|
204
|
+
const newWidth = Math.round(finalRect.width)
|
|
205
|
+
const newHeight = Math.round(finalRect.height)
|
|
206
|
+
this.dragging = null
|
|
207
|
+
const pos = this.getPos()
|
|
208
|
+
const tr = this.view.state.tr.setNodeMarkup(pos, null, { ...this.node.attrs, width: newWidth, height: newHeight })
|
|
209
|
+
this.view.dispatch(tr)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
update(node) {
|
|
213
|
+
if (node.type !== this.node.type) return false
|
|
214
|
+
this.node = node
|
|
215
|
+
if (node.attrs.src !== this.img.src) this.img.src = node.attrs.src
|
|
216
|
+
this.img.alt = node.attrs.alt || ''
|
|
217
|
+
if (node.attrs.width) this.img.style.width = node.attrs.width + 'px'
|
|
218
|
+
else this.img.style.removeProperty('width')
|
|
219
|
+
if (node.attrs.height) this.img.style.height = node.attrs.height + 'px'
|
|
220
|
+
else this.img.style.removeProperty('height')
|
|
221
|
+
this.applyAlignment(node.attrs.align)
|
|
222
|
+
this.updateMenuPosition(node.attrs.alignMenuPosition != null ? node.attrs.alignMenuPosition : (this.options && this.options.alignMenuPosition != null ? this.options.alignMenuPosition : 'below'))
|
|
223
|
+
this.btnLeft.innerHTML = (node.attrs.iconLeft != null ? node.attrs.iconLeft : (this.options && this.options.alignMenuIcons ? this.options.alignMenuIcons.left : '⟸'))
|
|
224
|
+
this.btnCenter.innerHTML = (node.attrs.iconCenter != null ? node.attrs.iconCenter : (this.options && this.options.alignMenuIcons ? this.options.alignMenuIcons.center : '⇔'))
|
|
225
|
+
this.btnRight.innerHTML = (node.attrs.iconRight != null ? node.attrs.iconRight : (this.options && this.options.alignMenuIcons ? this.options.alignMenuIcons.right : '⟹'))
|
|
226
|
+
this.btnClear.innerHTML = (node.attrs.iconClear != null ? node.attrs.iconClear : (this.options && this.options.alignMenuIcons ? this.options.alignMenuIcons.clear : 'x'))
|
|
227
|
+
this.aspect = (node.attrs.height && node.attrs.width) ? node.attrs.width / node.attrs.height : this.aspect
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
selectNode() {
|
|
232
|
+
this.dom.classList.add('selected')
|
|
233
|
+
const showMenu = (this.node.attrs.showAlignMenu != null ? this.node.attrs.showAlignMenu : (this.options && this.options.showAlignMenu != null ? this.options.showAlignMenu : true))
|
|
234
|
+
if (showMenu !== false) {
|
|
235
|
+
this.dom.classList.add('show-menu')
|
|
236
|
+
if (!this.inner.contains(this.menu)) {
|
|
237
|
+
this.inner.appendChild(this.menu)
|
|
238
|
+
}
|
|
239
|
+
this.menu.style.display = ''
|
|
240
|
+
} else {
|
|
241
|
+
this.dom.classList.remove('show-menu')
|
|
242
|
+
if (this.inner.contains(this.menu)) {
|
|
243
|
+
this.inner.removeChild(this.menu)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (!this.inner.contains(this.overlay)) {
|
|
247
|
+
this.inner.appendChild(this.overlay)
|
|
248
|
+
}
|
|
249
|
+
this.overlay.style.pointerEvents = 'auto'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
deselectNode() {
|
|
253
|
+
this.dom.classList.remove('selected')
|
|
254
|
+
this.dom.classList.remove('show-menu')
|
|
255
|
+
if (this.inner.contains(this.menu)) {
|
|
256
|
+
this.inner.removeChild(this.menu)
|
|
257
|
+
}
|
|
258
|
+
if (this.inner.contains(this.overlay)) {
|
|
259
|
+
this.inner.removeChild(this.overlay)
|
|
260
|
+
}
|
|
261
|
+
this.overlay.style.pointerEvents = 'none'
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
stopEvent(event) {
|
|
265
|
+
return (
|
|
266
|
+
(event.target.classList && event.target.classList.contains('resize-handle')) ||
|
|
267
|
+
(this.menu && this.menu.contains(event.target))
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
destroy() {
|
|
272
|
+
window.removeEventListener('pointermove', this._moveHandler)
|
|
273
|
+
window.removeEventListener('pointerup', this._upHandler)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const TiptapIzzyExtensionResizableImage = Node.create({
|
|
278
|
+
name: 'tiptap-izzy-extension-resizable-image',
|
|
279
|
+
inline: true,
|
|
280
|
+
group: 'inline',
|
|
281
|
+
draggable: true,
|
|
282
|
+
selectable: true,
|
|
283
|
+
atom: true,
|
|
284
|
+
|
|
285
|
+
addAttributes() {
|
|
286
|
+
return {
|
|
287
|
+
src: { default: null },
|
|
288
|
+
alt: { default: null },
|
|
289
|
+
title: { default: null },
|
|
290
|
+
width: { default: null },
|
|
291
|
+
height: { default: null },
|
|
292
|
+
align: { default: 'left' },
|
|
293
|
+
showAlignMenu: { default: null },
|
|
294
|
+
alignMenuPosition: { default: null },
|
|
295
|
+
iconLeft: { default: null },
|
|
296
|
+
iconCenter: { default: null },
|
|
297
|
+
iconRight: { default: null },
|
|
298
|
+
iconClear: { default: null },
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
parseHTML() {
|
|
303
|
+
return [{ tag: 'img[src]' }]
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
renderHTML({ HTMLAttributes }) {
|
|
307
|
+
return ['img', HTMLAttributes]
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
addNodeView() {
|
|
311
|
+
return ({ node, view, getPos }) => new ResizableImageView(node, view, getPos, this.options)
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
addOptions() {
|
|
315
|
+
return {
|
|
316
|
+
height: null,
|
|
317
|
+
showAlignMenu: true,
|
|
318
|
+
alignMenuPosition: 'below',
|
|
319
|
+
alignMenuIcons: { left: '⟸', center: '⇔', right: '⟹', clear: 'x' },
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
addCommands() {
|
|
324
|
+
return {
|
|
325
|
+
insertResizableImage:
|
|
326
|
+
({ src, alt, title, width, height }) => ({ commands }) => {
|
|
327
|
+
return commands.insertContent({
|
|
328
|
+
type: this.name,
|
|
329
|
+
attrs: {
|
|
330
|
+
src,
|
|
331
|
+
alt,
|
|
332
|
+
title,
|
|
333
|
+
width,
|
|
334
|
+
height: height != null ? height : this.options.height,
|
|
335
|
+
showAlignMenu: this.options.showAlignMenu,
|
|
336
|
+
alignMenuPosition: this.options.alignMenuPosition,
|
|
337
|
+
iconLeft: this.options.alignMenuIcons?.left,
|
|
338
|
+
iconCenter: this.options.alignMenuIcons?.center,
|
|
339
|
+
iconRight: this.options.alignMenuIcons?.right,
|
|
340
|
+
iconClear: this.options.alignMenuIcons?.clear,
|
|
341
|
+
},
|
|
342
|
+
})
|
|
343
|
+
},
|
|
344
|
+
setResizableImageAlignment:
|
|
345
|
+
(align) => ({ editor }) => {
|
|
346
|
+
const { state } = editor
|
|
347
|
+
const sel = state.selection
|
|
348
|
+
if (sel instanceof NodeSelection && sel.node && sel.node.type.name === this.name) {
|
|
349
|
+
const pos = sel.$from.pos
|
|
350
|
+
const tr = state.tr.setNodeMarkup(pos, null, { ...sel.node.attrs, align })
|
|
351
|
+
editor.view.dispatch(tr.scrollIntoView())
|
|
352
|
+
return true
|
|
353
|
+
}
|
|
354
|
+
return false
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
addKeyboardShortcuts() {
|
|
360
|
+
return {
|
|
361
|
+
Enter: ({ editor }) => {
|
|
362
|
+
const { state } = editor
|
|
363
|
+
const { selection } = state
|
|
364
|
+
if (selection instanceof NodeSelection && selection.node && selection.node.type.name === this.name) {
|
|
365
|
+
const paragraph = state.schema.nodes.paragraph?.createAndFill()
|
|
366
|
+
if (!paragraph) return false
|
|
367
|
+
const tr = state.tr.insert(selection.to, paragraph)
|
|
368
|
+
const resolved = tr.doc.resolve(selection.to + 1)
|
|
369
|
+
tr.setSelection(TextSelection.create(tr.doc, resolved.pos))
|
|
370
|
+
editor.view.dispatch(tr.scrollIntoView())
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
return false
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
addProseMirrorPlugins() {
|
|
379
|
+
return [
|
|
380
|
+
new Plugin({
|
|
381
|
+
props: {
|
|
382
|
+
handleDOMEvents: {
|
|
383
|
+
mousedown: (view, event) => {
|
|
384
|
+
const sel = view.state.selection
|
|
385
|
+
if (sel instanceof NodeSelection && sel.node && sel.node.type.name === this.name) {
|
|
386
|
+
const nodeDOM = view.nodeDOM(sel.$from.pos)
|
|
387
|
+
const target = event.target
|
|
388
|
+
if (nodeDOM && target && nodeDOM.contains(target)) {
|
|
389
|
+
return false
|
|
390
|
+
}
|
|
391
|
+
const coords = { left: event.clientX, top: event.clientY }
|
|
392
|
+
const found = view.posAtCoords(coords)
|
|
393
|
+
if (found && typeof found.pos === 'number') {
|
|
394
|
+
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, found.pos))
|
|
395
|
+
view.dispatch(tr)
|
|
396
|
+
return true
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return false
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
]
|
|
405
|
+
},
|
|
406
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tiptap-extension-izzy-resizable-image",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Resizable image NodeView for TipTap v2 with alignment menu and defaults",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "npm/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./npm/index.js"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://bernanr.github.io/TipTap-Extension-Izzy-Resizable-Image",
|
|
12
|
+
"files": [
|
|
13
|
+
"npm/index.js",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/BernanR/TipTap-Extension-Izzy-Resizable-Image.git"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"tiptap",
|
|
22
|
+
"prosemirror",
|
|
23
|
+
"image",
|
|
24
|
+
"resizable",
|
|
25
|
+
"extension",
|
|
26
|
+
"nodeview"
|
|
27
|
+
],
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@tiptap/core": ">=3.0.0",
|
|
30
|
+
"prosemirror-state": "^1.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|