Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
3c39f49
feat: prevent user elements like links and buttons from handling the …
devvaannsh Nov 24, 2025
9e3478b
fix: internal blocker also blocking in preview mode
devvaannsh Nov 24, 2025
e5e24b1
fix: cursor not getting positioned properly on element click in edit …
devvaannsh Nov 24, 2025
15a8768
feat: check and clear any selection if anything exists before selecti…
devvaannsh Nov 24, 2025
0785315
refactor: use better style when image gallery svg is selected
devvaannsh Nov 24, 2025
d80ea2b
feat: show pre-filled folder in image folder dialog
devvaannsh Nov 24, 2025
4abb404
fix: root directories not being displayed when user clears a pre fill…
devvaannsh Nov 25, 2025
8499926
fix: drag drop not working because mouse down is blocked
devvaannsh Nov 25, 2025
208226e
refactor: improve suggestions list colors to match it with tab bar
devvaannsh Nov 25, 2025
3653700
fix: tests failing as boxes not getting dismissed on click on body or…
devvaannsh Nov 25, 2025
79b260a
feat: show a toast message when folder selection dialog is open
devvaannsh Nov 26, 2025
d51696f
refactor: better UI for the toast message
devvaannsh Nov 26, 2025
86c11f2
fix: localize the dialog overlay string
devvaannsh Nov 26, 2025
9b18baf
fix: use higher z-index so that overlay does not get hidden
devvaannsh Nov 26, 2025
79c305a
fix: window focus handlers are never cleared which was causing repeti…
devvaannsh Nov 27, 2025
db16740
feat: add cut copy paste buttons in options box
devvaannsh Nov 27, 2025
c1877c7
refactor: move the edit options inside a dropdown
devvaannsh Nov 27, 2025
fb08a2b
feat: localize the strings used in the dropdown
devvaannsh Nov 27, 2025
dc05338
refactor: make dropdown consistent to existing phoenix colors
devvaannsh Nov 27, 2025
c714d96
fix: dropdown not getting dismissed on other element click
devvaannsh Nov 27, 2025
e7ec0e5
refactor: use font awesome icons for cut copy paste
devvaannsh Nov 27, 2025
e76b50c
fix: improvised positioning of the dropdown
devvaannsh Nov 27, 2025
3537382
feat: add ruler lines feature when element is clicked
devvaannsh Nov 29, 2025
2b9a071
fix: ruler lines not getting updated unless when clicked element changes
devvaannsh Nov 29, 2025
ec2ae2c
feat: ruler lines and outline to be shown in blue for non-editable el…
devvaannsh Nov 29, 2025
37c1261
refactor: use rgba vals with opacity to ruler lines to make it less d…
devvaannsh Nov 29, 2025
42f9552
feat: also add show ruler lines option in the more options dropdown
devvaannsh Nov 29, 2025
403e7bb
refactor: improve checkmark styles in more options dropdown
devvaannsh Nov 29, 2025
c53e196
refactor: change border color of the more options dropdown
devvaannsh Nov 29, 2025
8e349d2
fix: text editing in table, li, option etc not working because of dom…
devvaannsh Nov 29, 2025
b97dfe4
fix: indentation breaks when pasting a copied element
devvaannsh Nov 30, 2025
b34b701
feat: show first time copy cut toast message
devvaannsh Nov 30, 2025
d5cf4fd
feat: add edit link support for anchor tags
devvaannsh Dec 1, 2025
855a254
refactor: make hyperlink styles consistent to other elements
devvaannsh Dec 1, 2025
2ec9f7d
refactor: modify the link icon
devvaannsh Dec 1, 2025
f106577
feat: show a link icon in the hyperlink editor
devvaannsh Dec 1, 2025
dec1502
feat: show href link in info box for anchor elements
devvaannsh Dec 1, 2025
ba916ab
feat: add 35 char max limit for urls in the info box
devvaannsh Dec 1, 2025
bc6972d
fix: flicker issue after hyperlink edit completes
devvaannsh Dec 2, 2025
73f2ac2
fix: prevent more options dropdown from resizing because of checkmark
devvaannsh Dec 2, 2025
b4a6597
fix: slight pixel inconsistencies between ruler and elements outline
devvaannsh Dec 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@
ProtocolManager.enable();
});

