Skip to content

Commit ba46c18

Browse files
committed
feat: register images from paste + HTML insertion, fixes #790
1 parent 649663b commit ba46c18

15 files changed

Lines changed: 488 additions & 164 deletions

File tree

.ackrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
--ignore-dir=packages/superdoc/dist
2+
--ignore-dir=packages/super-editor/dist/

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/super-editor/src/assets/styles/elements/prosemirror.css

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ img.ProseMirror-separator {
148148
margin-bottom: 1.5px;
149149
}
150150

151-
/*
152-
Tables
151+
/*
152+
Tables
153153
https://github.com/ProseMirror/prosemirror-tables/blob/master/style/tables.css
154154
https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html
155155
*/
@@ -166,8 +166,8 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html
166166
scrollbar-width: thin;
167167
overflow: hidden;
168168

169-
/*
170-
The border width does not need to be multiplied by two,
169+
/*
170+
The border width does not need to be multiplied by two,
171171
for tables it works differently. */
172172
width: calc(100% + (var(--table-border-width) + var(--offset)));
173173
}
@@ -292,19 +292,25 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html
292292
/* Collaboration cursors - end */
293293

294294
/* Image placeholder */
295-
.ProseMirror placeholder {
295+
.ProseMirror placeholder,
296+
.ProseMirror img.placeholder {
296297
display: inline;
297298
border: 1px solid #ccc;
298299
color: #ccc;
299300
}
300301

301-
.ProseMirror placeholder:after {
302+
.ProseMirror placeholder:after,
303+
.ProseMirror img.placeholder:after {
302304
content: '☁';
303305
font-size: 200%;
304306
line-height: 0.1;
305307
font-weight: bold;
306308
}
307309

310+
.ProseMirror placeholder img {
311+
display: none !important;
312+
}
313+
308314
/* Gapcursor */
309315
.ProseMirror-gapcursor {
310316
display: none;

packages/super-editor/src/components/toolbar/super-toolbar.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { makeDefaultItems } from './defaultItems';
55
import { getActiveFormatting } from '@core/helpers/getActiveFormatting.js';
66
import { vClickOutside } from '@harbour-enterprises/common';
77
import Toolbar from './Toolbar.vue';
8-
import { startImageUpload, getFileOpener } from '../../extensions/image/imageHelpers/index.js';
8+
import {
9+
checkAndProcessImage,
10+
replaceSelectionWithImagePlaceholder,
11+
uploadAndInsertImage,
12+
getFileOpener,
13+
} from '../../extensions/image/imageHelpers/index.js';
914
import { findParentNode } from '@helpers/index.js';
1015
import { toolbarIcons } from './toolbarIcons.js';
1116
import { toolbarTexts } from './toolbarTexts.js';
@@ -366,10 +371,30 @@ export class SuperToolbar extends EventEmitter {
366371
return;
367372
}
368373

369-
startImageUpload({
370-
editor: this.activeEditor,
374+
const { size, file } = await checkAndProcessImage({
371375
view: this.activeEditor.view,
372376
file: result.file,
377+
getMaxContentSize: () => this.activeEditor.getMaxContentSize(),
378+
});
379+
380+
if (!file) {
381+
return;
382+
}
383+
384+
const id = {};
385+
386+
replaceSelectionWithImagePlaceholder({
387+
view: this.activeEditor.view,
388+
editorOptions: this.activeEditor.options,
389+
id,
390+
});
391+
392+
await uploadAndInsertImage({
393+
editor: this.activeEditor,
394+
view: this.activeEditor.view,
395+
file,
396+
size,
397+
id,
373398
});
374399
},
375400

packages/super-editor/src/core/super-converter/exporter.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,6 +1546,7 @@ function getScaledSize(originalWidth, originalHeight, maxWidth, maxHeight) {
15461546
}
15471547

15481548
function translateImageNode(params, imageSize) {
1549+
console.log('translateImageNode', { params, imageSize });
15491550
const {
15501551
node: { attrs = {} },
15511552
tableCell,

packages/super-editor/src/extensions/image/image.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Attribute, Node } from '@core/index.js';
2-
import { ImagePlaceholderPlugin } from './imageHelpers/imagePlaceholderPlugin.js';
2+
import { ImageRegistrationPlugin } from './imageHelpers/imageRegistrationPlugin.js';
33
import { ImagePositionPlugin } from './imageHelpers/imagePositionPlugin.js';
44

55
export const Image = Node.create({
@@ -147,6 +147,6 @@ export const Image = Node.create({
147147
},
148148

149149
addPmPlugins() {
150-
return [ImagePlaceholderPlugin(), ImagePositionPlugin({ editor: this.editor })];
150+
return [ImageRegistrationPlugin({ editor: this.editor }), ImagePositionPlugin({ editor: this.editor })];
151151
},
152152
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const simpleHash = (str) => {
2+
let hash = 0;
3+
for (let i = 0; i < str.length; i++) {
4+
const char = str.charCodeAt(i);
5+
hash = (hash << 5) - hash + char;
6+
hash = hash & hash; // Convert to 32-bit integer
7+
}
8+
return Math.abs(hash).toString();
9+
};
10+
11+
export const base64ToFile = (base64String) => {
12+
const arr = base64String.split(',');
13+
const mimeMatch = arr[0].match(/:(.*?);/);
14+
const mimeType = mimeMatch ? mimeMatch[1] : '';
15+
const data = arr[1];
16+
17+
// Decode the base64 string
18+
const binaryString = atob(data);
19+
20+
// Generate filename using a hash of the binary data
21+
const hash = simpleHash(binaryString);
22+
const extension = mimeType.split('/')[1] || 'bin'; // Simple way to get extension
23+
const filename = `image-${hash}.${extension}`;
24+
25+
// Create a typed array from the binary string
26+
const bytes = new Uint8Array(binaryString.length);
27+
for (let i = 0; i < binaryString.length; i++) {
28+
bytes[i] = binaryString.charCodeAt(i);
29+
}
30+
31+
// Create a Blob and then a File
32+
const blob = new Blob([bytes], { type: mimeType });
33+
return new File([blob], filename, { type: mimeType });
34+
};
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Handles URL to File conversion with comprehensive CORS error handling
3+
*/
4+
5+
/**
6+
* Converts a URL to a File object with proper CORS error handling
7+
* @param {string} url - The image URL to fetch
8+
* @param {string} [filename] - Optional filename for the resulting file
9+
* @param {string} [mimeType] - Optional MIME type for the resulting file
10+
* @returns {Promise<File|null>} File object or null if CORS prevents access
11+
*/
12+
export const urlToFile = async (url, filename, mimeType) => {
13+
try {
14+
// Try to fetch the image with credentials mode set to 'omit' to avoid CORS preflight
15+
const response = await fetch(url, {
16+
mode: 'cors',
17+
credentials: 'omit',
18+
headers: {
19+
// Add common headers that might help with CORS
20+
Accept: 'image/*,*/*;q=0.8',
21+
},
22+
});
23+
24+
if (!response.ok) {
25+
console.warn(`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`);
26+
return null;
27+
}
28+
29+
const blob = await response.blob();
30+
31+
// Extract filename from URL if not provided
32+
const finalFilename = filename || extractFilenameFromUrl(url);
33+
34+
// Determine MIME type from response if not provided
35+
const finalMimeType = mimeType || response.headers.get('content-type') || blob.type || 'image/jpeg';
36+
37+
return new File([blob], finalFilename, { type: finalMimeType });
38+
} catch (error) {
39+
if (isCorsError(error)) {
40+
console.warn(`CORS policy prevents accessing image from ${url}:`, error.message);
41+
return null;
42+
}
43+
44+
console.error(`Error fetching image from ${url}:`, error);
45+
return null;
46+
}
47+
};
48+
49+
/**
50+
* Checks if an error is likely a CORS-related error
51+
* @param {Error} error - The error to check
52+
* @returns {boolean} True if the error appears to be CORS-related
53+
*/
54+
const isCorsError = (error) => {
55+
const errorMessage = error.message.toLowerCase();
56+
const errorName = error.name.toLowerCase();
57+
58+
return (
59+
errorName.includes('cors') ||
60+
errorMessage.includes('cors') ||
61+
errorMessage.includes('cross-origin') ||
62+
errorMessage.includes('access-control') ||
63+
errorMessage.includes('network error') || // Often indicates CORS in browsers
64+
errorMessage.includes('failed to fetch') // Common CORS error message
65+
);
66+
};
67+
68+
/**
69+
* Extracts a filename from a URL
70+
* @param {string} url - The URL to extract filename from
71+
* @returns {string} The extracted filename
72+
*/
73+
const extractFilenameFromUrl = (url) => {
74+
try {
75+
const urlObj = new URL(url);
76+
const pathname = urlObj.pathname;
77+
const filename = pathname.split('/').pop();
78+
79+
// If no extension, add a default one
80+
if (filename && !filename.includes('.')) {
81+
return `${filename}.jpg`;
82+
}
83+
84+
return filename || 'image.jpg';
85+
} catch {
86+
return 'image.jpg';
87+
}
88+
};
89+
90+
/**
91+
* Validates if a URL can be accessed without CORS issues
92+
* @param {string} url - The URL to validate
93+
* @returns {Promise<boolean>} True if the URL is accessible without CORS issues
94+
*/
95+
export const validateUrlAccessibility = async (url) => {
96+
try {
97+
const response = await fetch(url, {
98+
method: 'HEAD',
99+
mode: 'cors',
100+
credentials: 'omit',
101+
});
102+
return response.ok;
103+
} catch (error) {
104+
return false;
105+
}
106+
};

packages/super-editor/src/extensions/image/imageHelpers/imagePlaceholderPlugin.js

Lines changed: 0 additions & 57 deletions
This file was deleted.

0 commit comments

Comments
 (0)