Skip to content

Commit 82ba910

Browse files
committed
feat(editor): add image paste-to-upload for markdown files
- Add ImageUploadManager service provider registry in core - Add paste interceptor on editor-holder that detects image clipboard content in markdown/gfm files and triggers upload flow - Insert uploading placeholder with spinner SVG, replace with embed URL - Show login dialog with image preview if not logged in - Show upsell dialog if upload quota exceeded - Add i18n strings for upload dialogs and messages
1 parent f5d1fb5 commit 82ba910

3 files changed

Lines changed: 232 additions & 1 deletion

File tree

src/editor/EditorCommandHandlers.js

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ define(function (require, exports, module) {
3737
TokenUtils = require("utils/TokenUtils"),
3838
CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"),
3939
_ = require("thirdparty/lodash"),
40-
ChangeHelper = require("editor/EditorHelper/ChangeHelper");
40+
ChangeHelper = require("editor/EditorHelper/ChangeHelper"),
41+
LanguageManager = require("language/LanguageManager"),
42+
ImageUploadManager = require("features/ImageUploadManager"),
43+
Dialogs = require("widgets/Dialogs"),
44+
AppInit = require("utils/AppInit");
4145

4246
/**
4347
* List of constants
@@ -1262,6 +1266,159 @@ define(function (require, exports, module) {
12621266
return result.promise();
12631267
}
12641268

1269+
// --- Image paste-to-upload for markdown files ---
1270+
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"];
1271+
1272+
function _handleImagePaste(editor, cm, event) {
1273+
const items = event.clipboardData && event.clipboardData.items;
1274+
if (!items) {
1275+
return false;
1276+
}
1277+
1278+
// Find an image item in the clipboard
1279+
let imageItem = null;
1280+
for (let i = 0; i < items.length; i++) {
1281+
if (items[i].kind === "file" && ALLOWED_IMAGE_TYPES.indexOf(items[i].type) !== -1) {
1282+
imageItem = items[i];
1283+
break;
1284+
}
1285+
}
1286+
if (!imageItem) {
1287+
return false;
1288+
}
1289+
1290+
// Only handle in markdown files
1291+
const doc = editor.document;
1292+
if (!doc || !doc.file) {
1293+
return false;
1294+
}
1295+
const lang = LanguageManager.getLanguageForPath(doc.file.fullPath);
1296+
const langId = lang ? lang.getId() : "";
1297+
if (langId !== "markdown" && langId !== "gfm") {
1298+
return false;
1299+
}
1300+
1301+
// Check if upload provider is available
1302+
if (!ImageUploadManager.isImageUploadAvailable()) {
1303+
return false;
1304+
}
1305+
1306+
event.preventDefault();
1307+
1308+
const blob = imageItem.getAsFile();
1309+
const fileName = blob.name || ("image." + blob.type.split("/")[1]);
1310+
const provider = ImageUploadManager.getImageUploadProvider();
1311+
const cmDoc = cm.getDoc();
1312+
1313+
// Upload asynchronously — provider shows confirmation dialog before uploading.
1314+
// Placeholder is inserted only after confirmation via onUploadStart callback.
1315+
let marker = null;
1316+
1317+
function _clearPlaceholder() {
1318+
if (marker) {
1319+
const range = marker.find();
1320+
marker.clear();
1321+
if (range) {
1322+
cmDoc.replaceRange("", range.from, range.to);
1323+
}
1324+
marker = null;
1325+
}
1326+
}
1327+
1328+
provider.uploadImage(blob, fileName, function onUploadStart() {
1329+
// Insert placeholder after user confirms (avoids dirtying the file on cancel)
1330+
const cursor = cmDoc.getCursor();
1331+
const placeholder = `![${Strings.IMAGE_UPLOADING} ${fileName}](https://user-cdn.phcode.site/images/uploading.svg)`;
1332+
cmDoc.replaceRange(placeholder, cursor);
1333+
const from = cursor;
1334+
const to = { line: cursor.line, ch: cursor.ch + placeholder.length };
1335+
marker = cmDoc.markText(from, to, { className: "image-uploading", clearWhenEmpty: false });
1336+
}).then(function (result) {
1337+
if (result.embedURL) {
1338+
// Success — replace placeholder with final embed
1339+
if (marker) {
1340+
const range = marker.find();
1341+
marker.clear();
1342+
marker = null;
1343+
if (range) {
1344+
cmDoc.replaceRange(`![${fileName}](${result.embedURL})`, range.from, range.to);
1345+
}
1346+
}
1347+
} else if (result.error === "cancelled") {
1348+
// User cancelled confirmation — nothing was inserted
1349+
} else if (result.error === "login_required") {
1350+
_clearPlaceholder();
1351+
const previewURL = URL.createObjectURL(blob);
1352+
const loginHTML = `<div>
1353+
<p>${Strings.IMAGE_UPLOAD_LOGIN_REQUIRED_MSG}</p>
1354+
<div style="text-align: center;">
1355+
<img src="${previewURL}" style="max-width: 300px; max-height: 200px; border: 1px solid #ccc; border-radius: 4px; margin: 12px 0;" />
1356+
</div>
1357+
</div>`;
1358+
const dialog = Dialogs.showModalDialog(
1359+
"",
1360+
Strings.IMAGE_UPLOAD_LOGIN_REQUIRED_TITLE,
1361+
loginHTML,
1362+
[
1363+
{ className: Dialogs.DIALOG_BTN_CLASS_NORMAL, id: Dialogs.DIALOG_BTN_CANCEL,
1364+
text: Strings.CANCEL },
1365+
{ className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: "login",
1366+
text: Strings.IMAGE_UPLOAD_LOGIN_BTN }
1367+
]
1368+
);
1369+
dialog.done(function (id) {
1370+
URL.revokeObjectURL(previewURL);
1371+
if (id === "login") {
1372+
const profileBtn = document.getElementById("user-profile-button");
1373+
if (profileBtn) {
1374+
profileBtn.click();
1375+
}
1376+
}
1377+
});
1378+
} else if (result.errorCode === "UPGRADE_TO_PRO") {
1379+
_clearPlaceholder();
1380+
const ProDialogs = brackets.getModule(
1381+
"extensionsIntegrated/phoenix-pro/services/pro-dialogs"
1382+
);
1383+
if (ProDialogs && ProDialogs.showUpsellDialog) {
1384+
ProDialogs.showUpsellDialog(
1385+
Strings.IMAGE_UPLOAD_LIMIT_TITLE,
1386+
result.errorLoc,
1387+
"imgUpload"
1388+
);
1389+
}
1390+
} else {
1391+
_clearPlaceholder();
1392+
console.error("Image upload failed:", result.error, result.errorLoc);
1393+
Dialogs.showModalDialog(
1394+
"",
1395+
Strings.IMAGE_UPLOAD_FAILED,
1396+
result.errorLoc || Strings.IMAGE_UPLOAD_FAILED
1397+
);
1398+
}
1399+
}).catch(function (err) {
1400+
_clearPlaceholder();
1401+
console.error("Image upload error:", err);
1402+
});
1403+
1404+
return true;
1405+
}
1406+
1407+
// Listen for paste events on the editor holder (capture phase) so image pastes
1408+
// are intercepted even when CM doesn't fire its own "paste" event (e.g. image-only clipboard).
1409+
AppInit.appReady(function () {
1410+
const editorHolder = document.getElementById("editor-holder");
1411+
if (editorHolder) {
1412+
editorHolder.addEventListener("paste", function (e) {
1413+
const editor = EditorManager.getFocusedEditor();
1414+
if (!editor) {
1415+
return;
1416+
}
1417+
_handleImagePaste(editor, editor._codeMirror, e);
1418+
}, true);
1419+
}
1420+
});
1421+
12651422
// Register commands
12661423
CommandManager.register(Strings.CMD_INDENT, Commands.EDIT_INDENT, indentText);
12671424
CommandManager.register(Strings.CMD_UNINDENT, Commands.EDIT_UNINDENT, unindentText);

src/features/ImageUploadManager.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* GNU AGPL-3.0 License
3+
*
4+
* Copyright (c) 2021 - present core.ai . All rights reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify it
7+
* under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful, but WITHOUT
12+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
14+
* for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
18+
*
19+
*/
20+
21+
/**
22+
* ImageUploadManager provides a service provider interface for image uploads.
23+
* Extensions (e.g. phoenix-pro) register an upload provider; core code (e.g.
24+
* the paste handler in EditorCommandHandlers) calls the provider to upload images.
25+
*
26+
* Provider interface:
27+
* {
28+
* uploadImage(blob, fileName) → Promise<{embedURL}|{error, errorCode, errorLoc}>
29+
* }
30+
*/
31+
define(function (require, exports, module) {
32+
33+
let _provider = null;
34+
35+
/**
36+
* Register an image upload provider. Only one provider is supported at a time.
37+
* @param {Object} provider - must have an `uploadImage(blob, fileName)` method
38+
*/
39+
function registerImageUploadProvider(provider) {
40+
if (!provider || typeof provider.uploadImage !== "function") {
41+
throw new Error("ImageUploadManager: provider must implement uploadImage(blob, fileName)");
42+
}
43+
_provider = provider;
44+
}
45+
46+
/**
47+
* @return {Object|null} The registered provider, or null if none registered.
48+
*/
49+
function getImageUploadProvider() {
50+
return _provider;
51+
}
52+
53+
/**
54+
* @return {boolean} True if an upload provider is registered.
55+
*/
56+
function isImageUploadAvailable() {
57+
return _provider !== null;
58+
}
59+
60+
exports.registerImageUploadProvider = registerImageUploadProvider;
61+
exports.getImageUploadProvider = getImageUploadProvider;
62+
exports.isImageUploadAvailable = isImageUploadAvailable;
63+
});

src/nls/root/strings.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,17 @@ define({
394394
"AVAILABLE_IN_PRO_TITLE": "Available in Phoenix Pro",
395395
"DEVICE_SIZE_LIMIT_MESSAGE": "Phoenix Pro lets you preview your page at the screen sizes defined in your CSS.",
396396
"MD_EDIT_UPSELL_MESSAGE": "Write Markdown like a document. Phoenix handles the formatting so you can stay focused on writing.",
397+
"IMAGE_UPLOADING": "Uploading",
398+
"IMAGE_UPLOAD_FAILED": "Failed to upload image",
399+
"IMAGE_UPLOAD_LOGIN_REQUIRED_TITLE": "Log in to Embed Image",
400+
"IMAGE_UPLOAD_LOGIN_REQUIRED_MSG": "Log in to upload and embed images in your document.",
401+
"IMAGE_UPLOAD_LOGIN_BTN": "Log In",
402+
"IMAGE_UPLOAD_CONFIRM_TITLE": "Embed Image",
403+
"IMAGE_UPLOAD_CONFIRM_MSG": "Upload this image so it's included directly in your file. It'll work on any computer — no broken images when you share or move markdown files.",
404+
"IMAGE_UPLOAD_HOSTED_ON": "Images are hosted on user-cdn.phcode.site",
405+
"IMAGE_UPLOAD_BTN": "Upload & Embed",
406+
"IMAGE_UPLOAD_DONT_SHOW_AGAIN": "Always embed without asking",
407+
"IMAGE_UPLOAD_LIMIT_TITLE": "Upload more images with Phoenix Pro",
397408
"IMAGE_SEARCH_LIMIT_TITLE": "Image search limit reached",
398409
"IMAGE_SEARCH_LIMIT_MESSAGE": "You’ve used all {0} image searches for this month.<br>Start a paid Phoenix Pro plan to remove trial limits and continue searching.",
399410
"IMAGE_SEARCH_LIMIT_MESSAGE_THROTTLE": "Image search is temporarily unavailable due to high demand.<br>Start a paid Phoenix Pro plan to remove trial limits and continue searching.",

0 commit comments

Comments
 (0)