function _getAllInheritedSelectorsInOrder(element) {
function getAllInheritedSelectorsInOrder(element) {
let selectorsFound= new Map();
const selectorsList = [];
while (element) {
Expand All @@ -383,6 +383,7 @@
return selectorsList;
}

global.getAllInheritedSelectorsInOrder = getAllInheritedSelectorsInOrder;

/**
* Sends the message containing tagID which is being clicked
Expand All @@ -407,7 +408,7 @@
"nodeID": element.id,
"nodeClassList": element.classList,
"nodeName": element.nodeName,
"allSelectors": _getAllInheritedSelectorsInOrder(element),
"allSelectors": getAllInheritedSelectorsInOrder(element),
"contentEditable": element.contentEditable === 'true',
"clicked": true,
"edit": true
Expand All @@ -431,7 +432,7 @@
"nodeID": element.id,
"nodeClassList": element.classList,
"nodeName": element.nodeName,
"allSelectors": _getAllInheritedSelectorsInOrder(element),
"allSelectors": getAllInheritedSelectorsInOrder(element),
"contentEditable": element.contentEditable === 'true',
"clicked": true
});
Expand Down
1,020 changes: 983 additions & 37 deletions src/LiveDevelopment/BrowserScripts/RemoteFunctions.js

Large diffs are not rendered by default.

233 changes: 205 additions & 28 deletions src/LiveDevelopment/LivePreviewEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
const LiveDevelopment = require("LiveDevelopment/main");
const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror");
const ProjectManager = require("project/ProjectManager");
const PreferencesManager = require("preferences/PreferencesManager");
const CommandManager = require("command/CommandManager");
const Commands = require("command/Commands");
const FileSystem = require("filesystem/FileSystem");
Expand All @@ -46,7 +47,12 @@
const IMAGE_DOWNLOAD_FOLDER_KEY = "imageGallery.downloadFolder";
const IMAGE_DOWNLOAD_PERSIST_FOLDER_KEY = "imageGallery.persistFolder";

// state manager key for tracking if copy/cut toast has been shown
const COPY_CUT_TOAST_SHOWN_KEY = "livePreviewEdit.copyToastShown";

const DOWNLOAD_EVENTS = {
DIALOG_OPENED: 'dialogOpened',
DIALOG_CLOSED: 'dialogClosed',
STARTED: 'downloadStarted',
COMPLETED: 'downloadCompleted',
CANCELLED: 'downloadCancelled',
Expand All @@ -72,7 +78,7 @@
* we only care about text changes or things like newlines, <br>, or formatting like <b>, <i>, etc.
*
* Here's the basic idea:
* - Parse both old and new HTML strings into DOM trees
* - Parse both old and new HTML strings into document fragments using <template> elements
* - Then walk both DOMs side by side and sync changes
*
* What we handle:
Expand All @@ -87,12 +93,14 @@
* This avoids the browser trying to “fix” broken HTML (which we don’t want)
*/
function _syncTextContentChanges(oldContent, newContent) {
const parser = new DOMParser();
const oldDoc = parser.parseFromString(oldContent, "text/html");
const newDoc = parser.parseFromString(newContent, "text/html");
function parseFragment(html) {
const t = document.createElement("template");
t.innerHTML = html;
return t.content;
}

const oldRoot = oldDoc.body;
const newRoot = newDoc.body;
const oldRoot = parseFragment(oldContent);
const newRoot = parseFragment(newContent);

// this function is to remove the phoenix internal attributes from leaking into the user's source code
function cleanClonedElement(clonedElement) {
Expand Down Expand Up @@ -164,14 +172,16 @@
}
}

const oldEls = Array.from(oldRoot.children);
const newEls = Array.from(newRoot.children);
const oldEls = Array.from(oldRoot.childNodes);
const newEls = Array.from(newRoot.childNodes);

for (let i = 0; i < Math.min(oldEls.length, newEls.length); i++) {
syncText(oldEls[i], newEls[i]);
}

return oldRoot.innerHTML;
return Array.from(oldRoot.childNodes).map(node =>
node.outerHTML || node.textContent
).join("");
}

/**
Expand Down Expand Up @@ -313,6 +323,88 @@
});
}

/**
* this saves the element to clipboard and deletes its source code
* @param {Number} tagId
*/
function _cutElementToClipboard(tagId) {
const editor = _getEditorAndValidate(tagId);
if (!editor) {
return;
}

const range = _getElementRange(editor, tagId);
if (!range) {
return;
}

const { startPos, endPos } = range;
const text = editor.getTextBetween(startPos, endPos);

Phoenix.app.copyToClipboard(text);
_showCopyToastIfNeeded();

// delete the elements source code
editor.document.batchOperation(function () {
editor.replaceRange("", startPos, endPos);

// clean up any empty line
if(startPos.line !== 0 && !(editor.getLine(startPos.line).trim())) {
const prevLineText = editor.getLine(startPos.line - 1);
const chPrevLine = prevLineText ? prevLineText.length : 0;
editor.replaceRange("", {line: startPos.line - 1, ch: chPrevLine}, startPos);
}
});
}

function _copyElementToClipboard(tagId) {
const editor = _getEditorAndValidate(tagId);
if (!editor) {
return;
}

const range = _getElementRange(editor, tagId);
if (!range) {
return;
}

const { startPos, endPos } = range;
const text = editor.getTextBetween(startPos, endPos);

Phoenix.app.copyToClipboard(text);
_showCopyToastIfNeeded();
}

/**
* this function is to paste the clipboard content above the target element
* @param {Number} tagId
*/
function _pasteElementFromClipboard(tagId) {
const editor = _getEditorAndValidate(tagId);
if (!editor) {
return;
}
const range = _getElementRange(editor, tagId);
if (!range) {
return;
}

const { startPos } = range;

Phoenix.app.clipboardReadText().then(text => {
if (!text) {
return;
}
// get the indentation in the target line and check if there is any real indentation
let indent = editor.getTextBetween({ line: startPos.line, ch: 0 }, startPos);
indent = indent.trim() === '' ? indent : '';

editor.replaceRange(text + '\n' + indent, startPos);
}).catch(err => {
console.error("Failed to read from clipboard:", err);
});
}

/**
* This function is responsible to delete an element from the source code
* @param {Number} tagId - the data-brackets-id of the DOM element
Expand Down Expand Up @@ -696,6 +788,46 @@
}
}

/**
* Updates the href attribute of an anchor tag in the src code
* @param {number} tagId - The data-brackets-id of the link element
* @param {string} newHrefValue - The new href value to set
*/
function _updateHyperlinkHref(tagId, newHrefValue) {
const editor = _getEditorAndValidate(tagId);
if (!editor) {
return;
}

const range = _getElementRange(editor, tagId);
if (!range) {
return;
}

const { startPos, endPos } = range;
const elementText = editor.getTextBetween(startPos, endPos);

// parse it using DOM parser so that we can update the href attribute
const parser = new DOMParser();
const doc = parser.parseFromString(elementText, "text/html");
const linkElement = doc.querySelector('a');

if (linkElement) {
linkElement.setAttribute('href', newHrefValue);
const updatedElementText = linkElement.outerHTML;

editor.document.batchOperation(function () {
editor.replaceRange(updatedElementText, startPos, endPos);
});

// dismiss all UI boxes including the image ribbon gallery
const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {

Check warning on line 825 in src/LiveDevelopment/LivePreviewEdit.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZrd--a3vXclxoqwklwN&open=AZrd--a3vXclxoqwklwN&pullRequest=2490
currLiveDoc.protocol.evaluate("_LD.dismissUIAndCleanupState()");
}
}
}

function _sendDownloadStatusToBrowser(eventType, data) {
const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
Expand All @@ -712,6 +844,19 @@
}
}

function _showCopyToastIfNeeded() {
const hasShownToast = StateManager.get(COPY_CUT_TOAST_SHOWN_KEY);
if (!hasShownToast) {
const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
const message = Strings.LIVE_DEV_COPY_TOAST_MESSAGE;
const evalString = `_LD.showToastMessage(${JSON.stringify(message)}, 6000)`;
currLiveDoc.protocol.evaluate(evalString);
StateManager.set(COPY_CUT_TOAST_SHOWN_KEY, true);
}
}
}

function _trackDownload(downloadLocation) {
if (!downloadLocation) {
return;
Expand Down Expand Up @@ -1156,6 +1301,11 @@
const $suggestions = $dlg.find("#folder-suggestions");
const $rememberCheckbox = $dlg.find("#remember-folder-checkbox");

// notify live preview that dialog is now open
if (message && message.downloadId) {
_sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.DIALOG_OPENED, { downloadId: message.downloadId });
}

let folderList = [];
let rootFolders = [];
let stringMatcher = null;
Expand All @@ -1164,12 +1314,32 @@
const shouldBeChecked = persistFolder !== false;
$rememberCheckbox.prop('checked', shouldBeChecked);

_scanRootDirectoriesOnly(projectRoot, rootFolders).then(() => {
stringMatcher = new StringMatch.StringMatcher({ segmentedSearch: true });
_renderFolderSuggestions(rootFolders.slice(0, 15), $suggestions, $input);
});
// check if any folder path exists, we pre-fill it
const savedFolder = StateManager.get(IMAGE_DOWNLOAD_FOLDER_KEY, StateManager.PROJECT_CONTEXT);
if (savedFolder !== null && savedFolder !== undefined) {
$input.val(savedFolder);
}

_scanDirectories(projectRoot, '', folderList);
// we only scan root directories if we don't have a pre-filled value
if (!savedFolder) {
_scanRootDirectoriesOnly(projectRoot, rootFolders).then(() => {
stringMatcher = new StringMatch.StringMatcher({ segmentedSearch: true });
_renderFolderSuggestions(rootFolders.slice(0, 15), $suggestions, $input);
});
}

// scan all directories, and if we pre-filled a path, trigger autocomplete suggestions
_scanDirectories(projectRoot, '', folderList).then(() => {
// init stringMatcher if it wasn't created during root scan
if (!stringMatcher) {
stringMatcher = new StringMatch.StringMatcher({ segmentedSearch: true });
}
if (savedFolder) {
_updateFolderSuggestions(savedFolder, folderList, rootFolders, stringMatcher, $suggestions, $input);
// load root directories in background so they're ready when user clears input
_scanRootDirectoriesOnly(projectRoot, rootFolders);
}
});

// input event handler
$input.on('input', function() {
Expand Down Expand Up @@ -1206,6 +1376,12 @@
_sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.CANCELLED, { downloadId: message.downloadId });
}
}

// notify live preview that dialog is now closed
if (message && message.downloadId) {

Check warning on line 1381 in src/LiveDevelopment/LivePreviewEdit.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZrAHPLlWuFKTYURtyCd&open=AZrAHPLlWuFKTYURtyCd&pullRequest=2490
_sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.DIALOG_CLOSED, { downloadId: message.downloadId });
}

dialog.close();
});
}
Expand Down Expand Up @@ -1254,19 +1430,6 @@
});
}

/**
* Handles reset of image folder selection - clears the saved preference and shows the dialog
* @private
*/
function _handleResetImageFolderSelection() {
// clear the saved folder preference for this project
StateManager.set(IMAGE_DOWNLOAD_FOLDER_KEY, null, StateManager.PROJECT_CONTEXT);

// show the folder selection dialog for the user to choose a new folder
// we pass null because we're not downloading an image, just setting the preference
_showFolderSelectionDialog(null);
}

/**
* this function is responsible to save the active file (and previewed file, both might be same though)
* when ctrl/cmd + s is pressed in the live preview
Expand Down Expand Up @@ -1332,7 +1495,7 @@

// handle reset image folder selection
if (message.resetImageFolderSelection) {
_handleResetImageFolderSelection();
_showFolderSelectionDialog(null);
return;
}

Expand All @@ -1342,6 +1505,12 @@
return;
}

// handle ruler lines toggle message
if (message.type === "toggleRulerLines") {
PreferencesManager.set("livePreviewShowRulerLines", message.enabled);
return;
}

// handle move(drag & drop)
if (message.move && message.sourceId && message.targetId) {
_moveElementInSource(message.sourceId, message.targetId, message.insertAfter, message.insertInside);
Expand All @@ -1367,8 +1536,16 @@
_deleteElementInSourceByTagId(message.tagId);
} else if (message.duplicate) {
_duplicateElementInSourceByTagId(message.tagId);
} else if (message.cut) {
_cutElementToClipboard(message.tagId);
} else if (message.copy) {
_copyElementToClipboard(message.tagId);
} else if (message.paste) {
_pasteElementFromClipboard(message.tagId);
} else if (message.livePreviewTextEdit) {
_editTextInSource(message);
} else if (message.livePreviewHyperlinkEdit) {
_updateHyperlinkHref(message.tagId, message.newHref);
} else if (message.AISend) {
_editWithAI(message);
}
Expand Down
Loading
Loading