Array.<string> | list containing references to extension source location. A source location may be either (a) a folder name inside src/extensions or (b) an absolute path. |
+
+
+## uninstallExtension(extensionID) ⇒ Promise
+Uninstall a deprecated extension
+
+**Kind**: global function
+**Returns**: Promise - A promise that resolves when the extension is uninstalled successfully
+
+| Param | Type | Description |
+| --- | --- | --- |
+| extensionID | string | The ID of the extension to uninstall |
+
diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js
index 48405c4b1a..279d09b2a1 100644
--- a/gulpfile.js/index.js
+++ b/gulpfile.js/index.js
@@ -30,12 +30,13 @@ const { src, dest, series } = require('gulp');
const zip = require('gulp-zip');
const Translate = require("./translateStrings");
const copyThirdPartyLibs = require("./thirdparty-lib-copy");
+const optionalBuild = require("./optional-build");
const minify = require('gulp-minify');
const glob = require("glob");
-const sourcemaps = require('gulp-sourcemaps');
const crypto = require("crypto");
const rename = require("gulp-rename");
const execSync = require('child_process').execSync;
+const terser = require('terser');
function cleanDist() {
return del(['dist', 'dist-test']);
@@ -57,6 +58,8 @@ function cleanAll() {
// Test artifacts
'dist-test',
'test/spec/test_folders.zip',
+ 'src/extensionsIntegrated/pro-loader.js',
+ 'test/pro-test-suite.js',
...RELEASE_BUILD_ARTEFACTS
]);
}
@@ -71,6 +74,44 @@ function cleanUnwantedFilesInDist() {
]);
}
+function _cleanPhoenixProGitFolder() {
+ return new Promise((resolve) => {
+ const gitFolders = [
+ 'dist/extensionsIntegrated/phoenix-pro/.git',
+ 'dist-test/src/extensionsIntegrated/phoenix-pro/.git'
+ ];
+
+ for (const gitFolder of gitFolders) {
+ if (fs.existsSync(gitFolder)) {
+ fs.rmSync(gitFolder, { recursive: true, force: true });
+ console.log(`Removed git folder: ${gitFolder}`);
+ }
+ }
+ resolve();
+ });
+}
+
+function _deletePhoenixProSourceFolder() {
+ return new Promise((resolve) => {
+ const phoenixProFolders = [
+ // we only delete the source folder from the release build artifact and not the test artifact. why below?
+ 'dist/extensionsIntegrated/phoenix-pro'
+ // 'dist-test/src/extensionsIntegrated/phoenix-pro' // ideally we should delete this too so that the tests
+ // test exactly the release build artifact, but the spec runner requires on these files during test start
+ // and i wasnt able to isolate them. so instead wehat we do now is that we have an additional test in prod
+ // that checks that the phoenix-pro source folder is not loaded in prod and loaded only from the inlines
+ // brackets-min file.
+ ];
+
+ for (const folder of phoenixProFolders) {
+ if (fs.existsSync(folder)) {
+ fs.rmSync(folder, { recursive: true, force: true });
+ }
+ }
+ resolve();
+ });
+}
+
/**
* TODO: Release scripts to merge and min src js/css/html resources into dist.
* Links that might help:
@@ -89,8 +130,7 @@ function makeDistAll() {
function makeJSDist() {
return src(['src/**/*.js', '!src/**/unittest-files/**/*', "!src/thirdparty/prettier/**/*",
- "!src/thirdparty/no-minify/**/*"])
- .pipe(sourcemaps.init())
+ "!src/thirdparty/no-minify/**/*", "!src/LiveDevelopment/BrowserScripts/RemoteFunctions.js"])
.pipe(minify({
ext:{
min:'.js'
@@ -99,23 +139,31 @@ function makeJSDist() {
mangle: false,
compress: {
unused: false
+ },
+ preserveComments: function (node, comment) {
+ const text = (comment.value || "").trim();
+
+ // license headers should not end up in distribution as the license of dist depends on
+ // internal vs external builds. we strip every comment except with below flag.
+ // Preserve ONLY comments starting with "DONT_STRIP_MINIFY:"
+ return text.includes("DONT_STRIP_MINIFY:");
}
}))
- .pipe(sourcemaps.write('./'))
.pipe(dest('dist'));
}
// we had to do this as prettier is non minifiable
function makeJSPrettierDist() {
return src(["src/thirdparty/prettier/**/*"])
- .pipe(sourcemaps.init())
.pipe(dest('dist/thirdparty/prettier'));
}
function makeNonMinifyDist() {
- return src(["src/thirdparty/no-minify/**/*"])
- .pipe(sourcemaps.init())
- .pipe(dest('dist/thirdparty/no-minify'));
+ // we dont minify remote functions as its in live preview context and the prod minify is stripping variables
+ // used by plugins in live preview. so we dont minify this for now.
+ return src(["src/thirdparty/no-minify/**/*",
+ "src/LiveDevelopment/BrowserScripts/RemoteFunctions.js"], {base: 'src'})
+ .pipe(dest('dist'));
}
function makeDistNonJS() {
@@ -536,29 +584,81 @@ function containsRegExpExcludingEmpty(str) {
}
+// Paths that should be minified during production builds
+const minifyablePaths = [
+ 'src/extensionsIntegrated/phoenix-pro/browser-context'
+];
+
+function _minifyBrowserContextFile(fileContent) {
+ const minified = terser.minify(fileContent, {
+ mangle: true,
+ compress: {
+ unused: false
+ },
+ output: {
+ comments: function(node, comment) {
+ // license headers should not end up in distribution as the license of dist depends on
+ // internal vs external builds. we strip every comment except with below flag.
+ const text = comment.value.trim();
+ return text.includes("DONT_STRIP_MINIFY:");
+ }
+ }
+ });
+
+ if (minified.error) {
+ throw new Error(`Failed to minify file: ${minified.error}`);
+ }
+
+ return minified.code;
+}
+
+function _isMinifyablePath(filePath) {
+ const normalizedFilePath = path.normalize(filePath);
+ return minifyablePaths.some(minifyPath =>
+ normalizedFilePath.startsWith(path.normalize(minifyPath))
+ );
+}
+
+function getKey(filePath, isDevBuild) {
+ return isDevBuild + filePath;
+}
+
const textContentMap = {};
-function inlineTextRequire(file, content, srcDir) {
+const excludeSuffixPathsInlining = ["MessageIds.json"];
+function inlineTextRequire(file, content, srcDir, isDevBuild = true) {
if(content.includes(`'text!`) || content.includes("`text!")) {
throw new Error(`in file ${file} require("text!...") should always use a double quote "text! instead of " or \``);
}
if(content.includes(`"text!`)) {
const requireFragments = extractRequireTextFragments(content);
for (const {requirePath, requireStatement} of requireFragments) {
- let textContent = textContentMap[requirePath];
+ let filePath = srcDir + requirePath;
+ if(requirePath.startsWith("./")) {
+ filePath = path.join(path.dirname(file), requirePath);
+ }
+ let textContent = textContentMap[getKey(filePath, isDevBuild)];
+
if(!textContent){
- let filePath = srcDir + requirePath;
- if(requirePath.startsWith("./")) {
- filePath = path.join(path.dirname(file), requirePath);
- }
console.log("reading file at path: ", filePath);
- const fileContent = fs.readFileSync(filePath, "utf8");
- textContentMap[requirePath] = fileContent;
+ let fileContent = fs.readFileSync(filePath, "utf8");
+
+ // Minify inline if this is a minifyable path and we're in production mode
+ if (!isDevBuild && _isMinifyablePath(filePath)) {
+ console.log("Minifying file inline:", filePath);
+ fileContent = _minifyBrowserContextFile(fileContent);
+ }
+
+ textContentMap[getKey(filePath, isDevBuild)] = fileContent;
textContent = fileContent;
}
- if(textContent.includes("`")) {
- console.log("Not inlining file as it contains a backquote(`) :", requirePath);
- } else if(requirePath.endsWith(".js") || requirePath.endsWith(".json")) {
- console.log("Not inlining JS/JSON file:", requirePath);
+ if((requirePath.endsWith(".js") && !requirePath.includes("./")) // js files that are relative paths are ok
+ || excludeSuffixPathsInlining.some(ext => requirePath.endsWith(ext))) {
+ console.warn("Not inlining JS/JSON file:", requirePath, filePath);
+ if(filePath.includes("phoenix-pro")) {
+ // this is needed as we will delete the extension sources when packaging for release.
+ // so non inlined content will not be available in the extension. throw early to detect that.
+ throw new Error(`All Files in phoenix pro extension should be inlineable!: failed for ${filePath}`);
+ }
} else {
console.log("Inlining", requireStatement);
if((requireStatement.includes(".html") || requireStatement.includes(".js"))
@@ -568,7 +668,7 @@ function inlineTextRequire(file, content, srcDir) {
throw `Error inlining ${requireStatement} in ${file}: Regex: ${detectedRegEx}`+
"\nRegular expression of the form /*/ is not allowed for minification please use RegEx constructor";
}
- content = content.replaceAll(requireStatement, "`"+textContent+"`");
+ content = content.replaceAll(requireStatement, `${JSON.stringify(textContent)}`);
}
}
@@ -576,7 +676,7 @@ function inlineTextRequire(file, content, srcDir) {
return content;
}
-function makeBracketsConcatJS() {
+function _makeBracketsConcatJSInternal(isDevBuild = true) {
return new Promise((resolve)=>{
const srcDir = "src/";
const DO_NOT_CONCATENATE = [
@@ -610,7 +710,7 @@ function makeBracketsConcatJS() {
console.log("Merging: ", requirePath);
mergeCount ++;
content = content.replace("define(", `define("${requirePath}", `);
- content = inlineTextRequire(file, content, srcDir);
+ content = inlineTextRequire(file, content, srcDir, isDevBuild);
concatenatedFile = concatenatedFile + "\n" + content;
}
}
@@ -621,14 +721,20 @@ function makeBracketsConcatJS() {
});
}
+function makeBracketsConcatJS() {
+ return _makeBracketsConcatJSInternal(true);
+}
+
+function makeBracketsConcatJSWithMinifiedBrowserScripts() {
+ return _makeBracketsConcatJSInternal(false);
+}
+
function _renameBracketsConcatAsBracketsJSInDist() {
return new Promise((resolve)=>{
fs.unlinkSync("dist/brackets.js");
fs.copyFileSync("dist/brackets-min.js", "dist/brackets.js");
- fs.copyFileSync("dist/brackets-min.js.map", "dist/brackets.js.map");
// cleanup minifed files
fs.unlinkSync("dist/brackets-min.js");
- fs.unlinkSync("dist/brackets-min.js.map");
resolve();
});
}
@@ -711,8 +817,8 @@ function makeExtensionConcatJS(extensionName) {
`define("${defineId}", `
);
- // inline text requires
- content = inlineTextRequire(file, content, extensionDir);
+ // inline text requires (extensions use isDevBuild=true, they're minified via makeJSDist)
+ content = inlineTextRequire(file, content, extensionDir, true);
concatenatedFile += '\n' + content;
mergeCount++;
@@ -748,9 +854,7 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) {
const srcExtensionConcatFile = path.join(srcExtensionDir, 'extension-min.js');
const distExtensionDir = path.join('dist/extensions/default', extensionName);
const extMinFile = path.join(distExtensionDir, 'main.js');
- const extMinFileMap = path.join(distExtensionDir, 'main.js.map');
const extSrcFile = path.join(distExtensionDir, 'extension-min.js');
- const extSrcFileMap = path.join(distExtensionDir, 'extension-min.js.map');
// Make sure extension-min.js exists in dist.
if (!fs.existsSync(extSrcFile)) {
@@ -769,17 +873,7 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) {
}
fs.copyFileSync(extSrcFile, extMinFile);
- if (fs.existsSync(extMinFileMap)) {
- fs.unlinkSync(extMinFileMap);
- }
- if (fs.existsSync(extSrcFileMap)) {
- fs.copyFileSync(extSrcFileMap, extMinFileMap);
- }
-
fs.unlinkSync(extSrcFile);
- if (fs.existsSync(extSrcFileMap)) {
- fs.unlinkSync(extSrcFileMap);
- }
resolve();
} catch (err) {
@@ -877,6 +971,32 @@ function makeLoggerConfig() {
});
}
+function generateProLoaderFiles() {
+ return new Promise((resolve) => {
+ // AMD module template for generated files
+ const AMD_MODULE_TEMPLATE = `define(function (require, exports, module) {});\n`;
+
+ const phoenixProExists = fs.existsSync('src/extensionsIntegrated/phoenix-pro');
+
+ // Generate test/pro-test-suite.js content
+ const testSuiteCode = phoenixProExists ?
+ '\n require("extensionsIntegrated/phoenix-pro/unittests");\n' : '';
+ const testSuiteContent = AMD_MODULE_TEMPLATE.replace('', testSuiteCode);
+
+ // Generate src/extensionsIntegrated/pro-loader.js content
+ const loaderCode = phoenixProExists ? '\n require("./phoenix-pro/main");\n' : '';
+ const loaderContent = AMD_MODULE_TEMPLATE.replace('', loaderCode);
+
+ fs.writeFileSync('test/pro-test-suite.js', testSuiteContent);
+ fs.writeFileSync('src/extensionsIntegrated/pro-loader.js', loaderContent);
+
+ console.log(`Generated pro-loader.js (phoenix-pro ${phoenixProExists ? 'found' : 'not found'})`);
+ console.log(`Generated pro-test-suite.js (phoenix-pro ${phoenixProExists ? 'found' : 'not found'})`);
+
+ resolve();
+ });
+}
+
function validatePackageVersions() {
return new Promise((resolve, reject)=>{
const mainPackageJson = require("../package.json", "utf8");
@@ -942,26 +1062,28 @@ function _patchMinifiedCSSInDistIndex() {
const createDistTest = series(copyDistToDistTestFolder, copyTestToDistTestFolder, copyIndexToDistTestFolder);
-exports.build = series(copyThirdPartyLibs.copyAll, makeLoggerConfig, zipDefaultProjectFiles, zipSampleProjectFiles,
- makeBracketsConcatJS, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late
+exports.build = series(optionalBuild.clonePhoenixProRepo, copyThirdPartyLibs.copyAll, makeLoggerConfig, generateProLoaderFiles, zipDefaultProjectFiles, zipSampleProjectFiles,
+ makeBracketsConcatJS, makeBracketsConcatJSWithMinifiedBrowserScripts, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late
createSrcCacheManifest, validatePackageVersions);
-exports.buildDebug = series(copyThirdPartyLibs.copyAllDebug, makeLoggerConfig, zipDefaultProjectFiles,
- makeBracketsConcatJS, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late
+exports.buildDebug = series(optionalBuild.clonePhoenixProRepo, copyThirdPartyLibs.copyAllDebug, makeLoggerConfig, generateProLoaderFiles, zipDefaultProjectFiles,
+ makeBracketsConcatJS, makeBracketsConcatJSWithMinifiedBrowserScripts, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late
zipSampleProjectFiles, createSrcCacheManifest);
exports.clean = series(cleanDist);
exports.reset = series(cleanAll);
exports.releaseDev = series(cleanDist, exports.buildDebug, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc,
makeDistAll, cleanUnwantedFilesInDist, releaseDev, _renameConcatExtensionsinDist,
- createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc);
-exports.releaseStaging = series(cleanDist, exports.build, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc,
- makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, cleanUnwantedFilesInDist,
- _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, _patchMinifiedCSSInDistIndex, releaseStaging,
- createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc);
-exports.releaseProd = series(cleanDist, exports.build, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc,
- makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, cleanUnwantedFilesInDist,
- _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, _patchMinifiedCSSInDistIndex, releaseProd,
- createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc);
+ createDistCacheManifest, createDistTest, _cleanPhoenixProGitFolder, _cleanReleaseBuildArtefactsInSrc);
+exports.releaseStaging = series(cleanDist, exports.build, makeBracketsConcatJSWithMinifiedBrowserScripts,
+ makeConcatExtensions, _compileLessSrc, makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist,
+ cleanUnwantedFilesInDist, _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist,
+ _patchMinifiedCSSInDistIndex, releaseStaging, createDistCacheManifest, createDistTest,
+ _deletePhoenixProSourceFolder, _cleanReleaseBuildArtefactsInSrc);
+exports.releaseProd = series(cleanDist, exports.build, makeBracketsConcatJSWithMinifiedBrowserScripts,
+ makeConcatExtensions, _compileLessSrc, makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist,
+ cleanUnwantedFilesInDist, _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist,
+ _patchMinifiedCSSInDistIndex, releaseProd, createDistCacheManifest, createDistTest,
+ _deletePhoenixProSourceFolder, _cleanReleaseBuildArtefactsInSrc);
exports.releaseWebCache = series(makeDistWebCache);
exports.serve = series(exports.build, serve);
exports.zipTestFiles = series(zipTestFiles);
diff --git a/gulpfile.js/optional-build.js b/gulpfile.js/optional-build.js
new file mode 100644
index 0000000000..83d3ea4d37
--- /dev/null
+++ b/gulpfile.js/optional-build.js
@@ -0,0 +1,182 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2022 - present core.ai . All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/* eslint-env node */
+
+const fs = require('fs');
+const path = require('path');
+const execSync = require('child_process').execSync;
+
+/**
+ * Conditionally clones the phoenix-pro repository if environment variables are set.
+ *
+ * Behavior:
+ * - If env vars not set: Skip clone, build continues (community builds)
+ * - If env vars set but clone fails: Build FAILS (credentials configured but clone failed)
+ * - If directory exists with correct commit: Skip clone, build continues
+ * - If directory exists with wrong commit: Log warning, build continues (respect local changes)
+ */
+function clonePhoenixProRepo() {
+ return new Promise((resolve, reject) => {
+ // this is only expected to be hit in github actions environment.
+ // in normal builds, we will bail out as soon as we detect that the environmental vars are note present.
+
+ const proRepoUrl = process.env.PRO_REPO_URL;
+ const proRepoToken = process.env.PRO_REPO_ACCESS_TOKEN;
+ const targetDir = path.resolve(__dirname, '../src/extensionsIntegrated/phoenix-pro');
+
+ // Check if both environment variables are set
+ if (!proRepoUrl || !proRepoToken) {
+ // this si what will happen in most dev builds.
+ console.log('Skipping phoenix-pro clone: PRO_REPO_URL or PRO_REPO_ACCESS_TOKEN not set');
+ console.log('This is expected for community builds');
+ resolve();
+ return;
+ }
+
+ // all code below is only likely to be executed in the ci environment
+
+ // Check if directory already exists
+ if (fs.existsSync(targetDir)) {
+ console.log('phoenix-pro directory already exists at:', targetDir);
+
+ // Check if it's a git repository
+ const gitDir = path.join(targetDir, '.git');
+ if (fs.existsSync(gitDir)) {
+ try {
+ // Verify current commit
+ const trackingRepos = require('../tracking-repos.json');
+ const expectedCommit = trackingRepos.phoenixPro.commitID;
+ const currentCommit = execSync('git rev-parse HEAD', {
+ cwd: targetDir,
+ encoding: 'utf8'
+ }).trim();
+
+ if (currentCommit === expectedCommit) {
+ console.log(`✓ phoenix-pro is already at the correct commit: ${expectedCommit}`);
+ resolve();
+ return;
+ } else {
+ // this code will only reach in ci envs with teh env variables, so ward if the commit
+ // is not what we expect.
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ console.error(`Error: phoenix-pro is at commit ${currentCommit.substring(0, 8)}`);
+ console.error(` but tracking-repos.json specifies ${expectedCommit.substring(0, 8)}`);
+ console.error('Not building incorrect binary.');
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ reject();
+ return;
+ }
+ } catch (error) {
+ console.error(`Error: Could not verify phoenix-pro commit: ${error.message}`);
+ console.error('Not building incorrect binary.');
+ reject();
+ return;
+ }
+ } else {
+ console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ console.warn('Error: phoenix-pro directory exists but is not a git repository');
+ console.error('Not building incorrect binary as it could not be verified.');
+ console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ reject();
+ return;
+ }
+ }
+
+ // Perform the clone operation
+ try {
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ console.log('Cloning phoenix-pro repository...');
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+
+ // Load target commit from tracking-repos.json
+ const trackingRepos = require('../tracking-repos.json');
+ const commitID = trackingRepos.phoenixPro.commitID;
+ console.log(`Target commit: ${commitID}`);
+
+ // Construct authenticated URL
+ const authUrl = proRepoUrl.replace('https://', `https://oauth2:${proRepoToken}@`);
+
+ // Step 1: Shallow clone
+ console.log('Step 1/3: Cloning repository (shallow clone)...');
+ execSync(`git clone --depth 1 "${authUrl}" "${targetDir}"`, {
+ stdio: ['pipe', 'pipe', 'inherit'] // Hide stdout (may contain token), show stderr
+ });
+ console.log('✓ Clone completed');
+
+ // Step 2: Fetch specific commit
+ console.log(`Step 2/3: Fetching specific commit: ${commitID}...`);
+ try {
+ execSync(`git fetch --depth 1 origin ${commitID}`, {
+ cwd: targetDir,
+ stdio: ['pipe', 'pipe', 'inherit']
+ });
+ console.log('✓ Fetch completed');
+ } catch (fetchError) {
+ // Commit might already be in shallow clone
+ console.log(' (Commit may already be present in shallow clone)');
+ }
+
+ // Step 3: Checkout specific commit
+ console.log(`Step 3/3: Checking out commit: ${commitID}...`);
+ execSync(`git checkout ${commitID}`, {
+ cwd: targetDir,
+ stdio: ['pipe', 'pipe', 'inherit']
+ });
+ console.log('✓ Checkout completed');
+
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ console.log('✓ Successfully cloned and checked out phoenix-pro repository');
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ resolve();
+
+ } catch (error) {
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ console.error('✗ ERROR: Failed to clone phoenix-pro repository');
+ console.error(`Error: ${error.message}`);
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+ console.error('Build failed because:');
+ console.error(' - PRO_REPO_URL and PRO_REPO_ACCESS_TOKEN are set (phoenix-pro expected)');
+ console.error(' - Clone operation failed');
+ console.error('');
+ console.error('Possible causes:');
+ console.error(' - Invalid or expired access token');
+ console.error(' - Insufficient token permissions (needs "repo" scope)');
+ console.error(' - Network connectivity issues');
+ console.error(' - Repository URL is incorrect');
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
+
+ // Clean up partial clone if it exists
+ if (fs.existsSync(targetDir)) {
+ try {
+ fs.rmSync(targetDir, { recursive: true, force: true });
+ console.log('Cleaned up partial clone directory');
+ } catch (cleanupError) {
+ console.warn(`Could not clean up partial clone: ${cleanupError.message}`);
+ }
+ }
+
+ reject(new Error('Failed to clone phoenix-pro repository')); // FAIL BUILD
+ }
+ });
+}
+
+// Export the function
+exports.clonePhoenixProRepo = clonePhoenixProRepo;
diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js
index 72b95696fb..f026291e10 100644
--- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js
+++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js
@@ -182,6 +182,10 @@
}
}
s.id = msg.params.url;
+
+ if (window._LD && window._LD.redrawEverything) {
+ window._LD.redrawEverything();
+ }
},
/**
@@ -363,7 +367,7 @@
ProtocolManager.enable();
});
- function _getAllInheritedSelectorsInOrder(element) {
+ function getAllInheritedSelectorsInOrder(element) {
let selectorsFound= new Map();
const selectorsList = [];
while (element) {
@@ -383,6 +387,7 @@
return selectorsList;
}
+ global.getAllInheritedSelectorsInOrder = getAllInheritedSelectorsInOrder;
/**
* Sends the message containing tagID which is being clicked
@@ -407,7 +412,7 @@
"nodeID": element.id,
"nodeClassList": element.classList,
"nodeName": element.nodeName,
- "allSelectors": _getAllInheritedSelectorsInOrder(element),
+ "allSelectors": getAllInheritedSelectorsInOrder(element),
"contentEditable": element.contentEditable === 'true',
"clicked": true,
"edit": true
@@ -431,7 +436,7 @@
"nodeID": element.id,
"nodeClassList": element.classList,
"nodeName": element.nodeName,
- "allSelectors": _getAllInheritedSelectorsInOrder(element),
+ "allSelectors": getAllInheritedSelectorsInOrder(element),
"contentEditable": element.contentEditable === 'true',
"clicked": true
});
@@ -440,34 +445,90 @@
}
window.document.addEventListener("click", onDocumentClick);
window.document.addEventListener("keydown", function (e) {
+ // Check if user is editing text content - if so, allow normal text cut
+ // Get the truly active element, even if inside shadow roots
+ let activeElement = document.activeElement;
+
+ const isEditingText = activeElement && (
+ // Check for standard form input elements
+ ['INPUT', 'TEXTAREA'].includes(activeElement.tagName) ||
+ // Check for contentEditable elements
+ activeElement.isContentEditable ||
+ // Check for ARIA roles that indicate text input
+ ['textbox', 'searchbox', 'combobox'].includes(activeElement.getAttribute('role')) ||
+ // Check if element is designed to receive text input
+ (activeElement.hasAttribute("contenteditable") && activeElement.hasAttribute("data-brackets-id"))
+ );
+
+ // Check if a Phoenix tool is active (has data-phcode-internal-* attribute)
+ const isActiveElementPhoenixTool = activeElement && Array.from(activeElement.attributes || []).some(attr =>
+ attr.name.startsWith('data-phcode-internal-') && attr.value === 'true'
+ );
+
+ const isInEditMode = window._LD && window._LD.getMode && window._LD.getMode() === 'edit';
+
// for undo. refer to LivePreviewEdit.js file 'handleLivePreviewEditOperation' function
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
+ if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode &&
+ (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
MessageBroker.send({
livePreviewEditEnabled: true,
undoLivePreviewOperation: true
});
}
- // for redo
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
+ // for redo - supports both Ctrl+Y and Ctrl+Shift+Z (Cmd+Y and Cmd+Shift+Z on Mac)
+ if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && (e.ctrlKey || e.metaKey) &&
+ (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) {
MessageBroker.send({
livePreviewEditEnabled: true,
redoLivePreviewOperation: true
});
}
+ // Cut: Ctrl+X / Cmd+X - operates on selected element
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "x") {
+
+ // Only handle element cut if not editing text and in edit mode
+ if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleCutElement) {
+ e.preventDefault();
+ window._LD.handleCutElement();
+ }
+ }
+
+ // Copy: Ctrl+C / Cmd+C - operates on selected element
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
+
+ // Only handle element copy if not editing text and in edit mode
+ if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleCopyElement) {
+ e.preventDefault();
+ window._LD.handleCopyElement();
+ }
+ }
+
+ // Paste: Ctrl+V / Cmd+V - operates on selected element
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
+
+ // Only handle element paste if not editing text and in edit mode
+ if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handlePasteElement) {
+ e.preventDefault();
+ window._LD.handlePasteElement();
+ }
+ }
+
+ if (e.key.toLowerCase() === 'delete' || e.key.toLowerCase() === 'backspace') {
+ if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleDeleteElement) {
+ e.preventDefault();
+ window._LD.handleDeleteElement();
+ }
+ }
+
// for save
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
// to check if user was in between editing text
// in such cases we first finish the editing and then save
- const activeElement = document.activeElement;
- if (activeElement &&
- activeElement.hasAttribute("contenteditable") &&
- activeElement.hasAttribute("data-brackets-id") &&
- window._LD &&
- window._LD.finishEditing) {
+ if (isEditingText && window._LD && window._LD.finishEditing) {
window._LD.finishEditing(activeElement);
}
diff --git a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js
index 50d278c061..66a0d27065 100644
--- a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js
+++ b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js
@@ -313,9 +313,10 @@
}
};
+ const ABS_REGEX = new RegExp("^(?:[a-z]+:)?\\/\\/", "i");
function getAbsoluteUrl(url) {
// Check if the URL is already absolute
- if (/^(?:[a-z]+:)?\/\//i.test(url)) {
+ if (ABS_REGEX.test(url)) {
return url; // The URL is already absolute
}
@@ -439,6 +440,7 @@
let alertQueue = [], confirmCalled = false, promptCalled = false;
let addToQueue = true;
+ window.__PHOENIX_APP_INFO = {isTauri, platform};
if(!isExternalBrowser){
// this is an embedded iframe we always take hold of the alert api for better ux within the live preivew frame.
window.__PHOENIX_EMBED_INFO = {isTauri, platform};
diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js
index 0b7beceaed..f2d73370d8 100644
--- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js
+++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js
@@ -1,3372 +1,265 @@
-/*
- * GNU AGPL-3.0 License
- *
- * Copyright (c) 2021 - present core.ai . All rights reserved.
- * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved.
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
- * for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
- *
- */
-
-/*jslint forin: true */
-/*global Node, MessageEvent */
-/*theseus instrument: false */
-
-/**
- * RemoteFunctions define the functions to be executed in the browser. This
- * modules should define a single function that returns an object of all
- * exported functions.
- */
-function RemoteFunctions(config = {}) {
- // this will store the element that was clicked previously (before the new click)
- // we need this so that we can remove click styling from the previous element when a new element is clicked
- let previouslyClickedElement = null;
-
- var req, timeout;
- var animateHighlight = function (time) {
- if(req) {
- window.cancelAnimationFrame(req);
- window.clearTimeout(timeout);
- }
- req = window.requestAnimationFrame(redrawHighlights);
-
- timeout = setTimeout(function () {
- window.cancelAnimationFrame(req);
- req = null;
- }, time * 1000);
- };
-
- /**
- * @type {DOMEditHandler}
- */
- var _editHandler;
-
- var HIGHLIGHT_CLASSNAME = "__brackets-ld-highlight";
-
- // auto-scroll variables to auto scroll the live preview when an element is dragged to the top/bottom
- let _autoScrollTimer = null;
- let _isAutoScrolling = false; // to disable highlights when auto scrolling
- const AUTO_SCROLL_SPEED = 12; // pixels per scroll
- const AUTO_SCROLL_EDGE_SIZE = 0.05; // 5% of viewport height (either top/bottom)
-
- // initialized from config, defaults to true if not set
- let imageGallerySelected = config.imageGalleryState !== undefined ? config.imageGalleryState : true;
-
- /**
- * this function is responsible to auto scroll the live preview when
- * dragging an element to the viewport edges
- * @param {number} clientY - curr mouse Y position
- */
- function _handleAutoScroll(clientY) {
- const viewportHeight = window.innerHeight;
- const scrollEdgeSize = viewportHeight * AUTO_SCROLL_EDGE_SIZE;
-
- // Clear existing timer
- if (_autoScrollTimer) {
- clearInterval(_autoScrollTimer);
- _autoScrollTimer = null;
- }
-
- let scrollDirection = 0;
-
- // check if near top edge (scroll up)
- if (clientY <= scrollEdgeSize) {
- scrollDirection = -AUTO_SCROLL_SPEED;
- } else if (clientY >= viewportHeight - scrollEdgeSize) {
- // check if near bottom edge (scroll down)
- scrollDirection = AUTO_SCROLL_SPEED;
- }
-
- // Start scrolling if needed
- if (scrollDirection !== 0) {
- _isAutoScrolling = true;
- _autoScrollTimer = setInterval(() => {
- window.scrollBy(0, scrollDirection);
- }, 16); // 16 is ~60fps
- }
- }
-
- // stop autoscrolling
- function _stopAutoScroll() {
- if (_autoScrollTimer) {
- clearInterval(_autoScrollTimer);
- _autoScrollTimer = null;
- }
- _isAutoScrolling = false;
- }
-
- // determine whether an event should be processed for Live Development
- function _validEvent(event) {
- if (window.navigator.platform.substr(0, 3) === "Mac") {
- // Mac
- return event.metaKey;
- }
- // Windows
- return event.ctrlKey;
- }
-
- /**
- * check if an element is inspectable.
- * inspectable elements are those which doesn't have data-brackets-id,
- * this normally happens when content is DOM content is inserted by some scripting language
- */
- function isElementInspectable(element, onlyHighlight = false) {
- if(!config.isProUser && !onlyHighlight) {
- return false;
- }
-
- if(element && // element should exist
- element.tagName.toLowerCase() !== "body" && // shouldn't be the body tag
- element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag
- !element.closest("[data-phcode-internal-c15r5a9]") && // this attribute is used by phoenix internal elements
- !_isInsideHeadTag(element)) { // shouldn't be inside the head tag like meta tags and all
- return true;
- }
- return false;
- }
-
- /**
- * This is a checker function for editable elements, it makes sure that the element satisfies all the required check
- * - When onlyHighlight is false → config.isProUser must be true
- * - When onlyHighlight is true → config.isProUser can be true or false (doesn't matter)
- * @param {DOMElement} element
- * @param {boolean} [onlyHighlight=false] - If true, bypasses the isProUser check
- * @returns {boolean} - True if the element is editable else false
- */
- function isElementEditable(element, onlyHighlight = false) {
- // for an element to be editable it should satisfy all inspectable checks and should also have data-brackets-id
- return isElementInspectable(element, onlyHighlight) &&
- element.hasAttribute("data-brackets-id");
- }
-
- // helper function to check if an element is inside the HEAD tag
- // we need this because we don't wanna trigger the element highlights on head tag and its children,
- // except for `;
- this._shadow = shadow;
- },
-
- create: function() {
- this.remove(); // remove existing box if already present
-
- // this check because when there is no element visible to the user, we don't want to show the box
- // for ex: when user clicks on a 'x' button and the button is responsible to hide a panel
- // then clicking on that button shouldn't show the more options box
- // also covers cases where elements are inside closed/collapsed menus
- if(!isElementVisible(this.element)) {
- return;
- }
-
- this._style(); // style the box
-
- window.document.body.appendChild(this.body);
-
- // get the actual rendered dimensions of the box and then we reposition it to the actual place
- const boxElement = this._shadow.querySelector('.phoenix-more-options-box');
- if (boxElement) {
- const boxRect = boxElement.getBoundingClientRect();
- const pos = this._getBoxPosition(boxRect.width, boxRect.height);
-
- boxElement.style.left = pos.leftPos + 'px';
- boxElement.style.top = pos.topPos + 'px';
- }
-
- // add click handler to all the buttons
- const spans = this._shadow.querySelectorAll('.node-options span');
- spans.forEach(span => {
- span.addEventListener('click', (event) => {
- event.stopPropagation();
- event.preventDefault();
- // data-action is to differentiate between the buttons (duplicate, delete or select-parent)
- const action = event.currentTarget.getAttribute('data-action');
- handleOptionClick(event, action, this.element);
- if (action !== 'duplicate') {
- this.remove();
- }
- });
- });
-
- this._registerDragDrop();
- },
-
- remove: function() {
- if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) {
- window.document.body.removeChild(this.body);
- this.body = null;
- _nodeMoreOptionsBox = null;
- }
- }
- };
-
- // Node info box to display DOM node ID and classes on hover
- function NodeInfoBox(element) {
- this.element = element;
- this.remove = this.remove.bind(this);
- this.create();
- }
-
- NodeInfoBox.prototype = {
- _checkOverlap: function(nodeInfoBoxPos, nodeInfoBoxDimensions) {
- if (_nodeMoreOptionsBox && _nodeMoreOptionsBox._shadow) {
- const moreOptionsBoxElement = _nodeMoreOptionsBox._shadow.querySelector('.phoenix-more-options-box');
- if (moreOptionsBoxElement) {
- const moreOptionsBoxOffset = _screenOffset(moreOptionsBoxElement);
- const moreOptionsBoxRect = moreOptionsBoxElement.getBoundingClientRect();
-
- const infoBox = {
- left: nodeInfoBoxPos.leftPos,
- top: nodeInfoBoxPos.topPos,
- right: nodeInfoBoxPos.leftPos + nodeInfoBoxDimensions.width,
- bottom: nodeInfoBoxPos.topPos + nodeInfoBoxDimensions.height
- };
-
- const moreOptionsBox = {
- left: moreOptionsBoxOffset.left,
- top: moreOptionsBoxOffset.top,
- right: moreOptionsBoxOffset.left + moreOptionsBoxRect.width,
- bottom: moreOptionsBoxOffset.top + moreOptionsBoxRect.height
- };
-
- const isOverlapping = !(infoBox.right < moreOptionsBox.left ||
- moreOptionsBox.right < infoBox.left ||
- infoBox.bottom < moreOptionsBox.top ||
- moreOptionsBox.bottom < infoBox.top);
-
- return isOverlapping;
- }
- }
- return false;
- },
-
- _getBoxPosition: function(boxDimensions, overlap = false) {
- const elemBounds = this.element.getBoundingClientRect();
- const offset = _screenOffset(this.element);
- let topPos = 0;
- let leftPos = 0;
-
- if (overlap) {
- topPos = offset.top + 2;
- leftPos = offset.left + elemBounds.width + 6; // positioning at the right side
-
- // Check if overlap position would go off the right of the viewport
- if (leftPos + boxDimensions.width > window.innerWidth) {
- leftPos = offset.left - boxDimensions.width - 6; // positioning at the left side
-
- if (leftPos < 0) { // if left positioning not perfect, position at bottom
- topPos = offset.top + elemBounds.height + 6;
- leftPos = offset.left;
-
- // if bottom position not perfect, move at top above the more options box
- if (elemBounds.bottom + 6 + boxDimensions.height > window.innerHeight) {
- topPos = offset.top - boxDimensions.height - 34; // 34 is for moreOptions box height
- leftPos = offset.left;
- }
- }
- }
- } else {
- topPos = offset.top - boxDimensions.height - 6; // 6 for just some little space to breathe
- leftPos = offset.left;
-
- if (elemBounds.top - boxDimensions.height < 6) {
- // check if placing the box below would cause viewport height increase
- // we need this or else it might cause a flickering issue
- // read this to know why flickering occurs:
- // when we hover over the bottom part of a tall element, the info box appears below it.
- // this increases the live preview height, which makes the cursor position relatively
- // higher due to content shift. the cursor then moves out of the element boundary,
- // ending the hover state. this makes the info box disappear, decreasing the height
- // back, causing the cursor to fall back into the element, restarting the hover cycle.
- // this creates a continuous flickering loop.
- const bottomPosition = offset.top + elemBounds.height + 6;
- const wouldIncreaseViewportHeight = bottomPosition + boxDimensions.height > window.innerHeight;
-
- // we only need to use floating position during hover mode (not on click mode)
- const isHoverMode = shouldShowHighlightOnHover();
- const shouldUseFloatingPosition = wouldIncreaseViewportHeight && isHoverMode;
-
- if (shouldUseFloatingPosition) {
- // float over element at bottom-right to prevent layout shift during hover
- topPos = offset.top + elemBounds.height - boxDimensions.height - 6;
- leftPos = offset.left + elemBounds.width - boxDimensions.width;
-
- // make sure it doesn't go off-screen
- if (leftPos < 0) {
- leftPos = offset.left; // align to left edge of element
- }
- if (topPos < 0) {
- topPos = offset.top + 6; // for the top of element
- }
- } else {
- topPos = bottomPosition;
- }
- }
-
- // Check if the box would go off the right of the viewport
- if (leftPos + boxDimensions.width > window.innerWidth) {
- leftPos = window.innerWidth - boxDimensions.width - 10;
- }
- }
-
- return {topPos: topPos, leftPos: leftPos};
- },
-
- _style: function() {
- this.body = window.document.createElement("div");
- this.body.setAttribute("data-phcode-internal-c15r5a9", "true");
-
- // this is shadow DOM.
- // we need it because if we add the box directly to the DOM then users style might override it.
- // {mode: "open"} allows us to access the shadow DOM to get actual height/position of the boxes
- const shadow = this.body.attachShadow({ mode: "open" });
-
- // get the ID and classes for that element, as we need to display it in the box
- const id = this.element.id;
- const classes = Array.from(this.element.classList || []);
-
- // get the dimensions of the element
- const elemBounds = this.element.getBoundingClientRect();
- // we only show integers, because showing decimal places will take up a lot more space
- const elemWidth = Math.round(elemBounds.width);
- const elemHeight = Math.round(elemBounds.height);
-
- let content = ""; // this will hold the main content that will be displayed
-
- // add the tag name and dimensions in the same line
- content += "";
- content += "" + this.element.tagName.toLowerCase() + "";
- content += `${elemWidth} × ${elemHeight}`;
- content += "";
-
- // Add ID if present
- if (id) {
- content += "#" + id + "";
- }
-
- // Add classes (limit to 3 with dropdown indicator)
- if (classes.length > 0) {
- content += "";
- for (var i = 0; i < Math.min(classes.length, 3); i++) {
- content += "." + classes[i] + " ";
- }
- if (classes.length > 3) {
- content += "+" + (classes.length - 3) + " more";
- }
- content += "";
- }
-
- // initially, we place our info box -1000px to the top but at the right left pos. this is done so that
- // we can take the text-wrapping inside the info box in account when calculating the height
- // after calculating the height of the box, we place it at the exact position above the element
- const offset = _screenOffset(this.element);
- const leftPos = offset.left;
-
- // if element is non-editable we use gray bg color in info box, otherwise normal blue color
- const bgColor = this.element.hasAttribute('data-brackets-id') ? '#4285F4' : '#3C3F41';
-
- const styles = `
- :host {
- all: initial !important;
- }
-
- .phoenix-node-info-box {
- background-color: ${bgColor} !important;
- color: white !important;
- border-radius: 3px !important;
- padding: 5px 8px !important;
- box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important;
- font-size: 12px !important;
- font-family: Arial, sans-serif !important;
- z-index: 2147483646 !important;
- position: absolute !important;
- left: ${leftPos}px;
- top: -1000px;
- max-width: 300px !important;
- box-sizing: border-box !important;
- pointer-events: none !important;
- }
-
- .tag-line {
- display: flex !important;
- align-items: baseline !important;
- justify-content: space-between !important;
- }
-
- .tag-name {
- font-weight: bold !important;
- }
-
- .elem-dimensions {
- font-size: 9px !important;
- font-weight: 500 !important;
- opacity: 0.9 !important;
- margin-left: 7px !important;
- flex-shrink: 0 !important;
- }
-
- .id-name,
- .class-name {
- margin-top: 3px !important;
- }
-
- .exceeded-classes {
- opacity: 0.8 !important;
- }
- `;
-
- // add everything to the shadow box
- shadow.innerHTML = `${content}`;
- this._shadow = shadow;
- },
-
- create: function() {
- this.remove(); // remove existing box if already present
-
- if(!config.isProUser) {
- return;
- }
-
- // this check because when there is no element visible to the user, we don't want to show the box
- // for ex: when user clicks on a 'x' button and the button is responsible to hide a panel
- // then clicking on that button shouldn't show the more options box
- // also covers cases where elements are inside closed/collapsed menus
- if(!isElementVisible(this.element)) {
- return;
- }
-
- this._style(); // style the box
-
- window.document.body.appendChild(this.body);
-
- // get the actual rendered height of the box and then we reposition it to the actual place
- const boxElement = this._shadow.querySelector('.phoenix-node-info-box');
- if (boxElement) {
- const nodeInfoBoxDimensions = {
- height: boxElement.getBoundingClientRect().height,
- width: boxElement.getBoundingClientRect().width
- };
- const nodeInfoBoxPos = this._getBoxPosition(nodeInfoBoxDimensions, false);
-
- boxElement.style.left = nodeInfoBoxPos.leftPos + 'px';
- boxElement.style.top = nodeInfoBoxPos.topPos + 'px';
-
- const isBoxOverlapping = this._checkOverlap(nodeInfoBoxPos, nodeInfoBoxDimensions);
- if(isBoxOverlapping) {
- const newPos = this._getBoxPosition(nodeInfoBoxDimensions, true);
- boxElement.style.left = newPos.leftPos + 'px';
- boxElement.style.top = newPos.topPos + 'px';
- }
- }
- },
-
- remove: function() {
- if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) {
- window.document.body.removeChild(this.body);
- this.body = null;
- }
- }
- };
-
- // AI prompt box, it is displayed when user clicks on the AI button in the more options box
- function AIPromptBox(element) {
- this.element = element;
- this.selectedModel = 'fast';
- this.remove = this.remove.bind(this);
- this.create();
- }
-
- AIPromptBox.prototype = {
- _getBoxPosition: function(boxWidth, boxHeight) {
- const elemBounds = this.element.getBoundingClientRect();
- const offset = _screenOffset(this.element);
-
- let topPos = offset.top - boxHeight - 6; // 6 for just some little space to breathe
- let leftPos = offset.left + elemBounds.width - boxWidth;
-
- // Check if the box would go off the top of the viewport
- if (elemBounds.top - boxHeight < 6) {
- topPos = offset.top + elemBounds.height + 6;
- }
-
- // Check if the box would go off the left of the viewport
- if (leftPos < 0) {
- leftPos = offset.left;
- }
-
- return {topPos: topPos, leftPos: leftPos};
- },
-
- _style: function() {
- this.body = window.document.createElement("div");
- this.body.setAttribute("data-phcode-internal-c15r5a9", "true");
- // using shadow dom so that user styles doesn't override it
- const shadow = this.body.attachShadow({ mode: "open" });
-
- // Calculate responsive dimensions based on viewport width
- const viewportWidth = window.innerWidth;
- let boxWidth, boxHeight;
-
- if (viewportWidth >= 400) {
- boxWidth = Math.min(310, viewportWidth * 0.85);
- boxHeight = 60;
- } else if (viewportWidth >= 350) {
- boxWidth = Math.min(275, viewportWidth * 0.85);
- boxHeight = 70;
- } else if (viewportWidth >= 300) {
- boxWidth = Math.min(230, viewportWidth * 0.85);
- boxHeight = 80;
- } else if (viewportWidth >= 250) {
- boxWidth = Math.min(180, viewportWidth * 0.85);
- boxHeight = 100;
- } else if (viewportWidth >= 200) {
- boxWidth = Math.min(130, viewportWidth * 0.85);
- boxHeight = 120;
- } else {
- boxWidth = Math.min(100, viewportWidth * 0.85);
- boxHeight = 140;
- }
-
- const styles = `
- :host {
- all: initial !important;
- }
-
- .phoenix-ai-prompt-box {
- position: absolute !important;
- background: #3C3F41 !important;
- border: 1px solid #4285F4 !important;
- border-radius: 4px !important;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
- font-family: Arial, sans-serif !important;
- z-index: 2147483647 !important;
- width: ${boxWidth}px !important;
- padding: 0 !important;
- box-sizing: border-box !important;
- }
-
- .phoenix-ai-prompt-input-container {
- position: relative !important;
- }
-
- .phoenix-ai-prompt-textarea {
- width: 100% !important;
- height: ${boxHeight}px !important;
- border: none !important;
- border-radius: 4px 4px 0 0 !important;
- padding: 12px 40px 12px 16px !important;
- font-size: 14px !important;
- font-family: Arial, sans-serif !important;
- resize: none !important;
- outline: none !important;
- box-sizing: border-box !important;
- background: transparent !important;
- color: #c5c5c5 !important;
- transition: background 0.2s ease !important;
- }
-
- .phoenix-ai-prompt-textarea:focus {
- background: rgba(255, 255, 255, 0.03) !important;
- }
-
- .phoenix-ai-prompt-textarea::placeholder {
- color: #a0a0a0 !important;
- opacity: 0.7 !important;
- }
-
- .phoenix-ai-prompt-send-button {
- background-color: transparent !important;
- border: 1px solid transparent !important;
- color: #a0a0a0 !important;
- border-radius: 4px !important;
- cursor: pointer !important;
- padding: 3px 6px !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
- font-size: 14px !important;
- transition: all 0.2s ease !important;
- }
-
- .phoenix-ai-prompt-send-button:hover:not(:disabled) {
- border: 1px solid rgba(0, 0, 0, 0.24) !important;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important;
- }
-
- .phoenix-ai-prompt-send-button:disabled {
- opacity: 0.5 !important;
- cursor: not-allowed !important;
- }
-
- .phoenix-ai-bottom-controls {
- border-top: 1px solid rgba(255,255,255,0.14) !important;
- padding: 8px 16px !important;
- background: transparent !important;
- border-radius: 0 0 4px 4px !important;
- display: flex !important;
- align-items: center !important;
- justify-content: space-between !important;
- }
-
- .phoenix-ai-model-select {
- padding: 4px 8px !important;
- border: 1px solid transparent !important;
- border-radius: 4px !important;
- font-size: 12px !important;
- background: transparent !important;
- color: #a0a0a0 !important;
- outline: none !important;
- cursor: pointer !important;
- transition: all 0.2s ease !important;
- }
-
- .phoenix-ai-model-select:hover {
- border: 1px solid rgba(0, 0, 0, 0.24) !important;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important;
- }
-
- .phoenix-ai-model-select:focus {
- border: 1px solid rgba(0, 0, 0, 0.24) !important;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important;
- }
-
- .phoenix-ai-model-select option {
- background: #000 !important;
- color: #fff !important;
- padding: 4px 8px !important;
- }
- `;
-
- const content = `
-
-
-
-
-
-
-
-
-
- `;
-
- shadow.innerHTML = `${content}`;
- this._shadow = shadow;
- },
-
- create: function() {
- this._style();
- window.document.body.appendChild(this.body);
-
- // Get the actual rendered dimensions of the box and position it
- const boxElement = this._shadow.querySelector('.phoenix-ai-prompt-box');
- if (boxElement) {
- const boxRect = boxElement.getBoundingClientRect();
- const pos = this._getBoxPosition(boxRect.width, boxRect.height);
-
- boxElement.style.left = pos.leftPos + 'px';
- boxElement.style.top = pos.topPos + 'px';
- }
-
- // Focus on the textarea
- const textarea = this._shadow.querySelector('.phoenix-ai-prompt-textarea');
- if (textarea) { // small timer to make sure that the text area element is fetched
- setTimeout(() => textarea.focus(), 50);
- }
-
- this._attachEventHandlers();
-
- // Prevent clicks inside the AI box from bubbling up and closing it
- this.body.addEventListener('click', (event) => {
- event.stopPropagation();
- });
- },
-
- _attachEventHandlers: function() {
- const textarea = this._shadow.querySelector('.phoenix-ai-prompt-textarea');
- const sendButton = this._shadow.querySelector('.phoenix-ai-prompt-send-button');
- const modelSelect = this._shadow.querySelector('.phoenix-ai-model-select');
-
- // Handle textarea input to enable/disable send button
- if (textarea && sendButton) {
- textarea.addEventListener('input', (event) => {
- const hasText = event.target.value.trim().length > 0;
- sendButton.disabled = !hasText;
- });
-
- // enter key
- textarea.addEventListener('keydown', (event) => {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault();
- if (textarea.value.trim()) {
- this._handleSend(event, textarea.value.trim());
- }
- } else if (event.key === 'Escape') {
- event.preventDefault();
- this.remove();
- }
- });
- }
-
- // send button click
- if (sendButton) {
- sendButton.addEventListener('click', (event) => {
- event.preventDefault();
- event.stopPropagation();
- if (textarea && textarea.value.trim()) {
- this._handleSend(event, textarea.value.trim());
- }
- });
- }
-
- // model selection change
- if (modelSelect) {
- modelSelect.addEventListener('change', (event) => {
- this.selectedModel = event.target.value;
- });
- }
- },
-
- _handleSend: function(event, prompt) {
- const element = this.element;
- if(!isElementEditable(element)) {
- return;
- }
- const tagId = element.getAttribute("data-brackets-id");
-
- window._Brackets_MessageBroker.send({
- livePreviewEditEnabled: true,
- event: event,
- element: element,
- prompt: prompt,
- tagId: Number(tagId),
- selectedModel: this.selectedModel,
- AISend: true
- });
- this.remove();
- },
-
- remove: function() {
- if (this._handleKeydown) {
- document.removeEventListener('keydown', this._handleKeydown);
- this._handleKeydown = null;
- }
-
- if (this._handleResize) {
- window.removeEventListener('resize', this._handleResize);
- this._handleResize = null;
- }
-
- if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) {
- window.document.body.removeChild(this.body);
- this.body = null;
- _aiPromptBox = null;
- }
- }
- };
-
- // image ribbon gallery cache, to store the last query and its results
- const CACHE_EXPIRY_TIME = 168 * 60 * 60 * 1000; // 7 days, might need to revise this...
- const CACHE_MAX_IMAGES = 50; // max number of images that we store in the localStorage
- const _imageGalleryCache = {
- get currentQuery() {
- const data = this._getFromStorage();
- return data ? data.currentQuery : null;
- },
- set currentQuery(val) {
- this._updateStorage({currentQuery: val});
- },
-
- get allImages() {
- const data = this._getFromStorage();
- return data ? data.allImages : [];
- },
- set allImages(val) {
- this._updateStorage({allImages: val});
- },
-
- get totalPages() {
- const data = this._getFromStorage();
- return data ? data.totalPages : 1;
- },
- set totalPages(val) {
- this._updateStorage({totalPages: val});
- },
-
- get currentPage() {
- const data = this._getFromStorage();
- return data ? data.currentPage : 1;
- },
- set currentPage(val) {
- this._updateStorage({currentPage: val});
- },
-
-
- _getFromStorage() {
- try {
- const data = window.localStorage.getItem('imageGalleryCache');
- if (!data) { return null; }
-
- const parsed = JSON.parse(data);
-
- if (Date.now() > parsed.expires) {
- window.localStorage.removeItem('imageGalleryCache');
- return null;
- }
-
- return parsed;
- } catch (error) {
- return null;
- }
- },
-
- _updateStorage(updates) {
- try {
- const current = this._getFromStorage() || {};
- const newData = {
- ...current,
- ...updates,
- expires: Date.now() + CACHE_EXPIRY_TIME
- };
- window.localStorage.setItem('imageGalleryCache', JSON.stringify(newData));
- } catch (error) {
- if (error.name === 'QuotaExceededError') {
- try {
- window.localStorage.removeItem('imageGalleryCache');
- window.localStorage.setItem('imageGalleryCache', JSON.stringify(updates));
- } catch (retryError) {
- console.error('Failed to save image cache even after clearing:', retryError);
- }
- }
- }
- }
- };
-
- /**
- * when user clicks on an image we call this,
- * this creates a image ribbon gallery at the bottom of the live preview
- */
- function ImageRibbonGallery(element) {
- this.element = element;
- this.remove = this.remove.bind(this);
- this.currentPage = 1;
- this.totalPages = 1;
- this.allImages = [];
- this.imagesPerPage = 10;
- this.scrollPosition = 0;
-
- this.create();
- }
-
- ImageRibbonGallery.prototype = {
- _style: function () {
- this.body = window.document.createElement("div");
- this.body.setAttribute("data-phcode-internal-c15r5a9", "true");
- this._shadow = this.body.attachShadow({ mode: 'open' });
-
- this._shadow.innerHTML = `
-
-
-
-
-
-
-
-
- ${config.strings.imageGallery}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ‹
-
-
- ${config.strings.imageGalleryLoadingInitial}
-
-
- ›
-
- `;
- },
-
- _getDefaultQuery: function() {
- // this are the default queries, so when image ribbon gallery is shown, we select a random query and show it
- const qualityQueries = [
- 'nature', 'minimal', 'workspace', 'abstract', 'coffee',
- 'mountains', 'city', 'flowers', 'ocean', 'sunset',
- 'architecture', 'forest', 'travel', 'technology', 'sky',
- 'landscape', 'creative', 'design', 'art', 'modern',
- 'food', 'patterns', 'colors', 'photography', 'studio',
- 'light', 'winter', 'summer', 'vintage', 'geometric',
- 'water', 'beach', 'space', 'garden', 'textures',
- 'urban', 'portrait', 'music', 'books', 'home',
- 'cozy', 'aesthetic', 'autumn', 'spring', 'clouds'
- ];
-
- const randIndex = Math.floor(Math.random() * qualityQueries.length);
- return qualityQueries[randIndex];
- },
-
- _fetchImages: function(searchQuery, page = 1, append = false) {
- this._currentSearchQuery = searchQuery;
-
- if (!append && this._loadFromCache(searchQuery)) { // try cache first
- return;
- }
- if (append && this._loadPageFromCache(searchQuery, page)) { // try to load new page from cache
- return;
- }
- // if unable to load from cache, we make the API call
- this._fetchFromAPI(searchQuery, page, append);
- },
-
- _fetchFromAPI: function(searchQuery, page, append) {
- // when we fetch from API, we clear the previous query from local storage and then store a fresh copy
- if (searchQuery !== _imageGalleryCache.currentQuery) {
- this._clearCache();
- }
-
- const apiUrl = `https://images.phcode.dev/api/images/search?q=${encodeURIComponent(searchQuery)}&per_page=10&page=${page}&safe=true`;
-
- if (!append) {
- this._showLoading();
- }
-
- fetch(apiUrl)
- .then(response => {
- if (!response.ok) {
- throw new Error(`API request failed: ${response.status}`);
- }
- return response.json();
- })
- .then(data => {
- if (data.results && data.results.length > 0) {
- if (append) {
- this.allImages = this.allImages.concat(data.results);
- this._renderImages(data.results, true); // true means need to append new images at the end
- } else {
- this.allImages = data.results;
- this._renderImages(this.allImages, false); // false means its a new search
- }
- this.totalPages = data.total_pages || 1;
- this.currentPage = page;
- this._handleNavButtonsDisplay('visible');
- this._updateSearchInput(searchQuery);
- this._updateCache(searchQuery, data, append);
- } else if (!append) {
- this._showError(config.strings.imageGalleryNoImages);
- }
-
- if (append) {
- this._isLoadingMore = false;
- this._hideLoadingMore();
- }
- })
- .catch(error => {
- console.error('Failed to fetch images:', error);
- if (!append) {
- this._showError(config.strings.imageGalleryLoadError);
- } else {
- this._isLoadingMore = false;
- this._hideLoadingMore();
- }
- });
- },
-
- _updateCache: function(searchQuery, data, append) {
- // Update cache with new data for current query
- _imageGalleryCache.currentQuery = searchQuery;
- _imageGalleryCache.totalPages = data.total_pages || 1;
- _imageGalleryCache.currentPage = this.currentPage;
-
- if (append) {
- const currentImages = _imageGalleryCache.allImages || [];
- const newImages = currentImages.concat(data.results);
-
- if (newImages.length > CACHE_MAX_IMAGES) {
- _imageGalleryCache.allImages = newImages.slice(0, CACHE_MAX_IMAGES);
- } else {
- _imageGalleryCache.allImages = newImages;
- }
- } else {
- // new search replace cache
- _imageGalleryCache.allImages = data.results;
- }
- },
-
- _clearCache: function() {
- try {
- window.localStorage.removeItem('imageGalleryCache');
- } catch (error) {
- console.error('Failed to clear image cache:', error);
- }
- },
-
- _updateSearchInput: function(searchQuery) {
- // write the current query in the search input
- const searchInput = this._shadow.querySelector('.search-wrapper input');
- if (searchInput && searchQuery) {
- searchInput.value = searchQuery;
- searchInput.placeholder = searchQuery;
- }
- },
-
- _loadFromCache: function(searchQuery) {
- const cachedImages = _imageGalleryCache.allImages;
- if (searchQuery === _imageGalleryCache.currentQuery && cachedImages && cachedImages.length > 0) {
- this.allImages = cachedImages;
- this.totalPages = _imageGalleryCache.totalPages;
- this.currentPage = _imageGalleryCache.currentPage;
-
- this._renderImages(this.allImages, false);
- this._handleNavButtonsDisplay('visible');
- this._updateSearchInput(searchQuery);
- return true;
- }
- return false;
- },
-
- _loadPageFromCache: function(searchQuery, page) {
- const cachedImages = _imageGalleryCache.allImages;
- if (searchQuery === _imageGalleryCache.currentQuery && cachedImages && page <= Math.ceil(cachedImages.length / 10)) {
- const startIdx = (page - 1) * 10;
- const endIdx = startIdx + 10;
- const pageImages = cachedImages.slice(startIdx, endIdx);
-
- if (pageImages.length > 0) {
- this.allImages = this.allImages.concat(pageImages);
- this._renderImages(pageImages, true);
- this.currentPage = page;
- this._handleNavButtonsDisplay('visible');
- this._isLoadingMore = false;
- this._hideLoadingMore();
- return true;
- }
- }
- return false;
- },
-
- _handleNavLeft: function() {
- const container = this._shadow.querySelector('.phoenix-image-gallery-strip');
- if (!container) { return; }
-
- const containerWidth = container.clientWidth;
- const imageWidth = 117; // image width + gap
-
- // calculate how many images are visible
- const visibleImages = Math.floor(containerWidth / imageWidth);
-
- // scroll by (visible images - 2), minimum 1 image, maximum 5 images
- const imagesToScroll = Math.max(1, Math.min(5, visibleImages - 2));
- const scrollAmount = imagesToScroll * imageWidth;
-
- this.scrollPosition = Math.max(0, this.scrollPosition - scrollAmount);
- container.scrollTo({ left: this.scrollPosition, behavior: 'smooth' });
- this._handleNavButtonsDisplay('visible');
- },
-
- _handleNavRight: function() {
- const container = this._shadow.querySelector('.phoenix-image-gallery-strip');
- if (!container) { return; }
-
- const containerWidth = container.clientWidth;
- const totalWidth = container.scrollWidth;
- const imageWidth = 117; // image width + gap
-
- // calculate how many images are visible
- const visibleImages = Math.floor(containerWidth / imageWidth);
-
- // scroll by (visible images - 2), minimum 1 image, maximum 5 images
- const imagesToScroll = Math.max(1, Math.min(5, visibleImages - 2));
- const scrollAmount = imagesToScroll * imageWidth;
-
- // if we're near the end, we need to load more images
- const nearEnd = (this.scrollPosition + containerWidth + scrollAmount) >= totalWidth - 100;
- if (nearEnd && this.currentPage < this.totalPages && !this._isLoadingMore) {
- this._isLoadingMore = true;
- this._showLoadingMore();
- this._fetchImages(this._currentSearchQuery, this.currentPage + 1, true);
- }
-
- this.scrollPosition = Math.min(totalWidth - containerWidth, this.scrollPosition + scrollAmount);
- container.scrollTo({ left: this.scrollPosition, behavior: 'smooth' });
- this._handleNavButtonsDisplay('visible');
- },
-
- _handleNavButtonsDisplay: function(state) { // state can be 'visible' or 'hidden'
- const navLeft = this._shadow.querySelector('.phoenix-image-gallery-nav.left');
- const navRight = this._shadow.querySelector('.phoenix-image-gallery-nav.right');
- const container = this._shadow.querySelector('.phoenix-image-gallery-strip');
-
- if (!navLeft || !navRight) { return; }
-
- if (state === 'hidden') {
- navLeft.style.setProperty('display', 'none', 'important');
- navRight.style.setProperty('display', 'none', 'important');
- return;
- }
-
- if (state === 'visible') {
- if (!container) { return; }
-
- // show/hide the nav-left button
- if (this.scrollPosition <= 0) {
- navLeft.style.setProperty('display', 'none', 'important');
- } else {
- navLeft.style.setProperty('display', 'flex', 'important');
- }
-
- // show/hide the nav-right button
- const containerWidth = container.clientWidth;
- const totalWidth = container.scrollWidth;
- const atEnd = (this.scrollPosition + containerWidth) >= totalWidth - 10;
- const hasMorePages = this.currentPage < this.totalPages;
-
- if (atEnd && !hasMorePages) {
- navRight.style.setProperty('display', 'none', 'important');
- } else {
- navRight.style.setProperty('display', 'flex', 'important');
- }
- }
- },
-
- _showLoading: function() {
- const rowElement = this._shadow.querySelector('.phoenix-image-gallery-row');
- if (!rowElement) { return; }
-
- rowElement.innerHTML = config.strings.imageGalleryLoadingInitial;
- rowElement.className = 'phoenix-image-gallery-row phoenix-image-gallery-loading';
-
- this._handleNavButtonsDisplay('hidden');
- },
-
- _showLoadingMore: function() {
- const rowElement = this._shadow.querySelector('.phoenix-image-gallery-row');
- if (!rowElement) { return; }
-
- // when loading more images we need to show the message at the end of the image ribbon
- const loadingIndicator = window.document.createElement('div');
- loadingIndicator.className = 'phoenix-loading-more';
- loadingIndicator.textContent = config.strings.imageGalleryLoadingMore;
- rowElement.appendChild(loadingIndicator);
- },
-
- _hideLoadingMore: function() {
- const loadingIndicator = this._shadow.querySelector('.phoenix-loading-more');
- if (loadingIndicator) {
- loadingIndicator.remove();
- }
- },
-
- _attachEventHandlers: function() {
- const ribbonContainer = this._shadow.querySelector('.phoenix-image-gallery-container');
- const ribbonStrip = this._shadow.querySelector('.phoenix-image-gallery-strip');
- const searchInput = this._shadow.querySelector('.search-wrapper input');
- const searchButton = this._shadow.querySelector('.search-icon');
- const closeButton = this._shadow.querySelector('.phoenix-image-gallery-close-button');
- const folderSettingsButton = this._shadow.querySelector('.phoenix-image-gallery-download-folder-button');
- const navLeft = this._shadow.querySelector('.phoenix-image-gallery-nav.left');
- const navRight = this._shadow.querySelector('.phoenix-image-gallery-nav.right');
- const selectImageBtn = this._shadow.querySelector('.phoenix-image-gallery-upload-container button');
- const fileInput = this._shadow.querySelector('.phoenix-file-input');
-
- if (searchInput && searchButton) {
- const performSearch = (e) => {
- e.stopPropagation();
- const query = searchInput.value.trim();
- if (query) {
- // reset pagination when searching
- this.currentPage = 1;
- this.allImages = [];
- this.scrollPosition = 0;
- this._fetchImages(query);
- }
- };
-
- // disable/enable search button as per input container text
- const updateSearchButtonState = () => {
- searchButton.disabled = searchInput.value.trim().length === 0;
- };
-
- searchInput.addEventListener('input', updateSearchButtonState);
-
- searchInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- performSearch(e);
- }
- });
-
- searchInput.addEventListener('click', (e) => {
- e.stopPropagation();
- });
-
- searchButton.addEventListener('click', performSearch);
- }
-
- if (selectImageBtn && fileInput) {
- selectImageBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- fileInput.click();
- });
-
- fileInput.addEventListener('change', (e) => {
- e.stopPropagation();
- const file = e.target.files[0];
- if (file) {
- this._handleLocalImageSelection(file);
- fileInput.value = '';
- }
- });
- }
-
- if (closeButton) {
- closeButton.addEventListener('click', (e) => {
- e.stopPropagation();
- this.remove();
- imageGallerySelected = false;
- _handleImageGalleryStateChange();
- dismissUIAndCleanupState();
- });
- }
-
- if (folderSettingsButton) {
- folderSettingsButton.addEventListener('click', (e) => {
- e.stopPropagation();
- // send message to LivePreviewEdit to show folder selection dialog
- const tagId = this.element.getAttribute("data-brackets-id");
- window._Brackets_MessageBroker.send({
- livePreviewEditEnabled: true,
- resetImageFolderSelection: true,
- element: this.element,
- tagId: Number(tagId)
- });
- });
- }
-
- if (navLeft) {
- navLeft.addEventListener('click', (e) => {
- e.stopPropagation();
- this._handleNavLeft();
- });
- }
+// this is a single file sent to browser preview. keep this light. add features as extensions
+// Please do not add any license header in this file as it will end up in distribution bin as is.
+/**
+ * RemoteFunctions define the functions to be executed in the browser. This
+ * modules should define a single function that returns an object of all
+ * exported functions.
+ */
+// eslint-disable-next-line no-unused-vars
+function RemoteFunctions(config = {}) {
+ const GLOBALS = {
+ // given to internal elements like info box, tool box, image gallery and all other phcode internal elements
+ // to distinguish between phoenix internal vs user created elements
+ PHCODE_INTERNAL_ATTR: "data-phcode-internal-c15r5a9",
+ DATA_BRACKETS_ID_ATTR: "data-brackets-id", // data attribute used to track elements for live preview operations
+ HIGHLIGHT_CLASSNAME: "__brackets-ld-highlight" // CSS class name used for highlighting elements in live preview
+ };
- if (navRight) {
- navRight.addEventListener('click', (e) => {
- e.stopPropagation();
- this._handleNavRight();
- });
- }
+ const SHARED_STATE = {
+ __description: "Use this to keep shared state for Live Preview Edit instead of window.*"
+ };
- // Restore original image when mouse leaves the entire ribbon strip
- if (ribbonStrip) {
- ribbonStrip.addEventListener('mouseleave', () => {
- this.element.src = this._originalImageSrc;
- });
- }
+ let _localHighlight;
+ let _hoverHighlight;
+ let _clickHighlight;
+ let _setup = false;
+ let _hoverLockTimer = null;
- // Prevent clicks anywhere inside the ribbon from bubbling up
- if (ribbonContainer) {
- ribbonContainer.addEventListener('click', (e) => {
- e.stopPropagation();
- });
- }
- },
+ // this will store the element that was clicked previously (before the new click)
+ // we need this so that we can remove click styling from the previous element when a new element is clicked
+ let previouslyClickedElement = null;
- // append true means load more images (user clicked on nav-right)
- // append false means its a new query
- _renderImages: function(images, append = false) {
- const rowElement = this._shadow.querySelector('.phoenix-image-gallery-row');
- if (!rowElement) { return; }
+ var req, timeout;
+ function animateHighlight(time) {
+ if(req) {
+ window.cancelAnimationFrame(req);
+ window.clearTimeout(timeout);
+ }
+ req = window.requestAnimationFrame(redrawHighlights);
- const container = this._shadow.querySelector('.phoenix-image-gallery-strip');
- const savedScrollPosition = container ? container.scrollLeft : 0;
+ timeout = setTimeout(function () {
+ window.cancelAnimationFrame(req);
+ req = null;
+ }, time * 1000);
+ }
- // if not appending we clear the phoenix ribbon
- if (!append) {
- rowElement.innerHTML = '';
- rowElement.className = 'phoenix-image-gallery-row';
- } else {
- // when appending we add the new images at the end
- const loadingIndicator = this._shadow.querySelector('.phoenix-loading-more');
- if (loadingIndicator) {
- loadingIndicator.remove();
- }
+ // the following fucntions can be in the handler and live preview will call those functions when the below
+ // events happen
+ const allowedHandlerFns = [
+ "dismiss", // when handler gets this event, it should dismiss all ui it renders in the live preview
+ "createToolBox",
+ "createInfoBox",
+ "createMoreOptionsDropdown",
+ // render an icon or html when the selected element toolbox appears in edit mode.
+ "renderToolBoxItem",
+ "redraw",
+ "onElementSelected", // an item is selected in live preview
+ "onElementCleanup",
+ "onNonEditableElementClick", // called when user clicks on a non-editable element
+ "handleConfigChange",
+ // below function gets called to render the dropdown when user clicks on the ... menu in the tool box,
+ // the handler should retrun html tor ender the dropdown item.
+ "renderDropdownItems",
+ // called when an item is selected from the more options dropdown
+ "handleDropdownClick",
+ "reRegisterEventHandlers",
+ "handleClick", // handle click on an icon in the tool box.
+ // when escape key is presses in the editor, we may need to dismiss the live edit boxes.
+ "handleEscapePressFromEditor",
+ // interaction blocks acts as 'kill switch' to block all kinds of click handlers
+ // this is done so that links or buttons doesn't perform their natural operation in edit mode
+ "registerInteractionBlocker", // to block
+ "unregisterInteractionBlocker", // to unblock
+ "udpateHotCornerState" // to update the hot corner button when state changes
+ ];
+
+ const _toolHandlers = new Map();
+ function registerToolHandler(handlerName, handler) {
+ if(_toolHandlers.get(handlerName)) {
+ console.error(`lp: Tool handler '${handlerName}' already registered. Ignoring new registration`);
+ return;
+ }
+ if (!handler || typeof handler !== "object") {
+ console.error(`lp: Tool handler '${handlerName}' value is invalid ${JSON.stringify(handler)}.`);
+ return;
+ }
+ handler.handlerName = handlerName;
+ for (const key of Object.keys(handler)) {
+ if (key !== "handlerName" && !allowedHandlerFns.includes(key)) {
+ console.warn(`lp: Tool handler '${handlerName}' has unknown property '${key}'`,
+ `should be one of ${allowedHandlerFns.join(",")}`);
}
+ }
+ _toolHandlers.set(handlerName, handler);
+ }
+ function getToolHandler(handlerName) {
+ return _toolHandlers.get(handlerName);
+ }
+ function getAllToolHandlers() {
+ return Array.from(_toolHandlers.values());
+ }
- // Create thumbnails from API data
- images.forEach(image => {
- const thumbDiv = window.document.createElement('div');
- thumbDiv.className = 'phoenix-ribbon-thumb';
-
- const img = window.document.createElement('img');
- img.src = image.thumb_url || image.url;
- img.alt = image.alt_text || '';
- img.loading = 'lazy';
-
- // show hovered image along with dimensions
- thumbDiv.addEventListener('mouseenter', () => {
- this.element.style.width = this._originalImageStyle.width;
- this.element.style.height = this._originalImageStyle.height;
-
- this.element.style.objectFit = this._originalImageStyle.objectFit || 'cover';
- this.element.src = image.url || image.thumb_url;
- });
-
- // attribution overlay, we show this only in the image ribbon gallery
- const attribution = window.document.createElement('div');
- attribution.className = 'phoenix-ribbon-attribution';
-
- const photographer = window.document.createElement('a');
- photographer.className = 'photographer';
- photographer.href = image.photographer_url;
- photographer.target = '_blank';
- photographer.rel = 'noopener noreferrer';
- photographer.textContent = (image.user && image.user.name) || 'Anonymous';
- photographer.addEventListener('click', (e) => {
- e.stopPropagation();
- });
-
- const source = window.document.createElement('a');
- source.className = 'source';
- source.href = image.unsplash_url;
- source.target = '_blank';
- source.rel = 'noopener noreferrer';
- source.textContent = 'on Unsplash';
- source.addEventListener('click', (e) => {
- e.stopPropagation();
- });
-
- attribution.appendChild(photographer);
- attribution.appendChild(source);
-
- // download icon
- const downloadIcon = window.document.createElement('div');
- downloadIcon.className = 'phoenix-download-icon';
- downloadIcon.title = config.strings.imageGalleryUseImage;
- downloadIcon.innerHTML = ICONS.downloadImage;
-
- // when the image is clicked we download the image
- thumbDiv.addEventListener('click', (e) => {
- e.stopPropagation();
- e.preventDefault();
-
- // prevent multiple downloads of the same image
- if (thumbDiv.classList.contains('downloading')) { return; }
-
- // show download indicator
- this._showDownloadIndicator(thumbDiv);
-
- const filename = this._generateFilename(image);
- const extnName = ".jpg";
-
- const downloadUrl = image.url || image.thumb_url;
- const downloadLocation = image.download_location;
-
- this._useImage(downloadUrl, filename, extnName, false, thumbDiv, downloadLocation);
- });
-
- thumbDiv.appendChild(img);
- thumbDiv.appendChild(attribution);
- thumbDiv.appendChild(downloadIcon);
- rowElement.appendChild(thumbDiv);
- });
-
- if (append && container && savedScrollPosition > 0) {
- setTimeout(() => {
- container.scrollLeft = savedScrollPosition;
- }, 0);
- }
+ /**
+ * check if an element is inspectable.
+ * inspectable elements are those which doesn't have GLOBALS.DATA_BRACKETS_ID_ATTR ('data-brackets-id'),
+ * this normally happens when content is DOM content is inserted by some scripting language
+ */
+ function isElementInspectable(element, onlyHighlight = false) {
+ if(config.mode !== 'edit' && !onlyHighlight) {
+ return false;
+ }
- this._handleNavButtonsDisplay('visible');
- },
+ if(element && // element should exist
+ element.tagName.toLowerCase() !== "body" && // shouldn't be the body tag
+ element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag
+ // this attribute is used by phoenix internal elements
+ !element.closest(`[${GLOBALS.PHCODE_INTERNAL_ATTR}]`) &&
+ !_isInsideHeadTag(element)) { // shouldn't be inside the head tag like meta tags and all
+ return true;
+ }
+ return false;
+ }
- _showError: function(message) {
- const rowElement = this._shadow.querySelector('.phoenix-image-gallery-row');
- if (!rowElement) { return; }
+ /**
+ * This is a checker function for editable elements, it makes sure that the element satisfies all the required check
+ * - When onlyHighlight is false → config.mode must be 'edit'
+ * - When onlyHighlight is true → config.mode can be any mode (doesn't matter)
+ * @param {DOMElement} element
+ * @param {boolean} [onlyHighlight=false] - If true, bypasses the mode check
+ * @returns {boolean} - True if the element is editable else false
+ */
+ function isElementEditable(element, onlyHighlight = false) {
+ // for an element to be editable it should satisfy all inspectable checks and should also have data-brackets-id
+ return isElementInspectable(element, onlyHighlight) && element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+ }
- rowElement.innerHTML = message;
- rowElement.className = 'phoenix-image-gallery-row phoenix-ribbon-error';
+ /**
+ * this function calc the screen offset of an element
+ *
+ * @param {DOMElement} element
+ * @returns {{left: number, top: number}}
+ */
+ function screenOffset(element) {
+ const elemBounds = element.getBoundingClientRect();
+ const body = window.document.body;
+ let offsetTop;
+ let offsetLeft;
- this._handleNavButtonsDisplay('hidden');
- },
+ if (window.getComputedStyle(body).position === "static") {
+ offsetLeft = elemBounds.left + window.pageXOffset;
+ offsetTop = elemBounds.top + window.pageYOffset;
+ } else {
+ const bodyBounds = body.getBoundingClientRect();
+ offsetLeft = elemBounds.left - bodyBounds.left;
+ offsetTop = elemBounds.top - bodyBounds.top;
+ }
+ return { left: offsetLeft, top: offsetTop };
+ }
- // file name with which we need to save the image
- _generateFilename: function(image) {
- const photographerName = (image.user && image.user.name) || 'Anonymous';
- const searchTerm = this._currentSearchQuery || 'image';
+ const LivePreviewView = {
+ registerToolHandler: registerToolHandler,
+ getToolHandler: getToolHandler,
+ getAllToolHandlers: getAllToolHandlers,
+ isElementEditable: isElementEditable,
+ isElementInspectable: isElementInspectable,
+ isElementVisible: isElementVisible,
+ screenOffset: screenOffset,
+ selectElement: selectElement,
+ brieflyDisableHoverListeners: brieflyDisableHoverListeners,
+ handleElementClick: handleElementClick,
+ cleanupPreviousElementState: cleanupPreviousElementState
+ };
- // clean the search term and the photograper name to write in file name
- const cleanSearchTerm = searchTerm.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
- const cleanPhotographerName = photographerName.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
+ /**
+ * @type {DOMEditHandler}
+ */
+ var _editHandler;
- return `${cleanSearchTerm}-by-${cleanPhotographerName}`;
- },
+ // the below code comment is replaced by added scripts for extensibility
+ // DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_CONSTANT_SCRIPTS
- _useImage: function(imageUrl, filename, extnName, isLocalFile, thumbDiv, downloadLocation) {
- const tagId = this.element.getAttribute("data-brackets-id");
- const downloadId = Date.now() + Math.random();
-
- const messageData = {
- livePreviewEditEnabled: true,
- useImage: true,
- imageUrl: imageUrl,
- filename: filename,
- extnName: extnName,
- element: this.element,
- tagId: Number(tagId),
- downloadLocation: downloadLocation,
- downloadId: downloadId
- };
+ // determine whether an event should be processed for Live Development
+ function _validEvent(event) {
+ if (window.navigator.platform.substr(0, 3) === "Mac") {
+ // Mac
+ return event.metaKey;
+ }
+ // Windows
+ return event.ctrlKey;
+ }
- // if this is a local file we need some more data before sending it to the editor
- if (isLocalFile) {
- messageData.isLocalFile = true;
- // Convert data URL to binary data array for local files
- const byteCharacters = atob(imageUrl.split(',')[1]);
- const byteNumbers = new Array(byteCharacters.length);
- for (let i = 0; i < byteCharacters.length; i++) {
- byteNumbers[i] = byteCharacters.charCodeAt(i);
- }
- messageData.imageData = byteNumbers;
+ // helper function to check if an element is inside the HEAD tag
+ // we need this because we don't wanna trigger the element highlights on head tag and its children,
+ // except for ${content}`;
- window.document.body.appendChild(toast);
-
- // Auto-dismiss after 3 seconds
- _toastTimeout = setTimeout(() => {
- if (toast && toast.parentNode) {
- toast.remove();
- }
- _toastTimeout = null;
- }, 3000);
- }
-
- /**
- * this function is to dismiss the toast message
- * and clear its timeout (if any)
- */
- function dismissToastMessage() {
- const toastMessage = window.document.getElementById('phoenix-toast-notification');
- if (toastMessage) {
- toastMessage.remove();
- }
- if (_toastTimeout) {
- clearTimeout(_toastTimeout);
- }
- _toastTimeout = null;
- }
-
/**
* Helper function to cleanup previously clicked element highlighting and state
*/
@@ -4650,6 +1278,16 @@ function RemoteFunctions(config = {}) {
if (_hoverHighlight) {
_hoverHighlight.clear();
}
+ if (_clickHighlight) {
+ _clickHighlight.clear();
+ }
+
+ // Notify handlers about cleanup
+ getAllToolHandlers().forEach(handler => {
+ if (handler.onElementCleanup) {
+ handler.onElementCleanup();
+ }
+ });
previouslyClickedElement = null;
}
@@ -4660,223 +1298,92 @@ function RemoteFunctions(config = {}) {
* Called when user presses Esc key, clicks on HTML/Body tags, or other dismissal events
*/
function dismissUIAndCleanupState() {
- dismissAllUIBoxes();
+ getAllToolHandlers().forEach(handler => (handler.dismiss && handler.dismiss())); // to dismiss all UI boxes
cleanupPreviousElementState();
}
- /**
- * this is a hard reset function, it resets every live preview edit thing, whether it be UI boxes
- * highlighting, any timers or anything
- */
- function resetState() {
- _stopAutoScroll();
-
- if (_hoverHighlight) {
- _hoverHighlight.clear();
- _hoverHighlight = null;
- }
- if (_clickHighlight) {
- _clickHighlight.clear();
- _clickHighlight = null;
- }
-
- dismissUIAndCleanupState();
-
- const allElements = window.document.querySelectorAll("[data-brackets-id]");
- for (let i = 0; i < allElements.length; i++) {
- if (allElements[i]._originalBackgroundColor !== undefined) {
- clearElementBackground(allElements[i]);
- }
- }
-
- if (config.isProUser) {
- _hoverHighlight = new Highlight("#c8f9c5", true);
- _clickHighlight = new Highlight("#cfc", true);
- }
- }
-
-
- /**
- * This function is responsible to move the cursor to the end of the text content when we start editing
- * @param {DOMElement} element
- */
- function moveCursorToEnd(selection, element) {
- const range = document.createRange();
- range.selectNodeContents(element);
- range.collapse(false);
- selection.removeAllRanges();
- selection.addRange(range);
- }
-
- // Function to handle direct editing of elements in the live preview
- function startEditing(element) {
- if (!isElementEditable(element)) {
- return;
- }
-
- // Make the element editable
- element.setAttribute("contenteditable", "true");
- element.focus();
- // to compare with the new text content, if same we don't make any changes in the editor area
- const oldContent = element.textContent;
-
- // Move cursor to end if no existing selection
- const selection = window.getSelection();
- if (selection.rangeCount === 0 || selection.isCollapsed) {
- moveCursorToEnd(selection, element);
- }
-
- dismissUIAndCleanupState();
-
- // flag to check if escape is pressed, if pressed we prevent onBlur from handling it as keydown already handles
- let isEscapePressed = false;
-
- function onBlur() {
- // Small delay so that keydown can handle things first
- setTimeout(() => {
- if (isEscapePressed) {
- isEscapePressed = false;
- finishEditingCleanup(element);
- return;
- }
-
- const newContent = element.textContent;
- if (oldContent !== newContent) {
- finishEditing(element);
- } else { // if same content, we just cleanup things
- finishEditingCleanup(element);
- }
- }, 10);
- }
-
- function onKeyDown(event) {
- if (event.key === "Escape") {
- isEscapePressed = true;
- // Cancel editing
- event.preventDefault();
- const newContent = element.textContent;
- if (oldContent !== newContent) {
- finishEditing(element, false); // false means that the edit operation was cancelled
- } else { // no content change we can avoid sending details to the editor
- finishEditingCleanup(element);
- }
- } else if (event.key === "Enter" && !event.shiftKey) {
- isEscapePressed = false;
- // Finish editing on Enter (unless Shift is held)
- event.preventDefault();
- finishEditing(element);
- } else if ((event.key === " " || event.key === "Spacebar") && element.tagName.toLowerCase() === 'button') {
- event.preventDefault();
- document.execCommand("insertText", false, " ");
- }
- }
-
- element.addEventListener("blur", onBlur);
- element.addEventListener("keydown", onKeyDown);
-
- // Store the event listeners for later removal
- element._editListeners = {
- blur: onBlur,
- keydown: onKeyDown
- };
- }
-
- function finishEditingCleanup(element) {
- if (!isElementEditable(element) || !element.hasAttribute("contenteditable")) {
- return;
- }
-
- // Remove contenteditable attribute
- element.removeAttribute("contenteditable");
- dismissUIAndCleanupState();
-
- // Remove event listeners
- if (element._editListeners) {
- element.removeEventListener("blur", element._editListeners.blur);
- element.removeEventListener("keydown", element._editListeners.keydown);
- delete element._editListeners;
- }
- }
-
- // Function to finish editing and apply changes
- // isEditSuccessful: this is a boolean value, defaults to true. false only when the edit operation is cancelled
- function finishEditing(element, isEditSuccessful = true) {
- finishEditingCleanup(element);
-
- const tagId = element.getAttribute("data-brackets-id");
- window._Brackets_MessageBroker.send({
- livePreviewEditEnabled: true,
- livePreviewTextEdit: true,
- element: element,
- newContent: element.outerHTML,
- tagId: Number(tagId),
- isEditSuccessful: isEditSuccessful
- });
- }
-
// init
_editHandler = new DOMEditHandler(window.document);
function registerHandlers() {
- // clear previous highlighting
- if (_hoverHighlight) {
- _hoverHighlight.clear();
- _hoverHighlight = null;
- }
- if (_clickHighlight) {
- _clickHighlight.clear();
- _clickHighlight = null;
- }
-
- // Always remove existing listeners first to avoid duplicates
- window.document.removeEventListener("mouseover", onElementHover);
- window.document.removeEventListener("mouseout", onElementHoverOut);
- window.document.removeEventListener("click", onClick);
- window.document.removeEventListener("dblclick", onDoubleClick);
- window.document.removeEventListener("dragover", onDragOver);
- window.document.removeEventListener("drop", onDrop);
- window.document.removeEventListener("dragleave", onDragLeave);
+ hideHighlight(); // clear previous highlighting
+ disableHoverListeners(); // Always remove existing listeners first to avoid duplicates
window.document.removeEventListener("keydown", onKeyDown);
+ getAllToolHandlers().forEach(handler => {
+ if (handler.unregisterInteractionBlocker) {
+ handler.unregisterInteractionBlocker();
+ }
+ });
- if (config.isProUser) {
+ if (config.mode === 'edit') {
// Initialize hover highlight with Chrome-like colors
_hoverHighlight = new Highlight("#c8f9c5", true); // Green similar to Chrome's padding color
// Initialize click highlight with animation
_clickHighlight = new Highlight("#cfc", true); // Light green for click highlight
- window.document.addEventListener("mouseover", onElementHover);
- window.document.addEventListener("mouseout", onElementHoverOut);
- window.document.addEventListener("click", onClick);
- window.document.addEventListener("dblclick", onDoubleClick);
- window.document.addEventListener("dragover", onDragOver);
- window.document.addEventListener("drop", onDrop);
- window.document.addEventListener("dragleave", onDragLeave);
+ // register the event handlers
+ enableHoverListeners();
window.document.addEventListener("keydown", onKeyDown);
+
+ // this is to block all the interactions of the user created elements
+ // so that lets say user created link doesn't redirect in edit mode
+ getAllToolHandlers().forEach(handler => {
+ if (handler.registerInteractionBlocker) {
+ handler.registerInteractionBlocker();
+ }
+ });
} else {
// Clean up any existing UI when edit features are disabled
dismissUIAndCleanupState();
}
+ getAllToolHandlers().forEach(handler => {
+ if (handler.reRegisterEventHandlers) {
+ handler.reRegisterEventHandlers();
+ }
+ });
}
- registerHandlers();
+ function _escapeKeyPressInEditor() {
+ enableHoverListeners(); // so that if hover lock is there it will get cleared
+ dismissUIAndCleanupState();
+ getAllToolHandlers().forEach(handler => {
+ if (handler.handleEscapePressFromEditor) {
+ handler.handleEscapePressFromEditor();
+ }
+ });
+ }
- return {
- "DOMEditHandler" : DOMEditHandler,
- "hideHighlight" : hideHighlight,
- "highlight" : highlight,
- "highlightRule" : highlightRule,
- "redrawHighlights" : redrawHighlights,
- "redrawEverything" : redrawEverything,
- "applyDOMEdits" : applyDOMEdits,
- "updateConfig" : updateConfig,
- "startEditing" : startEditing,
- "finishEditing" : finishEditing,
- "hasVisibleLivePreviewBoxes" : hasVisibleLivePreviewBoxes,
- "dismissUIAndCleanupState" : dismissUIAndCleanupState,
- "resetState" : resetState,
- "enableHoverListeners" : enableHoverListeners,
- "registerHandlers" : registerHandlers,
- "handleDownloadEvent" : handleDownloadEvent
+ // we need to refresh the config once the load is completed
+ // this is important because messageBroker gets ready for use only when load fires
+ window.addEventListener('load', function() {
+ window._Brackets_MessageBroker.send({
+ requestConfigRefresh: true
+ });
+ });
+
+ let customReturns = {};
+ // only apis that needs to be called from phoenix js layer should be customReturns. APis that are shared within
+ // the remote function context only should not be in customReturns and should be in
+ // either SHARED_STATE for state vars, GLOBALS for global vars, or LivePreviewView for shared functions.
+ customReturns = { // we have to do this else the minifier will strip the customReturns variable
+ ...customReturns,
+ "DOMEditHandler": DOMEditHandler,
+ "hideHighlight": hideHighlight,
+ "highlight": highlight,
+ "highlightRule": highlightRule,
+ "redrawHighlights": redrawHighlights,
+ "redrawEverything": redrawEverything,
+ "applyDOMEdits": applyDOMEdits,
+ "updateConfig": updateConfig,
+ "dismissUIAndCleanupState": dismissUIAndCleanupState,
+ "escapeKeyPressInEditor": _escapeKeyPressInEditor,
+ "getMode": function() { return config.mode; }
};
+
+ // the below code comment is replaced by added scripts for extensibility
+ // DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_SCRIPTS
+
+ registerHandlers();
+ return customReturns;
}
diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js
index 8d8598addf..b84e15290e 100644
--- a/src/LiveDevelopment/LiveDevMultiBrowser.js
+++ b/src/LiveDevelopment/LiveDevMultiBrowser.js
@@ -87,6 +87,7 @@ define(function (require, exports, module) {
LivePreviewTransport = require("LiveDevelopment/MultiBrowserImpl/transports/LivePreviewTransport"),
LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"),
Metrics = require("utils/Metrics"),
+ WorkspaceManager = require("view/WorkspaceManager"),
PageLoaderWorkerScript = require("text!LiveDevelopment/BrowserScripts/pageLoaderWorker.js");
// Documents
@@ -128,6 +129,8 @@ define(function (require, exports, module) {
*/
var _server;
+ let _config = {};
+
/**
* @private
* Determine which live document class should be used for a given document
@@ -375,6 +378,18 @@ define(function (require, exports, module) {
);
}
+ function _updateVirtualServerScripts() {
+ if(!_server || !_liveDocument || !_liveDocument.doc){
+ return;
+ }
+ _server.addVirtualContentAtPath(
+ `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME}`,
+ _protocol.getRemoteScriptContents());
+ _server.addVirtualContentAtPath(
+ `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME}`,
+ PageLoaderWorkerScript);
+ }
+
/**
* @private
* Creates the main live document for a given HTML document and notifies the server it exists.
@@ -389,12 +404,7 @@ define(function (require, exports, module) {
return;
}
_server.add(_liveDocument);
- _server.addVirtualContentAtPath(
- `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME}`,
- _protocol.getRemoteScriptContents());
- _server.addVirtualContentAtPath(
- `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME}`,
- PageLoaderWorkerScript);
+ _updateVirtualServerScripts();
}
@@ -435,7 +445,6 @@ define(function (require, exports, module) {
const urlString = `${url.origin}${url.pathname}`;
if (_liveDocument && urlString === _resolveUrl(_liveDocument.doc.file.fullPath)) {
_setStatus(STATUS_ACTIVE);
- resetLPEditState();
}
}
Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "connect",
@@ -650,7 +659,7 @@ define(function (require, exports, module) {
* Initialize the LiveDevelopment module.
*/
function init(config) {
- exports.config = config;
+ _config = config;
MainViewManager
.on("currentFileChange", _onFileChange);
DocumentManager
@@ -701,52 +710,46 @@ define(function (require, exports, module) {
}
/**
- * Check if live preview boxes are currently visible
+ * Update configuration in the remote browser
*/
- function hasVisibleLivePreviewBoxes() {
- if (_protocol) {
- return _protocol.evaluate("_LD.hasVisibleLivePreviewBoxes()");
- }
- return false;
+ function updateConfig(config) {
+ _config = config;
+ _updateVirtualServerScripts();
+ refreshConfig();
}
/**
- * Dismiss live preview boxes like info box, options box, AI box
+ * Refresh all live previews with existing configuration in the remote browser
*/
- function dismissLivePreviewBoxes() {
+ function refreshConfig() {
if (_protocol) {
- _protocol.evaluate("_LD.enableHoverListeners()"); // so that if hover lock is there it will get cleared
- _protocol.evaluate("_LD.dismissUIAndCleanupState()");
+ _protocol.evaluate("_LD.updateConfig(" + JSON.stringify(_config) + ")");
}
}
- /**
- * Register event handlers in the remote browser for live preview functionality
- */
- function registerHandlers() {
- if (_protocol) {
- _protocol.evaluate("_LD.registerHandlers()");
- }
- }
/**
- * Update configuration in the remote browser
+ * this function handles escape key for live preview to hide boxes if they are visible
+ * @param {Event} event
*/
- function updateConfig(configJSON) {
- if (_protocol) {
- _protocol.evaluate("_LD.updateConfig(" + JSON.stringify(configJSON) + ")");
+ function _handleLivePreviewEscapeKey(event) {
+ const currLiveDoc = getCurrentLiveDoc();
+ if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
+ currLiveDoc.protocol.evaluate("_LD.escapeKeyPressInEditor()");
}
+ // returning false to let the editor also handle the escape key
+ return false;
}
+ // allow live preview to handle escape key event
+ // Escape is mainly to hide boxes if they are visible
+ WorkspaceManager.addEscapeKeyEventHandler("livePreview", _handleLivePreviewEscapeKey);
/**
- * this function is to completely reset the live preview edit
- * its done so that when live preview is opened/popped out, we can re-update the config so that
- * there are no stale markers and edit works perfectly
+ * gets configuration used to set in the remote browser
*/
- function resetLPEditState() {
- if (_protocol) {
- _protocol.evaluate("_LD.resetState()");
- }
+ function getConfig() {
+ // not using structured clone as it's not fast for small objects
+ return JSON.parse(JSON.stringify(_config || {}));
}
/**
@@ -815,10 +818,9 @@ define(function (require, exports, module) {
exports.showHighlight = showHighlight;
exports.hideHighlight = hideHighlight;
exports.redrawHighlight = redrawHighlight;
- exports.hasVisibleLivePreviewBoxes = hasVisibleLivePreviewBoxes;
- exports.dismissLivePreviewBoxes = dismissLivePreviewBoxes;
- exports.registerHandlers = registerHandlers;
+ exports.getConfig = getConfig;
exports.updateConfig = updateConfig;
+ exports.refreshConfig = refreshConfig;
exports.init = init;
exports.isActive = isActive;
exports.setLivePreviewPinned= setLivePreviewPinned;
diff --git a/src/LiveDevelopment/LivePreviewConstants.js b/src/LiveDevelopment/LivePreviewConstants.js
new file mode 100644
index 0000000000..cc2a521ef2
--- /dev/null
+++ b/src/LiveDevelopment/LivePreviewConstants.js
@@ -0,0 +1,44 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2021 - present core.ai . All rights reserved.
+ * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/*global less, Phoenix */
+
+/**
+ * main integrates LiveDevelopment into Brackets
+ *
+ * This module creates two menu items:
+ *
+ * "Go Live": open or close a Live Development session and visualize the status
+ * "Highlight": toggle source highlighting
+ */
+define(function main(require, exports, module) {
+ exports.LIVE_PREVIEW_MODE = "preview";
+ exports.LIVE_HIGHLIGHT_MODE = "highlight";
+ exports.LIVE_EDIT_MODE = "edit";
+
+ exports.PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode";
+
+ exports.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = "livePreviewElementHighlights";
+ exports.HIGHLIGHT_HOVER = "hover";
+ exports.HIGHLIGHT_CLICK = "click";
+
+ exports.PREFERENCE_SHOW_RULER_LINES = "livePreviewShowMeasurements";
+});
diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js
index 57b63570e3..cb0d7a554a 100644
--- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js
+++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js
@@ -22,7 +22,8 @@
define(function (require, exports, module) {
- var EditorManager = require("editor/EditorManager"),
+ const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"),
+ EditorManager = require("editor/EditorManager"),
EventDispatcher = require("utils/EventDispatcher"),
PreferencesManager = require("preferences/PreferencesManager"),
_ = require("thirdparty/lodash");
@@ -34,6 +35,16 @@ define(function (require, exports, module) {
*/
var SYNC_ERROR_CLASS = "live-preview-sync-error";
+ function _simpleHash(str) {
+ let hash = 5381;
+ for (let i = 0; i < str.length; ) {
+ // eslint-disable-next-line no-bitwise
+ hash = (hash * 33) ^ str.charCodeAt(i++);
+ }
+ // eslint-disable-next-line no-bitwise
+ return hash >>> 0;
+ }
+
/**
* @constructor
* Base class for managing the connection between a live editor and the browser. Provides functions
@@ -62,16 +73,11 @@ define(function (require, exports, module) {
this._onActiveEditorChange = this._onActiveEditorChange.bind(this);
this._onCursorActivity = this._onCursorActivity.bind(this);
- this._onHighlightPrefChange = this._onHighlightPrefChange.bind(this);
-
- EditorManager.on(`activeEditorChange.LiveDocument-${this.doc.file.fullPath}`, this._onActiveEditorChange);
- PreferencesManager.stateManager.getPreference("livedevHighlight")
- .on(`change.LiveDocument-${this.doc.file.fullPath}`, this._onHighlightPrefChange);
-
- // Redraw highlights when window gets focus. This ensures that the highlights
- // will be in sync with any DOM changes that may have occurred.
- $(window).focus(this._onHighlightPrefChange);
+ // we cant use file paths for event registration - paths may have spaces(treated as an event list separator)
+ this.fileHashForEvents = _simpleHash(this.doc.file.fullPath);
+ EditorManager.off(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`);
+ EditorManager.on(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`, this._onActiveEditorChange);
if (editor) {
// Attach now
@@ -85,12 +91,9 @@ define(function (require, exports, module) {
* Closes the live document, terminating its connection to the browser.
*/
LiveDocument.prototype.close = function () {
-
+ EditorManager.off(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`);
this._clearErrorDisplay();
this._detachFromEditor();
- EditorManager.off(`activeEditorChange.LiveDocument-${this.doc.file.fullPath}`);
- PreferencesManager.stateManager.getPreference("livedevHighlight")
- .off(`change.LiveDocument-${this.doc.file.fullPath}`);
};
/**
@@ -126,18 +129,6 @@ define(function (require, exports, module) {
};
};
- /**
- * @private
- * Handles changes to the "Live Highlight" preference, switching it on/off in the browser as appropriate.
- */
- LiveDocument.prototype._onHighlightPrefChange = function () {
- if (this.isHighlightEnabled()) {
- this.updateHighlight();
- } else {
- this.hideHighlight();
- }
- };
-
/**
* @private
* Handles when the active editor changes, attaching to the new editor if it's for the current document.
@@ -163,6 +154,7 @@ define(function (require, exports, module) {
if (this.editor) {
this.setInstrumentationEnabled(true, true);
+ this.editor.off("cursorActivity", this._onCursorActivity);
this.editor.on("cursorActivity", this._onCursorActivity);
this.updateHighlight();
}
@@ -262,7 +254,7 @@ define(function (require, exports, module) {
* @return {boolean}
*/
LiveDocument.prototype.isHighlightEnabled = function () {
- return PreferencesManager.getViewState("livedevHighlight");
+ return PreferencesManager.get(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE) !== CONSTANTS.LIVE_PREVIEW_MODE;
};
/**
diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js
index 0d660ec2e2..cfa52533f6 100644
--- a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js
+++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js
@@ -42,7 +42,8 @@ define(function (require, exports, module) {
const EventDispatcher = require("utils/EventDispatcher");
// Text of the script we'll inject into the browser that handles protocol requests.
- const LiveDevProtocolRemote = require("text!LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js"),
+ const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"),
+ LiveDevProtocolRemote = require("text!LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js"),
DocumentObserver = require("text!LiveDevelopment/BrowserScripts/DocumentObserver.js"),
LanguageManager = require("language/LanguageManager"),
RemoteFunctions = require("text!LiveDevelopment/BrowserScripts/RemoteFunctions.js"),
@@ -52,8 +53,7 @@ define(function (require, exports, module) {
HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"),
StringUtils = require("utils/StringUtils"),
FileViewController = require("project/FileViewController"),
- MainViewManager = require("view/MainViewManager"),
- LivePreviewEdit = require("LiveDevelopment/LivePreviewEdit");
+ MainViewManager = require("view/MainViewManager");
const LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = `phoenix_live_preview_scripts_instrumented_${StringUtils.randomString(8)}.js`;
const LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = `pageLoaderWorker_${StringUtils.randomString(8)}.js`;
@@ -89,6 +89,16 @@ define(function (require, exports, module) {
*/
var _responseDeferreds = {};
+ let _remoteFunctionProvider = null;
+
+ /**
+ * The callback fn must return a single text string that will be used as remote function script
+ * @param callbackFn
+ */
+ function setCustomRemoteFunctionProvider(callbackFn) {
+ _remoteFunctionProvider = callbackFn;
+ }
+
/**
* Returns an array of the client IDs that are being managed by this live document.
* @return {Array.}
@@ -149,9 +159,9 @@ define(function (require, exports, module) {
}
function _tagSelectedInLivePreview(tagId, nodeName, contentEditable, allSelectors) {
- const highlightPref = PreferencesManager.getViewState("livedevHighlight");
- if(!highlightPref){
- // live preview highlight and reverse highlight feature is disabled
+ const livePreviewMode = PreferencesManager.get(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE);
+ if(livePreviewMode === CONSTANTS.LIVE_PREVIEW_MODE){
+ // hilights are enabled only in edit and highlight mode
return;
}
const liveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(),
@@ -203,6 +213,11 @@ define(function (require, exports, module) {
// for a fraction of a second. so a size of 1000 should be more than enough.
});
+ let _livePreviewMessageHandler;
+ function setLivePreviewMessageHandler(handler) {
+ _livePreviewMessageHandler = handler;
+ }
+
/**
* @private
* Handles a message received from the remote protocol handler via the transport.
@@ -219,16 +234,23 @@ define(function (require, exports, module) {
* only processed once and not from any reflections.
*/
function _receive(clientId, msgStr, messageID) {
- var msg = JSON.parse(msgStr),
- event = msg.method || "event",
- deferred;
+ const msg = JSON.parse(msgStr),
+ event = msg.method || "event";
+ let deferred;
if(messageID && processedMessageIDs.has(messageID)){
return; // this message is already processed.
} else if (messageID) {
processedMessageIDs.set(messageID, true);
}
- if (msg.livePreviewEditEnabled) {
- LivePreviewEdit.handleLivePreviewEditOperation(msg);
+ if(_livePreviewMessageHandler) {
+ let preventDefault = _livePreviewMessageHandler(msg);
+ if(preventDefault){
+ return;
+ }
+ }
+ if(msg.requestConfigRefresh){
+ LiveDevMultiBrowser.refreshConfig();
+ return;
}
if (msg.id) {
@@ -263,6 +285,11 @@ define(function (require, exports, module) {
function _send(msg, clients) {
var id = _nextMsgId++,
result = new $.Deferred();
+ if(!_transport){
+ console.error("Cannot send message before live preview transport initialised");
+ result.reject();
+ return result.promise();
+ }
// broadcast if there are no specific clients
clients = clients || getConnectionIds();
@@ -331,7 +358,6 @@ define(function (require, exports, module) {
_transport.start();
}
-
/**
* Returns a script that should be injected into the HTML that's launched in the
* browser in order to implement remote commands that handle protocol requests.
@@ -343,7 +369,12 @@ define(function (require, exports, module) {
// Inject DocumentObserver into the browser (tracks related documents)
script += DocumentObserver;
// Inject remote functions into the browser.
- script += "\nwindow._LD=(" + RemoteFunctions + "(" + JSON.stringify(LiveDevMultiBrowser.config) + "))";
+ if(_remoteFunctionProvider){
+ script += _remoteFunctionProvider();
+ } else {
+ script += "\nwindow._LD=(" + RemoteFunctions +
+ "(" + JSON.stringify(LiveDevMultiBrowser.getConfig()) + "))";
+ }
return "\n" + script + "\n";
}
@@ -481,6 +512,8 @@ define(function (require, exports, module) {
exports.close = close;
exports.getConnectionIds = getConnectionIds;
exports.closeAllConnections = closeAllConnections;
+ exports.setLivePreviewMessageHandler = setLivePreviewMessageHandler;
+ exports.setCustomRemoteFunctionProvider = setCustomRemoteFunctionProvider;
exports.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME;
exports.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME;
exports.EVENT_LIVE_PREVIEW_CLICKED = EVENT_LIVE_PREVIEW_CLICKED;
diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js
index ad343d5880..fd6f127b98 100644
--- a/src/LiveDevelopment/main.js
+++ b/src/LiveDevelopment/main.js
@@ -19,7 +19,7 @@
*
*/
-/*global less, Phoenix */
+/*global less */
/**
* main integrates LiveDevelopment into Brackets
@@ -32,7 +32,8 @@
define(function main(require, exports, module) {
- const Commands = require("command/Commands"),
+ const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"),
+ Commands = require("command/Commands"),
AppInit = require("utils/AppInit"),
MultiBrowserLiveDev = require("LiveDevelopment/LiveDevMultiBrowser"),
LivePreviewTransport = require("LiveDevelopment/MultiBrowserImpl/transports/LivePreviewTransport"),
@@ -43,24 +44,30 @@ define(function main(require, exports, module) {
Strings = require("strings"),
ExtensionUtils = require("utils/ExtensionUtils"),
StringUtils = require("utils/StringUtils"),
- EventDispatcher = require("utils/EventDispatcher"),
- WorkspaceManager = require("view/WorkspaceManager"),
- EditorManager = require("editor/EditorManager");
+ EventDispatcher = require("utils/EventDispatcher");
-
- const KernalModeTrust = window.KernalModeTrust;
+ const LIVE_PREVIEW_MODE = CONSTANTS.LIVE_PREVIEW_MODE,
+ LIVE_HIGHLIGHT_MODE = CONSTANTS.LIVE_HIGHLIGHT_MODE,
+ LIVE_EDIT_MODE = CONSTANTS.LIVE_EDIT_MODE;
// this will later be assigned its correct values once entitlementsManager loads
- let isProUser = false;
- let isFreeTrialUser = false;
+ let hasLiveEditCapability = false;
- const EVENT_LIVE_HIGHLIGHT_PREF_CHANGED = "liveHighlightPrefChange";
- const PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode";
+ const PREFERENCE_LIVE_PREVIEW_MODE = CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE;
// state manager key to track image gallery selected state, by default we keep this as selected
// if this is true we show the image gallery when an image element is clicked
const IMAGE_GALLERY_STATE = "livePreview.imageGallery.state";
+ PreferencesManager.definePreference(PREFERENCE_LIVE_PREVIEW_MODE, "string", LIVE_HIGHLIGHT_MODE, {
+ description: StringUtils.format(
+ Strings.LIVE_PREVIEW_MODE_PREFERENCE, LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE, LIVE_EDIT_MODE),
+ values: [LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE, LIVE_EDIT_MODE]
+ }).on("change", function () {
+ // when mode changes we update the config mode and notify remoteFunctions so that it can get updated
+ _previewModeUpdated();
+ });
+
/**
* get the image gallery state from StateManager
* @returns {boolean} true (default)
@@ -78,83 +85,25 @@ define(function main(require, exports, module) {
StateManager.set(IMAGE_GALLERY_STATE, state);
// update the config with the new state
+ const config = MultiBrowserLiveDev.getConfig();
config.imageGalleryState = state;
- if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) {
- MultiBrowserLiveDev.updateConfig(JSON.stringify(config));
- }
+ MultiBrowserLiveDev.updateConfig(config);
}
- var params = new UrlParams();
- var config = {
- experimental: false, // enable experimental features
- debug: true, // enable debug output and helpers
- highlight: true, // enable highlighting?
- highlightConfig: { // the highlight configuration for the Inspector
- borderColor: {r: 255, g: 229, b: 153, a: 0.66},
- contentColor: {r: 111, g: 168, b: 220, a: 0.55},
- marginColor: {r: 246, g: 178, b: 107, a: 0.66},
- paddingColor: {r: 147, g: 196, b: 125, a: 0.66},
- showInfo: true
- },
- isProUser: isProUser,
- elemHighlights: "hover", // default value, this will get updated when the extension loads
- imageGalleryState: _getImageGalleryState(), // image gallery selected state
- // this strings are used in RemoteFunctions.js
- // we need to pass this through config as remoteFunctions runs in browser context and cannot
- // directly reference Strings file
- strings: {
- selectParent: Strings.LIVE_DEV_MORE_OPTIONS_SELECT_PARENT,
- editText: Strings.LIVE_DEV_MORE_OPTIONS_EDIT_TEXT,
- duplicate: Strings.LIVE_DEV_MORE_OPTIONS_DUPLICATE,
- delete: Strings.LIVE_DEV_MORE_OPTIONS_DELETE,
- ai: Strings.LIVE_DEV_MORE_OPTIONS_AI,
- imageGallery: Strings.LIVE_DEV_MORE_OPTIONS_IMAGE_GALLERY,
- aiPromptPlaceholder: Strings.LIVE_DEV_AI_PROMPT_PLACEHOLDER,
- imageGalleryUseImage: Strings.LIVE_DEV_IMAGE_GALLERY_USE_IMAGE,
- imageGallerySelectDownloadFolder: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_DOWNLOAD_FOLDER,
- imageGallerySearchPlaceholder: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER,
- imageGallerySearchButton: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_BUTTON,
- imageGalleryLoadingInitial: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_INITIAL,
- imageGalleryLoadingMore: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_MORE,
- imageGalleryNoImages: Strings.LIVE_DEV_IMAGE_GALLERY_NO_IMAGES,
- imageGalleryLoadError: Strings.LIVE_DEV_IMAGE_GALLERY_LOAD_ERROR,
- imageGalleryClose: Strings.LIVE_DEV_IMAGE_GALLERY_CLOSE,
- imageGallerySelectFromComputer: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER,
- imageGallerySelectFromComputerTooltip: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER_TOOLTIP,
- toastNotEditable: Strings.LIVE_DEV_TOAST_NOT_EDITABLE
- }
+ let params = new UrlParams();
+ const defaultConfig = {
+ mode: LIVE_HIGHLIGHT_MODE, // will be updated when we fetch entitlements
+ elemHighlights: CONSTANTS.HIGHLIGHT_HOVER, // default value, this will get updated when the extension loads
+ showRulerLines: false, // default value, this will get updated when the extension loads
+ imageGalleryState: _getImageGalleryState() // image gallery selected state
};
+
// Status labels/styles are ordered: error, not connected, progress1, progress2, connected.
var _status,
_allStatusStyles = ["warning", "info", "success", "out-of-sync", "sync-error"].join(" ");
var _$btnGoLive; // reference to the GoLive button
- var prefs = PreferencesManager.getExtensionPrefs("livedev");
-
- // "livedev.remoteHighlight" preference
- var PREF_REMOTEHIGHLIGHT = "remoteHighlight";
- var remoteHighlightPref = prefs.definePreference(PREF_REMOTEHIGHLIGHT, "object", {
- animateStartValue: {
- "background-color": "rgba(0, 162, 255, 0.5)",
- "opacity": 0
- },
- animateEndValue: {
- "background-color": "rgba(0, 162, 255, 0)",
- "opacity": 0.6
- },
- "paddingStyling": {
- "background-color": "rgba(200, 249, 197, 0.7)"
- },
- "marginStyling": {
- "background-color": "rgba(249, 204, 157, 0.7)"
- },
- "borderColor": "rgba(200, 249, 197, 0.85)",
- "showPaddingMargin": true
- }, {
- description: Strings.DESCRIPTION_LIVE_DEV_HIGHLIGHT_SETTINGS
- });
-
/** Load Live Development LESS Style */
function _loadStyles() {
var lessText = require("text!LiveDevelopment/main.less");
@@ -269,105 +218,31 @@ define(function main(require, exports, module) {
// Add checkmark when status is STATUS_ACTIVE; otherwise remove it
CommandManager.get(Commands.FILE_LIVE_FILE_PREVIEW)
.setChecked(status === MultiBrowserLiveDev.STATUS_ACTIVE);
- CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT)
- .setEnabled(status === MultiBrowserLiveDev.STATUS_ACTIVE);
});
}
- function _updateHighlightCheckmark() {
- CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).setChecked(config.highlight);
- exports.trigger(EVENT_LIVE_HIGHLIGHT_PREF_CHANGED, config.highlight);
- }
-
- function togglePreviewHighlight() {
- config.highlight = !config.highlight;
- _updateHighlightCheckmark();
- if (config.highlight) {
- MultiBrowserLiveDev.showHighlight();
- } else {
- MultiBrowserLiveDev.hideHighlight();
- }
- PreferencesManager.setViewState("livedevHighlight", config.highlight);
- }
-
- /** Setup window references to useful LiveDevelopment modules */
- function _setupDebugHelpers() {
- window.report = function report(params) { window.params = params; console.info(params); };
- }
-
- /** force reload the live preview currently only with shortcut ctrl-shift-R */
- function _handleReloadLivePreviewCommand() {
- if (MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) {
- MultiBrowserLiveDev.reload();
- }
- }
-
/**
- * this function handles escape key for live preview to hide boxes if they are visible
- * @param {Event} event
+ * Internal api used to update live edit capability status as entitlements changes. calling this will update the UI
+ * but will not functionally enable live editing capabilities as that are dependent on entitlements framework.
+ * @param newCapability
+ * @private
*/
- function _handleLivePreviewEscapeKey(event) {
- // we only handle the escape keypress for live preview when its active
- if (MultiBrowserLiveDev.status === MultiBrowserLiveDev.STATUS_ACTIVE) {
- MultiBrowserLiveDev.dismissLivePreviewBoxes();
- }
- // returning false to let the editor also handle the escape key
- return false;
- }
-
- // default mode means on first load for pro user we have edit mode
- // for free user we have highlight mode
- function _getDefaultMode() {
- return isProUser ? "edit" : "highlight";
- }
-
- // to set that mode in the preferences
- function _initializeMode() {
- if (isFreeTrialUser) {
- PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "edit");
- return;
- }
-
- const savedMode = PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode();
-
- if (savedMode === "highlight" && isProUser) {
- PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "edit");
- } else if (savedMode === "edit" && !isProUser) {
- PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight");
- }
- }
-
- // this is called everytime there is a change in entitlements
- async function _updateProUserStatus() {
- if (!KernalModeTrust) {
- return;
- }
-
- try {
- const entitlement = await KernalModeTrust.EntitlementsManager.getLiveEditEntitlement();
-
- isProUser = entitlement.activated;
- isFreeTrialUser = await KernalModeTrust.EntitlementsManager.isInProTrial();
-
- config.isProUser = isProUser;
- exports.isProUser = isProUser;
- exports.isFreeTrialUser = isFreeTrialUser;
-
- _initializeMode();
-
- if (MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) {
- MultiBrowserLiveDev.updateConfig(JSON.stringify(config));
- MultiBrowserLiveDev.registerHandlers();
+ function _liveEditCapabilityChanged(newCapability) {
+ if(newCapability !== hasLiveEditCapability){
+ hasLiveEditCapability = newCapability;
+ if(!hasLiveEditCapability && getCurrentMode() === LIVE_EDIT_MODE){
+ // downgraded, so we need to disable live edit mode
+ setMode(LIVE_HIGHLIGHT_MODE);
+ } else if(hasLiveEditCapability) {
+ // this means that the user has switched to pro-account and we need to enable live edit mode
+ // as user may have just logged in with a pro-capable account/upgraded to pro.
+ setMode(LIVE_EDIT_MODE);
}
- } catch (error) {
- console.error("Error updating pro user status:", error);
- isProUser = false;
- isFreeTrialUser = false;
}
}
function setMode(mode) {
- if (mode === "edit" && !exports.isProUser) {
+ if (mode === LIVE_EDIT_MODE && !hasLiveEditCapability) {
return false;
}
PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, mode);
@@ -375,31 +250,21 @@ define(function main(require, exports, module) {
}
function getCurrentMode() {
- return PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode();
+ return PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE);
+ }
+
+ function isInPreviewMode() {
+ return getCurrentMode() === LIVE_PREVIEW_MODE;
}
/** Initialize LiveDevelopment */
AppInit.appReady(function () {
params.parse();
- config.remoteHighlight = prefs.get(PREF_REMOTEHIGHLIGHT);
-
- // init experimental multi-browser implementation
- // it can be enable by setting 'livedev.multibrowser' preference to true.
- // It has to be initiated at this point in case of dynamically switching
- // by changing the preference value.
+ const config = MultiBrowserLiveDev.getConfig() || defaultConfig;
+ config.mode = getCurrentMode();
MultiBrowserLiveDev.init(config);
_loadStyles();
- _updateHighlightCheckmark();
-
- // init pro user status and listen for changes
- if (KernalModeTrust) {
- _updateProUserStatus();
- KernalModeTrust.EntitlementsManager.on(
- KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED,
- _updateProUserStatus
- );
- }
// update styles for UI status
_status = [
@@ -416,18 +281,6 @@ define(function main(require, exports, module) {
_setupGoLiveButton();
_setupGoLiveMenu();
- if (config.debug) {
- _setupDebugHelpers();
- }
-
- remoteHighlightPref
- .on("change", function () {
- config.remoteHighlight = prefs.get(PREF_REMOTEHIGHLIGHT);
- if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) {
- MultiBrowserLiveDev.updateConfig(JSON.stringify(config));
- }
- });
-
MultiBrowserLiveDev.on(MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL, function (event, previewDetails) {
exports.trigger(exports.EVENT_OPEN_PREVIEW_URL, previewDetails);
});
@@ -440,82 +293,62 @@ define(function main(require, exports, module) {
MultiBrowserLiveDev.on(MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_RELOAD, function (_event, clientDetails) {
exports.trigger(exports.EVENT_LIVE_PREVIEW_RELOAD, clientDetails);
});
-
- // allow live preview to handle escape key event
- // Escape is mainly to hide boxes if they are visible
- WorkspaceManager.addEscapeKeyEventHandler("livePreview", _handleLivePreviewEscapeKey);
- });
-
- // init prefs
- PreferencesManager.stateManager.definePreference("livedevHighlight", "boolean", true)
- .on("change", function () {
- config.highlight = PreferencesManager.getViewState("livedevHighlight");
- _updateHighlightCheckmark();
- if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) {
- MultiBrowserLiveDev.updateConfig(JSON.stringify(config));
- }
- });
-
- PreferencesManager.definePreference(PREFERENCE_LIVE_PREVIEW_MODE, "string", _getDefaultMode(), {
- description: StringUtils.format(Strings.LIVE_PREVIEW_MODE_PREFERENCE, "'preview'", "'highlight'", "'edit'"),
- values: ["preview", "highlight", "edit"]
});
- config.highlight = PreferencesManager.getViewState("livedevHighlight");
-
- function setLivePreviewEditFeaturesActive(enabled) {
- isProUser = enabled;
- config.isProUser = enabled;
- if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) {
- MultiBrowserLiveDev.updateConfig(JSON.stringify(config));
- MultiBrowserLiveDev.registerHandlers();
+ function _previewModeUpdated() {
+ const currentMode = getCurrentMode();
+ if (currentMode === LIVE_EDIT_MODE && !hasLiveEditCapability) {
+ PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE);
+ // we will get another update event for this immediately, so just return.
+ return;
}
+ const config = MultiBrowserLiveDev.getConfig();
+ config.mode = currentMode;
+ MultiBrowserLiveDev.updateConfig(config);
}
// this function is responsible to update element highlight config
// called from live preview extension when preference changes
function updateElementHighlightConfig() {
- const prefValue = PreferencesManager.get("livePreviewElementHighlights");
- config.elemHighlights = prefValue || "hover";
- if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) {
- MultiBrowserLiveDev.updateConfig(JSON.stringify(config));
- MultiBrowserLiveDev.registerHandlers();
- }
+ const prefValue = PreferencesManager.get(CONSTANTS.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT);
+ const config = MultiBrowserLiveDev.getConfig();
+ config.elemHighlights = prefValue || CONSTANTS.HIGHLIGHT_HOVER;
+ MultiBrowserLiveDev.updateConfig(config);
}
- // init commands
- CommandManager.register(Strings.CMD_LIVE_HIGHLIGHT, Commands.FILE_LIVE_HIGHLIGHT, togglePreviewHighlight);
- CommandManager.register(Strings.CMD_RELOAD_LIVE_PREVIEW, Commands.CMD_RELOAD_LIVE_PREVIEW, _handleReloadLivePreviewCommand);
-
- CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).setEnabled(false);
+ function updateRulerLinesConfig() {
+ const prefValue = PreferencesManager.get(CONSTANTS.PREFERENCE_SHOW_RULER_LINES);
+ const config = MultiBrowserLiveDev.getConfig();
+ config.showRulerLines = prefValue || false;
+ MultiBrowserLiveDev.updateConfig(config);
+ }
EventDispatcher.makeEventDispatcher(exports);
- exports.isProUser = isProUser;
- exports.isFreeTrialUser = isFreeTrialUser;
+ // private api
+ exports._liveEditCapabilityChanged = _liveEditCapabilityChanged;
// public events
exports.EVENT_OPEN_PREVIEW_URL = MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL;
exports.EVENT_CONNECTION_CLOSE = MultiBrowserLiveDev.EVENT_CONNECTION_CLOSE;
exports.EVENT_LIVE_PREVIEW_CLICKED = MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_CLICKED;
exports.EVENT_LIVE_PREVIEW_RELOAD = MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_RELOAD;
- exports.EVENT_LIVE_HIGHLIGHT_PREF_CHANGED = EVENT_LIVE_HIGHLIGHT_PREF_CHANGED;
// Export public functions
+ exports.CONSTANTS = CONSTANTS;
exports.openLivePreview = openLivePreview;
exports.closeLivePreview = closeLivePreview;
exports.isInactive = isInactive;
exports.isActive = isActive;
exports.setLivePreviewPinned = setLivePreviewPinned;
exports.setLivePreviewTransportBridge = setLivePreviewTransportBridge;
- exports.togglePreviewHighlight = togglePreviewHighlight;
- exports.setLivePreviewEditFeaturesActive = setLivePreviewEditFeaturesActive;
exports.setImageGalleryState = setImageGalleryState;
exports.updateElementHighlightConfig = updateElementHighlightConfig;
+ exports.updateRulerLinesConfig = updateRulerLinesConfig;
exports.getConnectionIds = MultiBrowserLiveDev.getConnectionIds;
exports.getLivePreviewDetails = MultiBrowserLiveDev.getLivePreviewDetails;
exports.hideHighlight = MultiBrowserLiveDev.hideHighlight;
- exports.dismissLivePreviewBoxes = MultiBrowserLiveDev.dismissLivePreviewBoxes;
exports.setMode = setMode;
exports.getCurrentMode = getCurrentMode;
+ exports.isInPreviewMode = isInPreviewMode;
});
diff --git a/src/command/Commands.js b/src/command/Commands.js
index dd9d5b0e64..188d1dfeff 100644
--- a/src/command/Commands.js
+++ b/src/command/Commands.js
@@ -103,9 +103,6 @@ define(function (require, exports, module) {
/** Reloads live preview */
exports.CMD_RELOAD_LIVE_PREVIEW = "file.reloadLivePreview"; // LiveDevelopment/main.js _handleReloadLivePreviewCommand()
- /** Toggles live highlight */
- exports.FILE_LIVE_HIGHLIGHT = "file.previewHighlight"; // LiveDevelopment/main.js _handlePreviewHighlightCommand()
-
/** Opens project settings */
exports.FILE_PROJECT_SETTINGS = "file.projectSettings"; // ProjectManager.js _projectSettings()
diff --git a/src/editor/EditorHelper/ChangeHelper.js b/src/editor/EditorHelper/ChangeHelper.js
index 9b20eb2dc6..3f9d50ba4d 100644
--- a/src/editor/EditorHelper/ChangeHelper.js
+++ b/src/editor/EditorHelper/ChangeHelper.js
@@ -25,6 +25,11 @@
define(function (require, exports, module) {
+ let _cutInterceptor = null;
+ let _copyInterceptor = null;
+ let _pasteInterceptor = null;
+ let _keyEventInterceptor = null;
+
const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"),
Menus = require("command/Menus");
@@ -170,6 +175,10 @@ define(function (require, exports, module) {
// Redispatch these CodeMirror key events as Editor events
function _onKeyEvent(instance, event) {
+ if(_keyEventInterceptor && _keyEventInterceptor(self, self._codeMirror, event)){
+ // the interceptor processed it, so don't pass it along to CodeMirror'
+ return;
+ }
self.trigger("keyEvent", self, event); // deprecated
self.trigger(event.type, self, event);
return event.defaultPrevented; // false tells CodeMirror we didn't eat the event
@@ -242,6 +251,29 @@ define(function (require, exports, module) {
elt.style.textIndent = "-" + off + "px";
elt.style.paddingLeft = off + "px";
});
+ self._codeMirror.on("cut", function(cm, e) {
+ // Let interceptor decide what to do with the event (including preventDefault)
+ if (_cutInterceptor) {
+ return _cutInterceptor(self, cm, e);
+ }
+ // Otherwise allow normal cut behavior
+ });
+
+ self._codeMirror.on("copy", function(cm, e) {
+ // Let interceptor decide what to do with the event (including preventDefault)
+ if (_copyInterceptor) {
+ return _copyInterceptor(self, cm, e);
+ }
+ // Otherwise allow normal copy behavior
+ });
+
+ self._codeMirror.on("paste", function(cm, e) {
+ // Let interceptor decide what to do with the event (including preventDefault)
+ if (_pasteInterceptor) {
+ return _pasteInterceptor(self, cm, e);
+ }
+ // Otherwise allow normal paste behavior
+ });
}
/**
@@ -282,5 +314,45 @@ define(function (require, exports, module) {
Editor.prototype._dontDismissPopupOnScroll = _dontDismissPopupOnScroll;
}
+ /**
+ * Sets the cut interceptor function in codemirror
+ * @param {Function} interceptor - Function(editor, cm, event) that returns true to
+ preventDefault
+ */
+ function setCutInterceptor(interceptor) {
+ _cutInterceptor = interceptor;
+ }
+
+ /**
+ * Sets the copy interceptor function in codemirror
+ * @param {Function} interceptor - Function(editor, cm, event) that returns true to
+ preventDefault
+ */
+ function setCopyInterceptor(interceptor) {
+ _copyInterceptor = interceptor;
+ }
+
+ /**
+ * Sets the paste interceptor function in codemirror
+ * @param {Function} interceptor - Function(editor, cm, event) that returns true to
+ preventDefault
+ */
+ function setPasteInterceptor(interceptor) {
+ _pasteInterceptor = interceptor;
+ }
+
+ /**
+ * Sets the key down/up/press interceptor function in codemirror
+ * @param {Function} interceptor - Function(editor, cm, event) that returns true to
+ preventDefault
+ */
+ function setKeyEventInterceptor(interceptor) {
+ _keyEventInterceptor = interceptor;
+ }
+
exports.addHelpers =addHelpers;
+ exports.setCutInterceptor = setCutInterceptor;
+ exports.setCopyInterceptor = setCopyInterceptor;
+ exports.setPasteInterceptor = setPasteInterceptor;
+ exports.setKeyEventInterceptor = setKeyEventInterceptor;
});
diff --git a/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js b/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js
index 65a65cad0c..e656669fa6 100644
--- a/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js
+++ b/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js
@@ -34,8 +34,6 @@ define(function (require, exports, module) {
Mustache = require("thirdparty/mustache/mustache"),
FileSystem = require("filesystem/FileSystem"),
EventDispatcher = require("utils/EventDispatcher"),
- CommandManager = require("command/CommandManager"),
- Commands = require("command/Commands"),
StringUtils = require("utils/StringUtils"),
EventManager = require("utils/EventManager"),
LivePreviewSettings = require("./LivePreviewSettings"),
@@ -730,11 +728,8 @@ define(function (require, exports, module) {
});
});
- function _isLiveHighlightEnabled() {
- return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked();
- }
exports.on(EVENT_EMBEDDED_IFRAME_ESCAPE_PRESS, function () {
- if(!_isLiveHighlightEnabled()){
+ if(LiveDevelopment.isInPreviewMode()){
return;
}
utils.focusActiveEditorIfFocusInLivePreview();
diff --git a/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js b/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js
index 6a4e385797..77fb640a0c 100644
--- a/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js
+++ b/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js
@@ -37,8 +37,6 @@ define(function (require, exports, module) {
LivePreviewSettings = require("./LivePreviewSettings"),
ProjectManager = require("project/ProjectManager"),
EventManager = require("utils/EventManager"),
- CommandManager = require("command/CommandManager"),
- Commands = require("command/Commands"),
Strings = require("strings"),
utils = require('./utils'),
NativeApp = require("utils/NativeApp"),
@@ -777,11 +775,8 @@ define(function (require, exports, module) {
}
});
- function _isLiveHighlightEnabled() {
- return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked();
- }
exports.on(EVENT_EMBEDDED_IFRAME_ESCAPE_PRESS, function () {
- if(!_isLiveHighlightEnabled()){
+ if(LiveDevelopment.isInPreviewMode()){
return;
}
utils.focusActiveEditorIfFocusInLivePreview();
diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js
index 547851b0ea..bd6187d630 100644
--- a/src/extensionsIntegrated/Phoenix-live-preview/main.js
+++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js
@@ -36,7 +36,7 @@
*/
/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */
-/*global path, jsPromise*/
+/*global path*/
//jshint-ignore:no-start
define(function (require, exports, module) {
@@ -57,6 +57,7 @@ define(function (require, exports, module) {
Strings = require("strings"),
Mustache = require("thirdparty/mustache/mustache"),
Metrics = require("utils/Metrics"),
+ CONSTANTS = require("LiveDevelopment/LivePreviewConstants"),
LiveDevelopment = require("LiveDevelopment/main"),
LiveDevServerManager = require("LiveDevelopment/LiveDevServerManager"),
MultiBrowserLiveDev = require("LiveDevelopment/LiveDevMultiBrowser"),
@@ -75,6 +76,11 @@ define(function (require, exports, module) {
ProDialogs = require("services/pro-dialogs"),
utils = require('./utils');
+ const KernalModeTrust = window.KernalModeTrust;
+ if(!KernalModeTrust){
+ throw new Error("KernalModeTrust is not defined. Cannot boot without trust ring");
+ }
+
const StateManager = PreferencesManager.stateManager;
const STATE_CUSTOM_SERVER_BANNER_ACK = "customServerBannerDone";
let customServerModalBar;
@@ -93,9 +99,16 @@ define(function (require, exports, module) {
const PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode";
// live preview element highlights preference (whether on hover or click)
- const PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = "livePreviewElementHighlights";
- PreferencesManager.definePreference(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, "string", "hover", {
- description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE
+ const PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = CONSTANTS.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT;
+ PreferencesManager.definePreference(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, "string", CONSTANTS.HIGHLIGHT_HOVER, {
+ description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE,
+ values: [CONSTANTS.HIGHLIGHT_HOVER, CONSTANTS.HIGHLIGHT_CLICK]
+ });
+
+ // live preview ruler lines preference (show/hide ruler lines on element selection)
+ const PREFERENCE_SHOW_RULER_LINES = CONSTANTS.PREFERENCE_SHOW_RULER_LINES;
+ PreferencesManager.definePreference(PREFERENCE_SHOW_RULER_LINES, "boolean", false, {
+ description: Strings.LIVE_DEV_SETTINGS_SHOW_RULER_LINES_PREFERENCE
});
const LIVE_PREVIEW_PANEL_ID = "live-preview-panel";
@@ -150,6 +163,18 @@ define(function (require, exports, module) {
let connectingOverlayTimer = null; // this is needed as we show the connecting overlay after 3s
let connectingOverlayTimeDuration = 3000;
+ let isProEditUser = false;
+ // this is called everytime there is a change in entitlements
+ async function _entitlementsChanged() {
+ try {
+ const entitlement = await KernalModeTrust.EntitlementsManager.getLiveEditEntitlement();
+ isProEditUser = entitlement.activated;
+ } catch (error) {
+ console.error("Error updating pro user status:", error);
+ isProEditUser = false;
+ }
+ }
+
StaticServer.on(EVENT_EMBEDDED_IFRAME_WHO_AM_I, function () {
if($iframe && $iframe[0]) {
const iframeDom = $iframe[0];
@@ -189,6 +214,12 @@ define(function (require, exports, module) {
// for connecting status, we delay showing the overlay by 3 seconds
if(status === MultiBrowserLiveDev.STATUS_CONNECTING) {
connectingOverlayTimer = setTimeout(() => {
+ // before creating the overlays we need to do a recheck for custom server
+ // cause project prefs sometimes takes time to reload which causes overlays to appear for custom servers
+ if(LivePreviewSettings.isUsingCustomServer()){
+ connectingOverlayTimer = null;
+ return;
+ }
_createAndShowOverlay(textMessage, status);
connectingOverlayTimer = null;
}, connectingOverlayTimeDuration);
@@ -274,44 +305,6 @@ define(function (require, exports, module) {
}
}
- // this function is to check if the live highlight feature is enabled or not
- function _isLiveHighlightEnabled() {
- return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked();
- }
-
- /**
- * Live Preview 'Preview Mode'. in this mode no live preview highlight or any such features are active
- * Just the plain website
- */
- function _LPPreviewMode() {
- LiveDevelopment.setLivePreviewEditFeaturesActive(false);
- if(_isLiveHighlightEnabled()) {
- LiveDevelopment.togglePreviewHighlight();
- }
- }
-
- /**
- * Live Preview 'Highlight Mode'. in this mode only the live preview matching with the source code is active
- * Meaning that if user clicks on some element that element's source code will be highlighted and vice versa
- */
- function _LPHighlightMode() {
- LiveDevelopment.setLivePreviewEditFeaturesActive(false);
- if(!_isLiveHighlightEnabled()) {
- LiveDevelopment.togglePreviewHighlight();
- }
- }
-
- /**
- * Live Preview 'Edit Mode'. this is the most interactive mode, in here the highlight features are available
- * along with that we also show element's highlighted boxes and such
- */
- function _LPEditMode() {
- LiveDevelopment.setLivePreviewEditFeaturesActive(true);
- if(!_isLiveHighlightEnabled()) {
- LiveDevelopment.togglePreviewHighlight();
- }
- }
-
/**
* update the mode button text in the live preview toolbar UI based on the current mode
* @param {String} mode - The current mode ("preview", "highlight", or "edit")
@@ -331,22 +324,18 @@ define(function (require, exports, module) {
function _initializeMode() {
const currentMode = LiveDevelopment.getCurrentMode();
- if (currentMode === "highlight") {
- _LPHighlightMode();
- $previewBtn.removeClass('selected');
- } else if (currentMode === "edit") {
- _LPEditMode();
- $previewBtn.removeClass('selected');
- } else {
- _LPPreviewMode();
+ // when in preview mode, we need to give the play button a selected state
+ if (currentMode === LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE) {
$previewBtn.addClass('selected');
+ } else {
+ $previewBtn.removeClass('selected');
}
_updateModeButton(currentMode);
}
function _showModeSelectionDropdown(event) {
- const isEditFeaturesActive = LiveDevelopment.isProUser;
+ const isEditFeaturesActive = isProEditUser;
const items = [
Strings.LIVE_PREVIEW_MODE_PREVIEW,
Strings.LIVE_PREVIEW_MODE_HIGHLIGHT,
@@ -357,6 +346,7 @@ define(function (require, exports, module) {
if (isEditFeaturesActive) {
items.push("---");
items.push(Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON);
+ items.push(Strings.LIVE_PREVIEW_SHOW_RULER_LINES);
}
const currentMode = LiveDevelopment.getCurrentMode();
@@ -364,22 +354,33 @@ define(function (require, exports, module) {
$dropdown = new DropdownButton.DropdownButton("", items, function(item, index) {
if (item === Strings.LIVE_PREVIEW_MODE_PREVIEW) {
// using empty spaces to keep content aligned
- return currentMode === "preview" ? `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`;
+ return currentMode === LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE ?
+ `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`;
} else if (item === Strings.LIVE_PREVIEW_MODE_HIGHLIGHT) {
- return currentMode === "highlight" ? `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`;
+ return currentMode === LiveDevelopment.CONSTANTS.LIVE_HIGHLIGHT_MODE ?
+ `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`;
} else if (item === Strings.LIVE_PREVIEW_MODE_EDIT) {
- const checkmark = currentMode === "edit" ? "✓ " : `${'\u00A0'.repeat(4)}`;
- const crownIcon = !isEditFeaturesActive ? ' Pro' : '';
+ const checkmark = currentMode === LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE ?
+ "✓ " : `${'\u00A0'.repeat(4)}`;
+ const crownIcon = !isEditFeaturesActive ?
+ ' Pro' : '';
return {
html: `${checkmark}${item}${crownIcon}`,
enabled: true
};
} else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) {
- const isHoverMode = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT) !== "click";
+ const isHoverMode =
+ PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT) === CONSTANTS.HIGHLIGHT_HOVER;
if(isHoverMode) {
return `✓ ${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`;
}
return `${'\u00A0'.repeat(4)}${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`;
+ } else if (item === Strings.LIVE_PREVIEW_SHOW_RULER_LINES) {
+ const isEnabled = PreferencesManager.get(PREFERENCE_SHOW_RULER_LINES);
+ if(isEnabled) {
+ return `✓ ${Strings.LIVE_PREVIEW_SHOW_RULER_LINES}`;
+ }
+ return `${'\u00A0'.repeat(4)}${Strings.LIVE_PREVIEW_SHOW_RULER_LINES}`;
}
return item;
});
@@ -405,11 +406,11 @@ define(function (require, exports, module) {
// handle the option selection
$dropdown.on("select", function (e, item, index) {
if (index === 0) {
- LiveDevelopment.setMode("preview");
+ LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE);
} else if (index === 1) {
- LiveDevelopment.setMode("highlight");
+ LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_HIGHLIGHT_MODE);
} else if (index === 2) {
- if (!LiveDevelopment.setMode("edit")) {
+ if (!LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE)) {
ProDialogs.showProUpsellDialog(ProDialogs.UPSELL_TYPE_LIVE_EDIT);
}
} else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) {
@@ -419,14 +420,20 @@ define(function (require, exports, module) {
}
// Toggle between hover and click
const currMode = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT);
- const newMode = currMode !== "click" ? "click" : "hover";
+ const newMode = (currMode !== CONSTANTS.HIGHLIGHT_CLICK) ?
+ CONSTANTS.HIGHLIGHT_CLICK : CONSTANTS.HIGHLIGHT_HOVER;
PreferencesManager.set(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, newMode);
return; // Don't dismiss highlights for this option
+ } else if (item === Strings.LIVE_PREVIEW_SHOW_RULER_LINES) {
+ // Don't allow ruler lines toggle if edit features are not active
+ if (!isEditFeaturesActive) {
+ return;
+ }
+ // Toggle ruler lines on/off
+ const currentValue = PreferencesManager.get(PREFERENCE_SHOW_RULER_LINES);
+ PreferencesManager.set(PREFERENCE_SHOW_RULER_LINES, !currentValue);
+ return; // Don't dismiss highlights for this option
}
-
- // need to dismiss the previous highlighting and stuff
- LiveDevelopment.hideHighlight();
- LiveDevelopment.dismissLivePreviewBoxes();
});
// Remove the button after the dropdown is hidden
@@ -669,10 +676,14 @@ define(function (require, exports, module) {
function _handlePreviewBtnClick() {
if($previewBtn.hasClass('selected')) {
$previewBtn.removeClass('selected');
- const isEditFeaturesActive = LiveDevelopment.isProUser;
+ const isEditFeaturesActive = isProEditUser;
if(modeThatWasSelected) {
- if(modeThatWasSelected === 'edit' && !isEditFeaturesActive) {
- // we just set the preference as preference has change handlers that will update the config
+ // If the last selected mode was preview itself, default to the best mode for user's entitlement
+ if(modeThatWasSelected === 'preview') {
+ const defaultMode = isEditFeaturesActive ? 'edit' : 'highlight';
+ PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, defaultMode);
+ } else if(modeThatWasSelected === 'edit' && !isEditFeaturesActive) {
+ // Non-pro users can't be in edit mode - switch to highlight
PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight");
} else {
PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, modeThatWasSelected);
@@ -1191,10 +1202,15 @@ define(function (require, exports, module) {
});
CommandManager.register(Strings.CMD_LIVE_FILE_PREVIEW_SETTINGS,
Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, _showSettingsDialog);
+ CommandManager.register(Strings.CMD_RELOAD_LIVE_PREVIEW, Commands.CMD_RELOAD_LIVE_PREVIEW, function () {
+ _loadPreview(true, true);
+ });
let fileMenu = Menus.getMenu(Menus.AppMenuBar.FILE_MENU);
fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW, "", Menus.AFTER, Commands.FILE_EXTENSION_MANAGER);
- fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, "",
+ fileMenu.addMenuItem(Commands.CMD_RELOAD_LIVE_PREVIEW, "",
Menus.AFTER, Commands.FILE_LIVE_FILE_PREVIEW);
+ fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, "",
+ Menus.AFTER, Commands.CMD_RELOAD_LIVE_PREVIEW);
fileMenu.addMenuDivider(Menus.BEFORE, Commands.FILE_LIVE_FILE_PREVIEW);
_registerHandlers();
@@ -1205,13 +1221,17 @@ define(function (require, exports, module) {
_initializeMode();
});
- // Handle element highlight preference changes from this extension
+ // Handle element highlight & ruler lines preference changes
PreferencesManager.on("change", PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, function() {
LiveDevelopment.updateElementHighlightConfig();
});
+ PreferencesManager.on("change", PREFERENCE_SHOW_RULER_LINES, function() {
+ LiveDevelopment.updateRulerLinesConfig();
+ });
- // Initialize element highlight config on startup
+ // Initialize element highlight and ruler lines config on startup
LiveDevelopment.updateElementHighlightConfig();
+ LiveDevelopment.updateRulerLinesConfig();
LiveDevelopment.openLivePreview();
LiveDevelopment.on(LiveDevelopment.EVENT_OPEN_PREVIEW_URL, _openLivePreviewURL);
@@ -1291,6 +1311,13 @@ define(function (require, exports, module) {
}
}, 1000);
_projectOpened();
+ if(!Phoenix.isSpecRunnerWindow){
+ _entitlementsChanged();
+ KernalModeTrust.EntitlementsManager.on(
+ KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED,
+ _entitlementsChanged
+ );
+ }
});
// private API to be used inside phoenix codebase only
diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js
index 523db814bb..a93c82ef1d 100644
--- a/src/extensionsIntegrated/loader.js
+++ b/src/extensionsIntegrated/loader.js
@@ -46,4 +46,5 @@ define(function (require, exports, module) {
require("./TabBar/main");
require("./CustomSnippets/main");
require("./CollapseFolders/main");
+ require("./pro-loader");
});
diff --git a/src/LiveDevelopment/LivePreviewEdit.js b/src/extensionsIntegrated/phoenix-pro/LivePreviewEdit.js
similarity index 67%
rename from src/LiveDevelopment/LivePreviewEdit.js
rename to src/extensionsIntegrated/phoenix-pro/LivePreviewEdit.js
index 851e84d094..e15547be60 100644
--- a/src/LiveDevelopment/LivePreviewEdit.js
+++ b/src/extensionsIntegrated/phoenix-pro/LivePreviewEdit.js
@@ -1,22 +1,6 @@
/*
- * GNU AGPL-3.0 License
- *
- * Copyright (c) 2021 - present core.ai . All rights reserved.
- * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved.
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
- * for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
- *
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
*/
/*
@@ -28,8 +12,10 @@ define(function (require, exports, module) {
const HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation");
const LiveDevMultiBrowser = require("LiveDevelopment/LiveDevMultiBrowser");
const LiveDevelopment = require("LiveDevelopment/main");
+ const Constants = require("LiveDevelopment/LivePreviewConstants");
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");
@@ -40,13 +26,25 @@ define(function (require, exports, module) {
const ProDialogs = require("services/pro-dialogs");
const Mustache = require("thirdparty/mustache/mustache");
const Strings = require("strings");
- const ImageFolderDialogTemplate = require("text!htmlContent/image-folder-dialog.html");
+ const ImageFolderDialogTemplate = require("text!./html/image-folder-dialog.html");
+ const LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol");
+ const ProUtils = require("./pro-utils");
+ const EditorManager = require("editor/EditorManager");
+ const ChangeHelper = require("editor/EditorHelper/ChangeHelper");
+ const AppInit = require("utils/AppInit");
+ const Editor = require("editor/Editor").Editor;
+ const CONSTANTS = require("LiveDevelopment/LivePreviewConstants");
// state manager key, to save the download location of the image
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',
@@ -72,7 +70,7 @@ define(function (require, exports, module) {
* we only care about text changes or things like newlines,
, or formatting like , , 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 elements
* - Then walk both DOMs side by side and sync changes
*
* What we handle:
@@ -87,12 +85,14 @@ define(function (require, exports, module) {
* 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) {
@@ -164,14 +164,285 @@ define(function (require, exports, module) {
}
}
- 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("");
+ }
+
+ /**
+ * builds a map of character positions to text nodes so we can find which nodes contain a selection
+ * @param {Element} element - the element to map
+ * @returns {Array} array of objects: { node, startOffset, endOffset }
+ */
+ function _buildTextNodeMap(element) {
+ const textNodeMap = [];
+ let currentOffset = 0;
+
+ function traverse(node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const textLength = node.nodeValue.length;
+ textNodeMap.push({
+ node: node,
+ startOffset: currentOffset,
+ endOffset: currentOffset + textLength
+ });
+ currentOffset += textLength;
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
+ // recursively traverse child nodes
+ for (let child of node.childNodes) {
+ traverse(child);
+ }
+ }
+ }
+
+ traverse(element);
+ return textNodeMap;
+ }
+
+ /**
+ * finds text nodes that overlap with the user's selection
+ * @param {Array} textNodeMap - the text node map from _buildTextNodeMap
+ * @param {Number} startOffset - selection start offset
+ * @param {Number} endOffset - selection end offset
+ * @returns {Array} array of objects: { node, localStart, localEnd, parentElement }
+ */
+ function _findSelectionNodes(textNodeMap, startOffset, endOffset) {
+ const selectionNodes = [];
+
+ for (let entry of textNodeMap) {
+ // check if this text node overlaps with the selection range
+ if (entry.endOffset > startOffset && entry.startOffset < endOffset) {
+ const localStart = Math.max(0, startOffset - entry.startOffset);
+ const localEnd = Math.min(entry.node.nodeValue.length, endOffset - entry.startOffset);
+
+ selectionNodes.push({
+ node: entry.node,
+ localStart: localStart,
+ localEnd: localEnd,
+ parentElement: entry.node.parentElement
+ });
+ }
+ }
+
+ return selectionNodes;
+ }
+
+ /**
+ * converts format command string to the actual HTML tag name we want to use
+ * @param {string} formatCommand - "bold", "italic", or "underline"
+ * @returns {string} tag name: "b", "i", or "u"
+ */
+ function _getFormatTag(formatCommand) {
+ const tagMap = {
+ 'bold': 'b',
+ 'italic': 'i',
+ 'underline': 'u'
+ };
+ return tagMap[formatCommand] || null;
+ }
+
+ /**
+ * checks if a text node is already wrapped in a formatting tag by checking ancestors
+ * we stop at contenteditable boundary since that's the element being edited
+ * @param {Node} node - the text node to check
+ * @param {string} tagName - the tag name to look for (lowercase)
+ * @returns {Element|null} the wrapping element if found, null otherwise
+ */
+ function _isNodeWrappedInTag(node, tagName) {
+ let parent = node.parentElement;
+ while (parent) {
+ if (parent.tagName && parent.tagName.toLowerCase() === tagName) {
+ return parent;
+ }
+ // stop at the editable element boundary
+ if (parent.hasAttribute('contenteditable')) {
+ break;
+ }
+ parent = parent.parentElement;
+ }
+ return null;
+ }
+
+ /**
+ * wraps a portion of a text node in a formatting tag, splitting the text node if needed
+ * @param {Node} textNode - the text node to wrap
+ * @param {string} tagName - the formatting tag name (b, i, u)
+ * @param {Number} start - start offset within the text node
+ * @param {Number} end - end offset within the text node
+ */
+ function _wrapTextInTag(textNode, tagName, start, end) {
+ const text = textNode.nodeValue;
+ const before = text.substring(0, start);
+ const selected = text.substring(start, end);
+ const after = text.substring(end);
+
+ const parent = textNode.parentNode;
+ const formatElement = document.createElement(tagName);
+ formatElement.textContent = selected;
+
+ // replace the text node with before + formatted + after
+ const fragment = document.createDocumentFragment();
+ if (before) {
+ fragment.appendChild(document.createTextNode(before));
+ }
+ fragment.appendChild(formatElement);
+ if (after) {
+ fragment.appendChild(document.createTextNode(after));
+ }
+
+ parent.replaceChild(fragment, textNode);
+ }
+
+ /**
+ * removes a formatting tag by moving its children to its parent
+ * @param {Element} formatElement - the formatting element to unwrap
+ */
+ function _unwrapFormattingTag(formatElement) {
+ const parent = formatElement.parentNode;
+ while (formatElement.firstChild) {
+ parent.insertBefore(formatElement.firstChild, formatElement);
+ }
+ parent.removeChild(formatElement);
+ }
+
+ /**
+ * applies or removes formatting on the selected text nodes (toggle behavior)
+ * if all nodes are wrapped in the format tag, we remove it. otherwise we add it.
+ * @param {Array} selectionNodes - array of selected node info from _findSelectionNodes
+ * @param {string} formatTag - the format tag to apply/remove (b, i, or u)
+ */
+ function _applyFormatToNodes(selectionNodes, formatTag) {
+ // check if all selected nodes are already wrapped in the format tag
+ const allWrapped = selectionNodes.every(nodeInfo =>
+ _isNodeWrappedInTag(nodeInfo.node, formatTag)
+ );
+
+ if (allWrapped) {
+ // remove formatting (toggle OFF)
+ selectionNodes.forEach(nodeInfo => {
+ const wrapper = _isNodeWrappedInTag(nodeInfo.node, formatTag);
+ if (wrapper) {
+ _unwrapFormattingTag(wrapper);
+ }
+ });
+ } else {
+ // apply formatting (toggle ON)
+ selectionNodes.forEach(nodeInfo => {
+ const { node, localStart, localEnd } = nodeInfo;
+
+ // skip if already wrapped
+ if (!_isNodeWrappedInTag(node, formatTag)) {
+ // check if we need to format the entire node or just a portion
+ if (localStart === 0 && localEnd === node.nodeValue.length) {
+ // format entire node
+ const formatElement = document.createElement(formatTag);
+ const parent = node.parentNode;
+ parent.insertBefore(formatElement, node);
+ formatElement.appendChild(node);
+ } else {
+ // format partial node
+ _wrapTextInTag(node, formatTag, localStart, localEnd);
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * handles text formatting (bold, italic, underline) for selected text in live preview
+ * this is called when user presses ctrl+b/i/u in contenteditable mode
+ * @param {Object} message - message from frontend with format command and selection info
+ */
+ function _applyFormattingToSource(message) {
+ const editor = _getEditorAndValidate(message.tagId);
+ if (!editor) {
+ return;
+ }
+
+ const range = _getElementRange(editor, message.tagId);
+ if (!range) {
+ return;
+ }
+
+ const { startPos, endPos } = range;
+ const elementText = editor.document.getRange(startPos, endPos);
+
+ // parse the HTML from source using DOMParser
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(elementText, "text/html");
+ const targetElement = doc.body.firstElementChild;
+
+ if (!targetElement) {
+ return;
+ }
+
+ // if targetElement itself is an inline formatting tag (b, i, u, etc), we need to wrap it
+ // because when we toggle it off, the element gets removed and we lose the reference
+ const isInlineFormatTag = ['b', 'i', 'u', 'strong', 'em'].includes(
+ targetElement.tagName.toLowerCase()
+ );
+
+ let workingElement = targetElement;
+ let wrapperElement = null;
+
+ if (isInlineFormatTag) {
+ // wrap in temporary container so we don't lose content when element is removed
+ wrapperElement = doc.createElement('div');
+ wrapperElement.appendChild(targetElement);
+ workingElement = wrapperElement.firstElementChild;
+ }
+
+ // build text node map for finding which nodes contain the selection
+ const textNodeMap = _buildTextNodeMap(workingElement);
+
+ // validate selection bounds
+ if (!message.selection || message.selection.startOffset >= message.selection.endOffset) {
+ return;
+ }
+ const endOffset = textNodeMap[textNodeMap.length - 1] && textNodeMap[textNodeMap.length - 1].endOffset;
+
+ if (message.selection.endOffset > endOffset) {
+ return;
+ }
+
+ // find which text nodes contain the selection
+ const selectionNodes = _findSelectionNodes(
+ textNodeMap,
+ message.selection.startOffset,
+ message.selection.endOffset
+ );
+
+ if (selectionNodes.length === 0) {
+ return;
+ }
+
+ // get the format tag and apply/remove it
+ const formatTag = _getFormatTag(message.livePreviewFormatCommand);
+ if (!formatTag) {
+ return;
+ }
+
+ _applyFormatToNodes(selectionNodes, formatTag);
+
+ // serialize and replace in editor
+ let updatedHTML;
+ if (wrapperElement) {
+ // if we wrapped the element, get the wrapper's innerHTML
+ updatedHTML = wrapperElement.innerHTML;
+ } else {
+ updatedHTML = workingElement.outerHTML;
+ }
+
+ editor.document.batchOperation(function () {
+ editor.document.replaceRange(updatedHTML, startPos, endPos);
+ });
}
/**
@@ -313,6 +584,110 @@ define(function (require, exports, module) {
});
}
+ let clipboardTextCopiedFallback = null;
+
+ /**
+ * 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);
+ clipboardTextCopiedFallback = text;
+
+ // 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);
+ }
+ });
+ _showCopyToastIfNeeded();
+ Phoenix.app.copyToClipboard(text)
+ .catch(console.error); // silently fail as in browser popped out live preview case, this will fail
+ // and since its expected failure with browser security there is litttle we can do.
+ }
+
+ 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);
+ clipboardTextCopiedFallback = text;
+
+ _showCopyToastIfNeeded();
+ Phoenix.app.copyToClipboard(text)
+ .catch(console.error); // silently fail as in browser popped out live preview case, this will fail
+ // and since its expected failure with browser security there is litttle we can do.
+ }
+
+ /**
+ * 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, endPos } = range;
+
+ function replaceText(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() === '' ? `\n${indent}` : '';
+ const finalReplacementText = indent + text;
+
+ editor.replaceRange(finalReplacementText, endPos);
+ editor.setCursorPos(endPos.line + 1, indent.length - 1);
+ // since this is called edither when user clicks on live preview paste button, or key board shortcut,
+ // when live preview is in context, we set it to true. If we dont, the a cursor position change will happen
+ // and we will exit live preview context. So when a user repeatedly presses ctrl+v, we will exit the context
+ // if we dont set it to true here.
+ _isInLivePreviewSelectionContext = true;
+ }
+
+ Phoenix.app.clipboardReadText()
+ .then(replaceText)
+ .catch(err => {
+ console.error("Failed to read from clipboard:", err);
+ // this can fail if the paste is initiated in popped out live preview in browser app and then
+ // if we try to access clipboard in Phoenix which isn't focused, the browser wont allow that.
+ // in which case we will use the fallback text passed in from the live preview.
+ replaceText(clipboardTextCopiedFallback);
+ });
+ }
+
/**
* This function is responsible to delete an element from the source code
* @param {Number} tagId - the data-brackets-id of the DOM element
@@ -341,6 +716,7 @@ define(function (require, exports, module) {
editor.replaceRange("", {line: startPos.line - 1, ch: chPrevLine}, startPos);
}
});
+ _isInLivePreviewSelectionContext = true;
}
/**
@@ -373,6 +749,56 @@ define(function (require, exports, module) {
}
}
+ /**
+ * This function reindents multiline text so that after the drag-drop operation the target indentation is matched
+ * note: it preserves the relative indentation
+ *
+ * @param {String} sourceText - the text to re-indent
+ * @param {String} targetIndent - the target indentation string
+ * @returns {String} - the re-indented text
+ */
+ function _reindentMultilineText(sourceText, targetIndent) {
+ const lines = sourceText.split('\n');
+ // for single lines no reindentation is needed
+ if (lines.length <= 1) {
+ return sourceText;
+ }
+
+ // find the source indent from last non-empty line (normally its the closing tag)
+ // this will represent the original indentation level of the element
+ let sourceIndentLen = 0;
+ for (let j = lines.length - 1; j >= 0; j--) {
+ if (lines[j].trim() !== '') {
+ let len = 0;
+ while (len < lines[j].length && (lines[j][len] === ' ' || lines[j][len] === '\t')) {
+ len++;
+ }
+ sourceIndentLen = len;
+ break;
+ }
+ }
+
+ // calculate the indentation delta
+ const delta = targetIndent.length - sourceIndentLen;
+
+ // adjust all lines by the delta to maintain relative indentation
+ for (let j = 0; j < lines.length; j++) {
+ if (lines[j].trim() === '') { continue; } // preserve empty lines
+
+ let currentIndent = 0;
+ while (currentIndent < lines[j].length &&
+ (lines[j][currentIndent] === ' ' || lines[j][currentIndent] === '\t')) {
+ currentIndent++;
+ }
+
+ const newIndent = Math.max(0, currentIndent + delta);
+ const indentChar = targetIndent.length > 0 ? targetIndent[0] : ' ';
+ lines[j] = indentChar.repeat(newIndent) + lines[j].substring(currentIndent);
+ }
+
+ return lines.join('\n');
+ }
+
/**
* this function is to make sure that we insert elements with proper indentation
*
@@ -383,6 +809,8 @@ define(function (require, exports, module) {
* @param {String} sourceText - the text to insert
*/
function _insertElementWithIndentation(editor, insertPos, insertAfterMode, targetIndent, sourceText) {
+ sourceText = _reindentMultilineText(sourceText, targetIndent);
+
if (insertAfterMode) {
// Insert after the target element
editor.replaceRange("\n" + targetIndent + sourceText, insertPos);
@@ -696,6 +1124,46 @@ define(function (require, exports, module) {
}
}
+ /**
+ * 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) {
+ currLiveDoc.protocol.evaluate("_LD.dismissUIAndCleanupState()");
+ }
+ }
+ }
+
function _sendDownloadStatusToBrowser(eventType, data) {
const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
@@ -712,6 +1180,18 @@ define(function (require, exports, module) {
}
}
+ 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 evalString = `_LD.showToastMessage('copyFirstTime', 6000)`;
+ currLiveDoc.protocol.evaluate(evalString);
+ StateManager.set(COPY_CUT_TOAST_SHOWN_KEY, true);
+ }
+ }
+ }
+
function _trackDownload(downloadLocation) {
if (!downloadLocation) {
return;
@@ -1156,6 +1636,11 @@ define(function (require, exports, module) {
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;
@@ -1164,12 +1649,32 @@ define(function (require, exports, module) {
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);
+ }
+
+ // 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);
+ });
+ }
- _scanDirectories(projectRoot, '', folderList);
+ // 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() {
@@ -1206,6 +1711,12 @@ define(function (require, exports, module) {
_sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.CANCELLED, { downloadId: message.downloadId });
}
}
+
+ // notify live preview that dialog is now closed
+ if (message && message.downloadId) {
+ _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.DIALOG_CLOSED, { downloadId: message.downloadId });
+ }
+
dialog.close();
});
}
@@ -1254,19 +1765,6 @@ define(function (require, exports, module) {
});
}
- /**
- * 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
@@ -1318,6 +1816,12 @@ define(function (require, exports, module) {
* these are the main properties that are passed through the message
*/
function handleLivePreviewEditOperation(message) {
+ if(!ProUtils.isProEditActivated()) {
+ // license check: only log this message if localhost dev build. else this should be a silent
+ // bailout to prevent tampering.
+ location.host.startsWith("localhost") && console.error("ProEdit is not activated, not editing");
+ return;
+ }
// handle save current document in live preview (ctrl/cmd + s)
if (message.saveCurrentDocument) {
_handleLivePreviewSave();
@@ -1330,9 +1834,15 @@ define(function (require, exports, module) {
return;
}
+ // toggle live preview mode using hot corner
+ if (message.type === "hotCornerPreviewToggle") {
+ _handlePreviewModeToggle(true);
+ return;
+ }
+
// handle reset image folder selection
if (message.resetImageFolderSelection) {
- _handleResetImageFolderSelection();
+ _showFolderSelectionDialog(null);
return;
}
@@ -1342,6 +1852,12 @@ define(function (require, exports, module) {
return;
}
+ // handle ruler lines toggle message
+ if (message.type === "toggleRulerLines") {
+ PreferencesManager.set(Constants.PREFERENCE_SHOW_RULER_LINES, message.enabled);
+ return;
+ }
+
// handle move(drag & drop)
if (message.move && message.sourceId && message.targetId) {
_moveElementInSource(message.sourceId, message.targetId, message.insertAfter, message.insertInside);
@@ -1367,12 +1883,132 @@ define(function (require, exports, module) {
_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.livePreviewFormatCommand) {
+ _applyFormattingToSource(message);
+ } else if (message.livePreviewHyperlinkEdit) {
+ _updateHyperlinkHref(message.tagId, message.newHref);
} else if (message.AISend) {
_editWithAI(message);
}
}
- exports.handleLivePreviewEditOperation = handleLivePreviewEditOperation;
+ LiveDevProtocol.setLivePreviewMessageHandler((msg)=>{
+ if (msg.livePreviewEditEnabled) {
+ handleLivePreviewEditOperation(msg);
+ }
+ });
+
+ let _isInLivePreviewSelectionContext = false;
+ /**
+ * Cut (Ctrl+X) interceptor.
+ *
+ * This handler intercepts CodeMirror’s internal "cut" event (not a DOM event
+ * from the Live Preview iframe).
+ *
+ * Important context:
+ * - The Live Preview is embedded, but keyboard focus is always on the editor.
+ * - Clicking an element in Live Preview shifts *selection context* to Live Preview,
+ * but not keyboard focus.
+ * - Therefore, when the user presses Ctrl+X while an element is selected in
+ * Live Preview, CodeMirror still receives the cut event.
+ *
+ * Expected user intent:
+ * - Ctrl+X while selecting a Live Preview element → cut the selected DOM node
+ * - Ctrl+X while editing text in the editor → cut editor text
+ *
+ * Behavior:
+ * - If we are in Live Preview selection context AND Live Edit mode is active,
+ * we prevent CodeMirror’s default cut behavior.
+ * - Instead, we forward the cut operation to the Live Preview via protocol,
+ * so the selected DOM element is cut correctly.
+ *
+ * Note:
+ * - The Live Preview selection context is reset as soon as any editor event occurs.
+ */
+ ChangeHelper.setCutInterceptor(function(editor, cm, event) {
+ if (_isInLivePreviewSelectionContext && LiveDevelopment.getCurrentMode() === CONSTANTS.LIVE_EDIT_MODE) {
+ const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
+ event.preventDefault();
+ currLiveDoc.protocol.evaluate(`_LD.handleCutElement()`);
+ }
+ }
+ });
+
+ // Copy interceptor, see cut interceptor docs above to understand impl details
+ ChangeHelper.setCopyInterceptor(function(editor, cm, event) {
+ if (_isInLivePreviewSelectionContext && LiveDevelopment.getCurrentMode() === CONSTANTS.LIVE_EDIT_MODE) {
+ const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
+ event.preventDefault();
+ currLiveDoc.protocol.evaluate(`_LD.handleCopyElement()`);
+ }
+ }
+ });
+
+ // Paste interceptor, see cut interceptor docs above to understand impl details
+ ChangeHelper.setPasteInterceptor(function(editor, cm, event) {
+ if (_isInLivePreviewSelectionContext && LiveDevelopment.getCurrentMode() === CONSTANTS.LIVE_EDIT_MODE) {
+ const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
+ event.preventDefault();
+ currLiveDoc.protocol.evaluate(`_LD.handlePasteElement()`);
+ }
+ }
+ });
+
+ ChangeHelper.setKeyEventInterceptor(function(editor, cm, event) {
+ if(event.type !== "keydown" || (event.key.toLowerCase() !== "delete"
+ && event.key.toLowerCase() !== "backspace")) {
+ return;
+ }
+ if (_isInLivePreviewSelectionContext && LiveDevelopment.getCurrentMode() === CONSTANTS.LIVE_EDIT_MODE) {
+ const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
+ if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
+ event.preventDefault();
+ currLiveDoc.protocol.evaluate(`_LD.handleDeleteElement()`);
+ return true;
+ }
+ }
+ });
+
+ function _cursorActivity() {
+ if(_isInLivePreviewSelectionContext) {
+ window.logger.livePreview.log("unlocked cut/copy/paste from live preview");
+ }
+ _isInLivePreviewSelectionContext = false;
+ }
+
+ const LIVE_EDIT_SCOPE = ".LiveEditMain";
+ AppInit.appReady(function () {
+ EditorManager.on(EditorManager.EVENT_ACTIVE_EDITOR_CHANGED + LIVE_EDIT_SCOPE, (_event, newEditor, oldEditor)=>{
+ if(_isInLivePreviewSelectionContext) {
+ window.logger.livePreview.log("unlocked cut/copy/paste from live preview");
+ }
+ _isInLivePreviewSelectionContext = false;
+ if(oldEditor){
+ oldEditor.off(Editor.EVENT_CURSOR_ACTIVITY + LIVE_EDIT_SCOPE);
+ }
+ if(newEditor){
+ newEditor.off(Editor.EVENT_CURSOR_ACTIVITY + LIVE_EDIT_SCOPE);
+ newEditor.on(Editor.EVENT_CURSOR_ACTIVITY + LIVE_EDIT_SCOPE, _cursorActivity);
+ }
+ });
+ const activeEditor = EditorManager.getActiveEditor();
+ if(activeEditor) {
+ activeEditor.on(Editor.EVENT_CURSOR_ACTIVITY + LIVE_EDIT_SCOPE, _cursorActivity);
+ }
+ LiveDevelopment.on(LiveDevelopment.EVENT_LIVE_PREVIEW_CLICKED, ()=>{
+ window.logger.livePreview.log("cut/copy/paste locked to live preview");
+ _isInLivePreviewSelectionContext = true;
+ });
+ });
});
diff --git a/src/extensionsIntegrated/phoenix-pro/README.md b/src/extensionsIntegrated/phoenix-pro/README.md
new file mode 100644
index 0000000000..68af47694e
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/README.md
@@ -0,0 +1,6 @@
+## Build notes
+All files this in this folder will be deleted in the prod build by the build script at `gulpfile.js/index.js`
+in phoenix code. This is because the code will be inlined in brackets-min.js. If you want to retain any files
+in this folder at distribution (strongly discouraged without reason), please update the function in
+build script `_deletePhoenixProSourceFolder()`. You may only do this for images,or other binary resources, which
+shouldn't be in the source in the first place.
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/ai-pro.js b/src/extensionsIntegrated/phoenix-pro/browser-context/ai-pro.js
new file mode 100644
index 0000000000..a92bf54489
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/ai-pro.js
@@ -0,0 +1,248 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+/*global GLOBALS, LivePreviewView, cssStyles, strings, icons, proConstants, dismissUIAndCleanupState*/
+let _aiPromptBox; // to store the AI box instance
+
+function AIPromptBox(element) {
+ this.element = element;
+ this.selectedModel = "fast";
+ this.remove = this.remove.bind(this);
+ this.create();
+}
+
+AIPromptBox.prototype = {
+ _getBoxPosition: function (boxWidth, boxHeight) {
+ const elemBounds = this.element.getBoundingClientRect();
+ const offset = LivePreviewView.screenOffset(this.element);
+
+ let topPos = offset.top - boxHeight - 6; // 6 for just some little space to breathe
+ let leftPos = offset.left + elemBounds.width - boxWidth;
+
+ // Check if the box would go off the top of the viewport
+ if (elemBounds.top - boxHeight < 6) {
+ topPos = offset.top + elemBounds.height + 6;
+ }
+
+ // Check if the box would go off the left of the viewport
+ if (leftPos < 0) {
+ leftPos = offset.left;
+ }
+
+ return { topPos: topPos, leftPos: leftPos };
+ },
+
+ _style: function () {
+ this.body = window.document.createElement("div");
+ this.body.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
+ // using shadow dom so that user styles doesn't override it
+ const shadow = this.body.attachShadow({ mode: "open" });
+
+ // Calculate responsive dimensions based on viewport width
+ const viewportWidth = window.innerWidth;
+ let boxWidth, boxHeight;
+
+ if (viewportWidth >= 400) {
+ boxWidth = Math.min(310, viewportWidth * 0.85);
+ boxHeight = 60;
+ } else if (viewportWidth >= 350) {
+ boxWidth = Math.min(275, viewportWidth * 0.85);
+ boxHeight = 70;
+ } else if (viewportWidth >= 300) {
+ boxWidth = Math.min(230, viewportWidth * 0.85);
+ boxHeight = 80;
+ } else if (viewportWidth >= 250) {
+ boxWidth = Math.min(180, viewportWidth * 0.85);
+ boxHeight = 100;
+ } else if (viewportWidth >= 200) {
+ boxWidth = Math.min(130, viewportWidth * 0.85);
+ boxHeight = 120;
+ } else {
+ boxWidth = Math.min(100, viewportWidth * 0.85);
+ boxHeight = 140;
+ }
+
+ const content = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ shadow.innerHTML = `
+
+ ${content}
+ `;
+ this._shadow = shadow;
+ },
+
+ create: function () {
+ this._style();
+ window.document.body.appendChild(this.body);
+
+ // Get the actual rendered dimensions of the box and position it
+ const boxElement = this._shadow.querySelector(".phoenix-ai-prompt-box");
+ if (boxElement) {
+ const boxRect = boxElement.getBoundingClientRect();
+ const pos = this._getBoxPosition(boxRect.width, boxRect.height);
+
+ boxElement.style.left = pos.leftPos + "px";
+ boxElement.style.top = pos.topPos + "px";
+ }
+
+ // Focus on the textarea
+ const textarea = this._shadow.querySelector(".phoenix-ai-prompt-textarea");
+ if (textarea) {
+ // small timer to make sure that the text area element is fetched
+ setTimeout(() => textarea.focus(), 50);
+ }
+
+ this._attachEventHandlers();
+
+ // Prevent clicks inside the AI box from bubbling up and closing it
+ this.body.addEventListener("click", (event) => {
+ event.stopPropagation();
+ });
+ },
+
+ _attachEventHandlers: function () {
+ const textarea = this._shadow.querySelector(".phoenix-ai-prompt-textarea");
+ const sendButton = this._shadow.querySelector(".phoenix-ai-prompt-send-button");
+ const modelSelect = this._shadow.querySelector(".phoenix-ai-model-select");
+
+ // Handle textarea input to enable/disable send button
+ if (textarea && sendButton) {
+ textarea.addEventListener("input", (event) => {
+ const hasText = event.target.value.trim().length > 0;
+ sendButton.disabled = !hasText;
+ });
+
+ // enter key
+ textarea.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ if (textarea.value.trim()) {
+ this._handleSend(event, textarea.value.trim());
+ }
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ this.remove();
+ }
+ });
+ }
+
+ // send button click
+ if (sendButton) {
+ sendButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (textarea && textarea.value.trim()) {
+ this._handleSend(event, textarea.value.trim());
+ }
+ });
+ }
+
+ // model selection change
+ if (modelSelect) {
+ modelSelect.addEventListener("change", (event) => {
+ this.selectedModel = event.target.value;
+ });
+ }
+ },
+
+ _handleSend: function (event, prompt) {
+ const element = this.element;
+ if (!LivePreviewView.isElementEditable(element)) {
+ return;
+ }
+ const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ event: event,
+ element: element,
+ prompt: prompt,
+ tagId: Number(tagId),
+ selectedModel: this.selectedModel,
+ AISend: true
+ });
+ this.remove();
+ },
+
+ remove: function () {
+ if (this._handleKeydown) {
+ document.removeEventListener("keydown", this._handleKeydown);
+ this._handleKeydown = null;
+ }
+
+ if (this._handleResize) {
+ window.removeEventListener("resize", this._handleResize);
+ this._handleResize = null;
+ }
+
+ if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) {
+ window.document.body.removeChild(this.body);
+ this.body = null;
+ _aiPromptBox = null;
+ }
+ }
+};
+
+function renderAIButton(element, shadow) {
+ // right now AI is not yet integrated, so returning null
+ // when feature is ready replace the null line with the return statement below that
+ return null;
+ //return {
+ // listOrder: proConstants.TOOLBOX_ORDERING.AI,
+ // htmlContent: `
+ // ${icons.ai}
+ // `
+ //};
+}
+
+/**
+ * This function gets called when the AI button is clicked
+ * it shows a AI prompt box to the user
+ * @param {Event} event
+ * @param {DOMElement} element - the HTML DOM element that was clicked
+ */
+function handleAIOptionClick(event, element) {
+ // make sure there is no existing AI prompt box, and no other box as well
+ dismissUIAndCleanupState();
+ _aiPromptBox = new AIPromptBox(element); // create a new one
+}
+
+function dismissAIPromptBox() {
+ if (_aiPromptBox) {
+ _aiPromptBox.remove();
+ _aiPromptBox = null;
+ }
+}
+
+// Register with LivePreviewView
+LivePreviewView.registerToolHandler("ai", {
+ renderToolBoxItem: renderAIButton,
+ handleClick: handleAIOptionClick,
+ dismiss: dismissAIPromptBox
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/dragAndDrop.js b/src/extensionsIntegrated/phoenix-pro/browser-context/dragAndDrop.js
new file mode 100644
index 0000000000..6a4a45ac1e
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/dragAndDrop.js
@@ -0,0 +1,1378 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global GLOBALS, LivePreviewView, dismissUIAndCleanupState, SHARED_STATE, config*/
+
+
+// =========================== VARIABLE DECLARATION ============================
+
+const DATA_BRACKETS_ID_ATTR = GLOBALS.DATA_BRACKETS_ID_ATTR;
+const PHCODE_INTERNAL_ATTR = GLOBALS.PHCODE_INTERNAL_ATTR;
+
+// CSS class names for drop markers
+const DROP_MARKER_CLASSNAME = "__brackets-drop-marker-horizontal";
+const DROP_MARKER_VERTICAL_CLASSNAME = "__brackets-drop-marker-vertical";
+const DROP_MARKER_INSIDE_CLASSNAME = "__brackets-drop-marker-inside";
+const DROP_MARKER_ARROW_CLASSNAME = "__brackets-drop-marker-arrow";
+const DROP_MARKER_LABEL_CLASSNAME = "__brackets-drop-marker-label";
+
+// auto-scroll variables to auto scroll the live preview when an element is dragged to the top/bottom
+const AUTO_SCROLL_SPEED = 12; // pixels per scroll
+const AUTO_SCROLL_EDGE_SIZE = 0.05; // 5% of viewport height (either top/bottom)
+let autoScrollTimer = null;
+
+// track the last drag target and drop zone to detect when they change
+let lastDragTarget = null;
+let lastDropZone = null;
+
+
+
+// ============================ DRAG-DROP VALIDATION ============================
+// this functions are responsible to check whether the drop location is a valid one or not
+// without validation: users may drop the dragged element at places which invalidates the HTML structure
+// this might cause live preview to break, so we have validation logic
+// so that we only show the drop markers at places which won't break the HTML structure
+
+/**
+ * determine whether this dragged over (target) element can accept children (to show 'inside' drop marker)
+ *
+ * @param {DOMElement} element - the dragged over (target) element
+ * @returns {Boolean} true if element can accept children otherwise false
+ */
+function canAcceptChildren(element) {
+ // self-closing elements, cannot have children
+ const voidElements = [
+ "IMG", "BR", "HR", "INPUT", "META", "LINK", "AREA", "BASE", "COL", "EMBED", "SOURCE", "TRACK", "WBR"
+ ];
+
+ // these elements shouldn't accept visual children
+ const nonContainerElements = [
+ "SCRIPT", "STYLE", "NOSCRIPT", "CANVAS", "SVG", "VIDEO", "AUDIO", "IFRAME", "OBJECT"
+ ];
+
+ const tagName = element.tagName.toUpperCase();
+ if (voidElements.includes(tagName) || nonContainerElements.includes(tagName)) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * it is to check if a source element can be placed inside a target element according to HTML rules
+ *
+ * @param {DOMElement} sourceElement - The element being dragged
+ * @param {DOMElement} targetElement - The target container element
+ * @returns {Boolean} true if the nesting is valid otherwise false
+ */
+function isValidNesting(sourceElement, targetElement) {
+ const sourceTag = sourceElement.tagName.toUpperCase();
+ const targetTag = targetElement.tagName.toUpperCase();
+
+ // block elements, cannot come inside inline elements
+ const blockElements = [
+ "DIV", "P", "H1", "H2", "H3", "H4", "H5", "H6", "SECTION", "ARTICLE", "HEADER", "FOOTER", "NAV",
+ "ASIDE", "MAIN", "BLOCKQUOTE", "PRE", "TABLE", "UL", "OL", "LI", "DL", "DT", "DD", "FORM", "FIELDSET",
+ "ADDRESS", "FIGURE", "FIGCAPTION", "DETAILS", "SUMMARY"
+ ];
+
+ // inline elements that can't contain block elements
+ const inlineElements = [
+ "SPAN", "A", "STRONG", "EM", "B", "I", "U", "SMALL", "CODE", "KBD", "SAMP", "VAR", "SUB", "SUP", "MARK",
+ "DEL", "INS", "Q", "CITE", "ABBR", "TIME", "DATA", "OUTPUT"
+ ];
+
+ // interactive elements that can't be nested inside each other
+ const interactiveElements = [
+ "A", "BUTTON", "INPUT", "SELECT", "TEXTAREA", "LABEL", "DETAILS", "SUMMARY", "AUDIO", "VIDEO", "EMBED",
+ "IFRAME", "OBJECT"
+ ];
+
+ // Sectioning content - semantic HTML5 sections
+ const sectioningContent = ["ARTICLE", "ASIDE", "NAV", "SECTION"];
+
+ // Elements that can't contain themselves (prevent nesting)
+ const noSelfNesting = [
+ "P", "A", "BUTTON", "LABEL", "FORM", "HEADER", "FOOTER", "NAV", "MAIN", "ASIDE", "SECTION", "ARTICLE",
+ "ADDRESS", "H1", "H2", "H3", "H4", "H5", "H6", "FIGURE", "FIGCAPTION", "DETAILS", "SUMMARY"
+ ];
+
+ // Special cases - elements that have specific content restrictions
+ const restrictedContainers = {
+ // List elements
+ UL: ["LI"],
+ OL: ["LI"],
+ DL: ["DT", "DD"],
+
+ // Table elements
+ TABLE: ["THEAD", "TBODY", "TFOOT", "TR", "CAPTION", "COLGROUP"],
+ THEAD: ["TR"],
+ TBODY: ["TR"],
+ TFOOT: ["TR"],
+ TR: ["TD", "TH"],
+ COLGROUP: ["COL"],
+
+ // Form elements
+ SELECT: ["OPTION", "OPTGROUP"],
+ OPTGROUP: ["OPTION"],
+ DATALIST: ["OPTION"],
+
+ // Media elements
+ PICTURE: ["SOURCE", "IMG"],
+ AUDIO: ["SOURCE", "TRACK"],
+ VIDEO: ["SOURCE", "TRACK"],
+
+ // Other specific containers
+ FIGURE: ["FIGCAPTION", "DIV", "P", "IMG", "CANVAS", "SVG", "TABLE", "PRE", "CODE"],
+ DETAILS: ["SUMMARY"] // SUMMARY should be the first child
+ };
+
+ // 1. Check self-nesting (elements that can't contain themselves)
+ if (noSelfNesting.includes(sourceTag) && sourceTag === targetTag) {
+ return false;
+ }
+
+ // 2. Check block elements inside inline elements
+ if (blockElements.includes(sourceTag) && inlineElements.includes(targetTag)) {
+ return false;
+ }
+
+ // 3. Check restricted containers (strict parent-child relationships)
+ if (restrictedContainers[targetTag]) {
+ return restrictedContainers[targetTag].includes(sourceTag);
+ }
+
+ // 4. Special case: P tags can't contain block elements (phrasing content only)
+ if (targetTag === "P" && blockElements.includes(sourceTag)) {
+ return false;
+ }
+
+ // 5. Interactive elements can't contain other interactive elements
+ if (interactiveElements.includes(targetTag) && interactiveElements.includes(sourceTag)) {
+ return false;
+ }
+
+ // 6. Semantic HTML5 sectioning rules
+ if (targetTag === "HEADER") {
+ // Header can't contain other headers, footers, or main
+ if (["HEADER", "FOOTER", "MAIN"].includes(sourceTag)) {
+ return false;
+ }
+ }
+
+ if (targetTag === "FOOTER") {
+ // Footer can't contain headers, footers, or main
+ if (["HEADER", "FOOTER", "MAIN"].includes(sourceTag)) {
+ return false;
+ }
+ }
+
+ if (targetTag === "MAIN") {
+ // Main can't contain other mains
+ if (sourceTag === "MAIN") {
+ return false;
+ }
+ }
+
+ if (targetTag === "ADDRESS") {
+ // Address can't contain sectioning content, headers, footers, or address
+ if (sectioningContent.includes(sourceTag) || ["HEADER", "FOOTER", "ADDRESS", "MAIN"].includes(sourceTag)) {
+ return false;
+ }
+ }
+
+ // 7. Form-related validation
+ if (targetTag === "FORM") {
+ // Form can't contain other forms
+ if (sourceTag === "FORM") {
+ return false;
+ }
+ }
+
+ if (targetTag === "FIELDSET") {
+ // Fieldset should have legend as first child (but we'll allow it anywhere for flexibility)
+ // No specific restrictions beyond normal content
+ }
+
+ if (targetTag === "LABEL") {
+ // Label can't contain other labels or form controls (except one input)
+ if (["LABEL", "BUTTON", "SELECT", "TEXTAREA"].includes(sourceTag)) {
+ return false;
+ }
+ }
+
+ // 8. Heading hierarchy validation (optional - can be strict or flexible)
+ if (["H1", "H2", "H3", "H4", "H5", "H6"].includes(targetTag)) {
+ // Headings can't contain block elements (should only contain phrasing content)
+ if (blockElements.includes(sourceTag)) {
+ return false;
+ }
+ }
+
+ // 9. List item specific rules
+ if (sourceTag === "LI") {
+ // LI can only be inside UL, OL, or MENU
+ if (!["UL", "OL", "MENU"].includes(targetTag)) {
+ return false;
+ }
+ }
+
+ if (["DT", "DD"].includes(sourceTag)) {
+ // DT and DD can only be inside DL
+ if (targetTag !== "DL") {
+ return false;
+ }
+ }
+
+ // 10. Table-related validation
+ if (["THEAD", "TBODY", "TFOOT"].includes(sourceTag)) {
+ if (targetTag !== "TABLE") {
+ return false;
+ }
+ }
+
+ if (sourceTag === "TR") {
+ if (!["TABLE", "THEAD", "TBODY", "TFOOT"].includes(targetTag)) {
+ return false;
+ }
+ }
+
+ if (["TD", "TH"].includes(sourceTag)) {
+ if (targetTag !== "TR") {
+ return false;
+ }
+ }
+
+ if (sourceTag === "CAPTION") {
+ if (targetTag !== "TABLE") {
+ return false;
+ }
+ }
+
+ // 11. Media and embedded content
+ if (["SOURCE", "TRACK"].includes(sourceTag)) {
+ if (!["AUDIO", "VIDEO", "PICTURE"].includes(targetTag)) {
+ return false;
+ }
+ }
+
+ // 12. Ruby annotation elements (if supported)
+ if (["RP", "RT"].includes(sourceTag)) {
+ if (targetTag !== "RUBY") {
+ return false;
+ }
+ }
+
+ // 13. Option elements
+ if (sourceTag === "OPTION") {
+ if (!["SELECT", "OPTGROUP", "DATALIST"].includes(targetTag)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+
+
+// ============================ AUTO SCROLLING RELATED FUNCTIONS ============================
+// auto scrolling happens when user drags an element to the edge of the viewport (top/bottom)
+
+function stopAutoScroll() {
+ if (autoScrollTimer) {
+ clearInterval(autoScrollTimer);
+ autoScrollTimer = null;
+ }
+ SHARED_STATE.isAutoScrolling = false;
+}
+
+/**
+ * TODO: we may need to add support for horizontal scroll too in the future
+ * param {Number} clientY - to scroll the live preview vertically
+ */
+function startAutoScroll(clientY) {
+ // to clear any existing timers
+ stopAutoScroll();
+
+ const viewportHeight = window.innerHeight;
+ const scrollEdgeSize = viewportHeight * AUTO_SCROLL_EDGE_SIZE;
+ let scrollDirection = 0;
+
+ // check if we need to scroll
+ if (clientY <= scrollEdgeSize) {
+ // top edge (scroll up)
+ scrollDirection = -AUTO_SCROLL_SPEED;
+ } else if (clientY >= viewportHeight - scrollEdgeSize) {
+ // bottom edge (scroll down)
+ scrollDirection = AUTO_SCROLL_SPEED;
+ }
+
+ // start scrolling if scroll direction is not equal to 0
+ if (scrollDirection !== 0) {
+ if (!SHARED_STATE.isAutoScrolling) {
+ clearDropMarkers();
+ }
+ SHARED_STATE.isAutoScrolling = true;
+ autoScrollTimer = setInterval(() => {
+ window.scrollBy(0, scrollDirection);
+ }, 16); // 16 is ~60fps
+ } else {
+ stopAutoScroll();
+ }
+}
+
+
+
+
+// ============================ DRAG-DROP HELPER FUNCTIONS ============================
+
+/**
+ * this gets called when drag starts to add opacity to the dragged element
+ * @param {DOMElement} element - the dragged element
+ */
+function dragStartChores(element) {
+ element._originalDragOpacity = element.style.opacity;
+ element.style.opacity = 0.4;
+}
+
+/**
+ * this gets called when drag ends to set the original opacity of the element back and clear all the values
+ * @param {DOMElement} element - the dragged element
+ */
+function dragEndChores(element) {
+ if (element._originalDragOpacity) {
+ element.style.opacity = element._originalDragOpacity;
+ } else {
+ element.style.opacity = 1;
+ }
+ delete element._originalDragOpacity;
+ lastDragTarget = null;
+ lastDropZone = null;
+}
+
+/**
+ * This function is responsible to determine whether to show vertical/horizontal indicators
+ * horizontal indicator (---) are shown for non-flex elements that are arranged column wise (default)
+ * vertical indicator (|) are shown for flex type elements which are arranged in row order
+ *
+ * @param {DOMElement} element - the target element
+ * @returns {String} 'vertical' or 'horizontal'
+ */
+function getIndicatorType(element) {
+ // we need to check the parent element's property if its a flex container
+ const parent = element.parentElement;
+ if (!parent) {
+ return 'horizontal';
+ }
+
+ const parentStyle = window.getComputedStyle(parent);
+ const display = parentStyle.display;
+ const flexDirection = parentStyle.flexDirection;
+
+ if ((display === "flex" || display === "inline-flex") && flexDirection.startsWith("row")) {
+ return "vertical";
+ }
+
+ return 'horizontal';
+}
+
+/**
+ * this function determines the drop zone based on cursor position relative to element
+ * whether to drop before (top) the element, inside it or after (below) the element
+ *
+ * @param {DOMElement} element - The target element
+ * @param {Number} clientX - x pos
+ * @param {Number} clientY - y pos
+ * @param {String} indicatorType - 'vertical' or 'horizontal'
+ * @param {DOMElement} sourceElement - The element being dragged (for validation)
+ * @returns {String} 'before', 'inside', or 'after'
+ */
+function getDropZone(element, clientX, clientY, indicatorType, sourceElement) {
+ const rect = element.getBoundingClientRect();
+ const childrenPossible = canAcceptChildren(element);
+ const nestingPossible = sourceElement ? isValidNesting(sourceElement, element) : true;
+
+ if (indicatorType === "vertical") {
+ const leftThird = rect.left + rect.width * 0.3;
+ const rightThird = rect.right - rect.width * 0.3;
+
+ if (clientX < leftThird) {
+ return "before";
+ } else if (clientX > rightThird) {
+ return "after";
+ } else if (childrenPossible && nestingPossible) {
+ return "inside";
+ }
+ // If can't accept children or invalid nesting, use middle as "after"
+ return clientX < rect.left + rect.width / 2 ? "before" : "after";
+ }
+
+ const topThird = rect.top + rect.height * 0.3;
+ const bottomThird = rect.bottom - rect.height * 0.3;
+
+ if (clientY < topThird) {
+ return "before";
+ } else if (clientY > bottomThird) {
+ return "after";
+ } else if (childrenPossible && nestingPossible) {
+ return "inside";
+ }
+ // If can't accept children or invalid nesting, use middle as "after"
+ return clientY < rect.top + rect.height / 2 ? "before" : "after";
+}
+
+/**
+ * this function is to check whether an element is a semantic element or not
+ * @param {DOMElement} element - the element that we need to check
+ * @returns {Boolean} - true when its semantic otherwise false
+ */
+function isSemanticElement(element) {
+ const semanticTags = [
+ 'div', 'section', 'article', 'aside', 'header', 'footer', 'main', 'nav',
+ 'form', 'fieldset', 'details', 'figure', 'ul', 'ol', 'dl', 'table', 'tbody',
+ 'thead', 'tfoot', 'tr', 'blockquote', 'pre', 'address'
+ ];
+ return semanticTags.includes(element.tagName.toLowerCase());
+}
+
+/**
+ * to check whether the 3 edges are aligned or not based on the drop zone
+ * for ex: if arrow marker is shown for the top, then top + left + right egde should align
+ * for bottom: bottom + left + right
+ * this means that there might be a possibility that the child element is blocking access of the parent element
+ *
+ * @param {DOMElement} child
+ * @param {DOMElement} parent
+ * @param {String} dropZone - "before" or "after" (not called for "inside")
+ * @returns {Boolean} - true if matches
+ */
+function hasThreeEdgesAligned(child, parent, dropZone) {
+ const tolerance = 2; // 2px tolerance
+ const childRect = child.getBoundingClientRect();
+ const parentRect = parent.getBoundingClientRect();
+
+ const topAligned = Math.abs(childRect.top - parentRect.top) <= tolerance;
+ const bottomAligned = Math.abs(childRect.bottom - parentRect.bottom) <= tolerance;
+ const leftAligned = Math.abs(childRect.left - parentRect.left) <= tolerance;
+ const rightAligned = Math.abs(childRect.right - parentRect.right) <= tolerance;
+
+ // For "before" drops (top), check: top + left + right (bottom doesn't matter)
+ // For "after" drops (bottom), check: bottom + left + right (top doesn't matter)
+ if (dropZone === "before") {
+ return topAligned && leftAligned && rightAligned;
+ } else if (dropZone === "after") {
+ return bottomAligned && leftAligned && rightAligned;
+ }
+
+ return false;
+}
+
+/**
+ * this function is responsible to get all the elements that could be the users possible drop targets
+ * @param {DOMElement} target - the element to start with
+ * @returns {Array} - array of all the possible elements (closest -> farthest)
+ */
+function getAllValidParentCandidates(target) {
+ const parents = [];
+ let current = target.parentElement;
+
+ while (current) {
+ if (current.hasAttribute(DATA_BRACKETS_ID_ATTR) && LivePreviewView.isElementEditable(current)) {
+ parents.push(current);
+ }
+
+ // need to stop at body or html
+ if (current.tagName.toLowerCase() === 'body' ||
+ current.tagName.toLowerCase() === 'html') {
+ break;
+ }
+
+ current = current.parentElement;
+ }
+
+ return parents;
+}
+
+/**
+ * this function decides which target should we select based on the list of the candidates
+ * so the total stable area (stable area: where marker remains consistent) will be divided by the no. of candidates
+ * and then based on the mouse pointer location we see which candidate is currently positioned and then return it
+ *
+ * @param {Element} initialTarget - the base element
+ * @param {Array} candidates - all the possible valid targets
+ * @param {number} clientX
+ * @param {number} clientY
+ * @param {string} indicatorType - "horizontal" or "vertical"
+ * @param {string} dropZone - "before", "after", or "inside"
+ * @returns {Element} - The selected target based on section
+ */
+function selectTargetFromSection(initialTarget, candidates, clientX, clientY, indicatorType, dropZone) {
+ // when only one candidate is available or the drop zone is inside, we just return it
+ if (candidates.length === 1 || dropZone === 'inside') {
+ return candidates[0];
+ }
+
+ const rect = initialTarget.getBoundingClientRect();
+ let relativePosition;
+
+ // for horz, we divide the height by n no. of candidates
+ if (indicatorType === "horizontal") {
+ const height = rect.height;
+ const stableAreaSize = height * 0.3; // 0.3 is the drop zone threshold
+
+ if (dropZone === "before") {
+ // for top arrow: stable area is top 30% of height
+ const stableAreaStart = rect.top;
+ relativePosition = (clientY - stableAreaStart) / stableAreaSize;
+ } else {
+ // for bottom arrow: stable area is bottom 30% of height
+ const stableAreaStart = rect.bottom - stableAreaSize;
+ relativePosition = (clientY - stableAreaStart) / stableAreaSize;
+ }
+ } else {
+ // for vertical, we divide the width by n
+ const width = rect.width;
+ const stableAreaSize = width * 0.3;
+
+ if (dropZone === "before") {
+ // left arrow: stable area is left 30% of width
+ const stableAreaStart = rect.left;
+ relativePosition = (clientX - stableAreaStart) / stableAreaSize;
+ } else {
+ // right arrow: stable area is right 30% of width
+ const stableAreaStart = rect.right - stableAreaSize;
+ relativePosition = (clientX - stableAreaStart) / stableAreaSize;
+ }
+ }
+
+ relativePosition = Math.max(0, Math.min(1, relativePosition));
+ // for "before" markers (top/left arrows), reverse the candidate order
+ // because innermost should be at bottom/right of stable area
+ let orderedCandidates = candidates;
+ if (dropZone === "before") {
+ orderedCandidates = [...candidates].reverse();
+ }
+
+ // find out which section the mouse is in
+ const sectionIndex = Math.floor(relativePosition * orderedCandidates.length);
+ // clamp the index to valid range (handles edge case where relativePosition = 1)
+ const clampedIndex = Math.min(sectionIndex, orderedCandidates.length - 1);
+
+ return orderedCandidates[clampedIndex];
+}
+
+/**
+ * this function gets the list of all the valid target candidates
+ * below are the 3 cases to satisfy to be a valid target candidate:
+ * - Same arrow type (indicator type)
+ * - 3 edges aligned based on drop zone
+ * - Semantic element
+ *
+ * @param {Element} initialTarget - base element
+ * @param {string} dropZone - "before", "after", or "inside"
+ * @param {string} indicatorType - "horizontal" or "vertical"
+ * @returns {Array} - list of valid candidates [initialTarget, ...validParents]
+ */
+function getValidTargetCandidates(initialTarget, dropZone, indicatorType) {
+ // for inside we don't need to check
+ if (dropZone === "inside") {
+ return [initialTarget];
+ }
+
+ const allParents = getAllValidParentCandidates(initialTarget);
+
+ const validParents = allParents.filter(parent => {
+ if (!isSemanticElement(parent)) {
+ return false;
+ }
+
+ const parentIndicatorType = getIndicatorType(parent);
+ if (parentIndicatorType !== indicatorType) {
+ return false;
+ }
+
+ return hasThreeEdgesAligned(initialTarget, parent, dropZone);
+ });
+
+ return [initialTarget, ...validParents];
+}
+
+/**
+ * this function is for finding the best target element on where to drop the dragged element
+ * for ex: div > image...here both the div and image are of the exact same size, then when user is dragging some
+ * other element, then almost everytime they want to drop it before/after the div and not like div>newEle+img
+ *
+ * @param {Element} target - The current target element
+ * @returns {Element|null} - The outermost parent with all edges aligned, or null
+ */
+function findBestParentTarget(target) {
+ if (!target) {
+ return null;
+ }
+
+ const tolerance = 1; // 1px is considered same
+ let bestParent = null;
+ let currentElement = target;
+ let parent = currentElement.parentElement;
+
+ while (parent) {
+ if (parent.hasAttribute(DATA_BRACKETS_ID_ATTR) && LivePreviewView.isElementEditable(parent)) {
+ const currentRect = currentElement.getBoundingClientRect();
+ const parentRect = parent.getBoundingClientRect();
+
+ // check if all the edges are same
+ const topAligned = Math.abs(currentRect.top - parentRect.top) <= tolerance;
+ const bottomAligned = Math.abs(currentRect.bottom - parentRect.bottom) <= tolerance;
+ const leftAligned = Math.abs(currentRect.left - parentRect.left) <= tolerance;
+ const rightAligned = Math.abs(currentRect.right - parentRect.right) <= tolerance;
+
+ if (topAligned && bottomAligned && leftAligned && rightAligned) {
+ // all edges match, we prefer the parent element
+ bestParent = parent;
+ currentElement = parent;
+ } else {
+ break;
+ }
+ }
+ parent = parent.parentElement;
+ }
+
+ return bestParent;
+}
+
+/**
+ * Find the nearest valid drop target when direct elementFromPoint fails
+ *
+ * @param {number} clientX - x coordinate
+ * @param {number} clientY - y coordinate
+ * @returns {Element|null} - nearest valid target or null
+ */
+function findNearestValidTarget(clientX, clientY) {
+ const searchRadius = 500;
+ const step = 10; // pixel step for search
+
+ // Search in expanding squares around the cursor position
+ for (let radius = step; radius <= searchRadius; radius += step) {
+ // Check points in a square pattern around the cursor
+ const points = [
+ [clientX + radius, clientY],
+ [clientX - radius, clientY],
+ [clientX, clientY + radius],
+ [clientX, clientY - radius],
+ [clientX + radius, clientY + radius],
+ [clientX - radius, clientY - radius],
+ [clientX + radius, clientY - radius],
+ [clientX - radius, clientY + radius]
+ ];
+
+ for (let point of points) {
+ const [x, y] = point;
+ let target = document.elementFromPoint(x, y);
+
+ if (!target || target === SHARED_STATE._currentDraggedElement) {
+ continue;
+ }
+
+ // Find closest editable element
+ while (target && !target.hasAttribute(DATA_BRACKETS_ID_ATTR)) {
+ target = target.parentElement;
+ }
+
+ // Check if target is valid (not BODY, HTML or inside HEAD)
+ if (LivePreviewView.isElementEditable(target) && target !== SHARED_STATE._currentDraggedElement) {
+ return target;
+ }
+ }
+ }
+ return null;
+}
+
+
+
+
+// ============================ DRAG-DROP LABEL ============================
+// when an element is dragged, we show the label for the element over which user is currently dragging over
+// label contains the tag name, class and id.
+// this helps a lot in identifying which element is currently being dragged over so users know where to drop
+
+/**
+ * this function creates the HTML content for the drop target label
+ * tagname is shown in bold, id in normal font and class in italics
+ *
+ * @param {DOMElement} element - The target element
+ * @returns {String} - HTML string with tag, ID, and classes
+ */
+function createLabelContent(element) {
+ const tagName = element.tagName.toLowerCase();
+ const id = element.id.trim();
+ const classList = Array.from(element.classList || []);
+
+ // TAG NAME HANDLING
+ let content = `${tagName}`;
+
+ // ID HANDLING
+ if (id) {
+ // for ids we have a max char limit of 15
+ if (id.length >= 15) {
+ content += `#${id.substring(0, 15)}...`;
+ } else {
+ content += `#${id}`;
+ }
+ }
+
+ // CLASS HANDLING
+ if (classList.length > 0) {
+ // max 3 classes
+ let classStr = classList.slice(0, 3).join('.');
+
+ // max char limit (30 chars for all 3 classes)
+ if (classStr.length > 30) {
+ classStr = classStr.substring(0, 27) + '...';
+ }
+
+ content += `.${classStr}`;
+ }
+
+ return content;
+}
+
+/**
+ * create the drop target label box
+ * this box will be shown at the side of the arrow marker
+ * it will show the tagname, class name and id name of the dropped target
+ *
+ * @param {DOMElement} element - The target element
+ * @param {DOMElement} arrow - The arrow marker element (we need this as we show label next to it)
+ * @returns {DOMElement} - The label element
+ */
+function createDropLabel(element, arrow) {
+ const label = window.document.createElement('div');
+ label.className = DROP_MARKER_LABEL_CLASSNAME;
+ label.setAttribute(PHCODE_INTERNAL_ATTR, 'true');
+ label.innerHTML = createLabelContent(element);
+
+ label.style.position = 'fixed';
+ label.style.zIndex = '2147483647';
+ label.style.backgroundColor = '#2c2c2c';
+ label.style.color = '#cdcdcd';
+ label.style.padding = '4px 8px';
+ label.style.borderRadius = '6px';
+ label.style.fontSize = '10px';
+ label.style.fontFamily = 'monospace';
+ label.style.whiteSpace = 'nowrap';
+ label.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.3)';
+ label.style.pointerEvents = 'none';
+
+ // append temporarily for dimensions calculations
+ window.document.body.appendChild(label);
+ const labelRect = label.getBoundingClientRect();
+ const arrowRect = arrow.getBoundingClientRect();
+
+ // default position: 10px to the right of arrow, vertically centered
+ let labelLeft = arrowRect.right + 7.5;
+ let labelTop = arrowRect.top + (arrowRect.height / 2) - (labelRect.height / 2);
+
+ // add 3px offset for horizontal arrows to prevent touching the line
+ const arrowChar = arrow.innerHTML;
+ if (arrowChar === '↑') {
+ labelTop -= 3;
+ } else if (arrowChar === '↓') {
+ labelTop += 3;
+ }
+
+ // if right side overflows, we switch to the left side
+ if (labelLeft + labelRect.width > window.innerWidth) {
+ labelLeft = arrowRect.left - labelRect.width - 10;
+
+ // if left side also overflows then we switch back to the right side
+ if (labelLeft < 0) {
+ labelLeft = arrowRect.right + 7.5;
+ }
+ }
+
+ label.style.left = labelLeft + 'px';
+ label.style.top = labelTop + 'px';
+
+ return label;
+}
+
+
+
+
+// ============================ DRAG-DROP MARKERS (ARROWS) ============================
+// when an element is dragged, we show the markers (arrows) for the element over which user is currently dragging over
+// like labels this also helps a lot in identifying over which element user is currently dragging over
+// arrows can be of multiple types: ↑, ↓, ←, →, ⊕(inside)
+
+
+/**
+ * this is to create a marker to indicate a valid drop position
+ *
+ * @param {DOMElement} element - The element where the drop is possible
+ * @param {String} dropZone - 'before', 'inside', or 'after'
+ * @param {String} indicatorType - 'vertical' or 'horizontal'
+ */
+function createDropMarker(element, dropZone, indicatorType = "horizontal") {
+ // clean any existing marker from that element
+ removeDropMarkerFromElement(element);
+
+ if (element._originalDragBackgroundColor === undefined) {
+ element._originalDragBackgroundColor = element.style.backgroundColor;
+ }
+ element.style.backgroundColor = "rgba(66, 133, 244, 0.22)";
+
+ // when not dropping inside we add a light outline
+ // because for inside we already show a thick dashed border
+ if (dropZone !== "inside") {
+ if (element._originalDragOutline === undefined) {
+ element._originalDragOutline = element.style.outline;
+ }
+ element.style.outline = "1px solid #4285F4";
+ }
+
+ // create the marker element
+ let marker = window.document.createElement("div");
+
+ // Set marker class based on drop zone
+ if (dropZone === "inside") {
+ marker.className = DROP_MARKER_INSIDE_CLASSNAME;
+ } else {
+ marker.className = indicatorType === "vertical" ? DROP_MARKER_VERTICAL_CLASSNAME : DROP_MARKER_CLASSNAME;
+ }
+
+ let rect = element.getBoundingClientRect();
+ marker.style.position = "fixed";
+ marker.style.zIndex = "2147483647";
+ marker.style.borderRadius = "2px";
+ marker.style.pointerEvents = "none";
+
+ // for the arrow indicator
+ let arrow = window.document.createElement("div");
+ arrow.className = DROP_MARKER_ARROW_CLASSNAME;
+ arrow.style.position = "fixed";
+ arrow.style.zIndex = "2147483647";
+ arrow.style.pointerEvents = "none";
+ arrow.style.fontWeight = "bold";
+ arrow.style.color = "#4285F4";
+
+ if (dropZone === "inside") {
+ // inside marker - outline around the element
+ marker.style.border = "2px dashed #4285F4";
+ marker.style.backgroundColor = "rgba(66, 133, 244, 0.05)";
+ marker.style.left = rect.left + "px";
+ marker.style.top = rect.top + "px";
+ marker.style.width = rect.width + "px";
+ marker.style.height = rect.height + "px";
+
+ // exclusive or symbol (plus inside circle) we use when dropping inside
+ arrow.style.fontSize = "16px";
+ arrow.innerHTML = "⊕";
+ arrow.style.left = (rect.left + rect.width / 2) + "px";
+ arrow.style.top = (rect.top + rect.height / 2) + "px";
+ arrow.style.transform = "translate(-50%, -50%)";
+ } else {
+ // Before/After markers - lines
+ marker.style.background = "linear-gradient(90deg, #4285F4, #1976D2)";
+ marker.style.boxShadow = "0 0 8px rgba(66, 133, 244, 0.5)";
+
+ arrow.style.fontSize = "22px";
+ if (indicatorType === "vertical") {
+ // Vertical marker (for flex row containers)
+ marker.style.width = "3px";
+ marker.style.height = rect.height + "px";
+ marker.style.top = rect.top + "px";
+ arrow.style.top = (rect.top + rect.height / 2) + "px";
+
+ if (dropZone === "after") {
+ marker.style.left = rect.right + 3 + "px";
+ // Right arrow
+ arrow.innerHTML = "→";
+ arrow.style.left = (rect.right + 5) + "px";
+ arrow.style.transform = "translateY(-50%)";
+ } else {
+ marker.style.left = rect.left - 5 + "px";
+ // Left arrow
+ arrow.innerHTML = "←";
+ arrow.style.left = (rect.left - 15) + "px";
+ arrow.style.transform = "translate(-50%, -50%)";
+ }
+ } else {
+ // Horizontal marker (for block/grid containers)
+ marker.style.width = rect.width + "px";
+ marker.style.height = "3px";
+ marker.style.left = rect.left + "px";
+ arrow.style.left = (rect.left + rect.width / 2) + "px";
+
+ if (dropZone === "after") {
+ marker.style.top = rect.bottom + 3 + "px";
+ // Down arrow
+ arrow.innerHTML = "↓";
+ arrow.style.top = rect.bottom + "px";
+ arrow.style.transform = "translateX(-50%)";
+ } else {
+ marker.style.top = rect.top - 5 + "px";
+ // Up arrow
+ arrow.innerHTML = "↑";
+ arrow.style.top = (rect.top - 15) + "px";
+ arrow.style.transform = "translate(-50%, -50%)";
+ }
+ }
+ }
+
+ element._dropMarker = marker; // we need this in the _removeDropMarkerFromElement function
+ element._dropArrow = arrow; // store arrow reference too
+ window.document.body.appendChild(marker);
+ window.document.body.appendChild(arrow);
+
+ // create and append the drop target label
+ const label = createDropLabel(element, arrow);
+ element._dropLabel = label;
+}
+
+/**
+ * This function removes a drop marker from a specific element
+ * @param {DOMElement} element - The element to remove the marker from
+ */
+function removeDropMarkerFromElement(element) {
+ if (element._dropMarker && element._dropMarker.parentNode) {
+ element._dropMarker.parentNode.removeChild(element._dropMarker);
+ delete element._dropMarker;
+ }
+ if (element._dropArrow && element._dropArrow.parentNode) {
+ element._dropArrow.parentNode.removeChild(element._dropArrow);
+ delete element._dropArrow;
+ }
+ if (element._dropLabel && element._dropLabel.parentNode) {
+ element._dropLabel.parentNode.removeChild(element._dropLabel);
+ delete element._dropLabel;
+ }
+}
+
+/**
+ * this function is to clear all the drop markers from the document
+ */
+function clearDropMarkers() {
+ // Clear all types of markers
+ let horizontalMarkers = window.document.querySelectorAll("." + DROP_MARKER_CLASSNAME);
+ let verticalMarkers = window.document.querySelectorAll("." + DROP_MARKER_VERTICAL_CLASSNAME);
+ let insideMarkers = window.document.querySelectorAll("." + DROP_MARKER_INSIDE_CLASSNAME);
+ let arrows = window.document.querySelectorAll("." + DROP_MARKER_ARROW_CLASSNAME);
+ let labels = window.document.querySelectorAll("." + DROP_MARKER_LABEL_CLASSNAME);
+
+ for (let i = 0; i < horizontalMarkers.length; i++) {
+ if (horizontalMarkers[i].parentNode) {
+ horizontalMarkers[i].parentNode.removeChild(horizontalMarkers[i]);
+ }
+ }
+
+ for (let i = 0; i < verticalMarkers.length; i++) {
+ if (verticalMarkers[i].parentNode) {
+ verticalMarkers[i].parentNode.removeChild(verticalMarkers[i]);
+ }
+ }
+
+ for (let i = 0; i < insideMarkers.length; i++) {
+ if (insideMarkers[i].parentNode) {
+ insideMarkers[i].parentNode.removeChild(insideMarkers[i]);
+ }
+ }
+
+ for (let i = 0; i < arrows.length; i++) {
+ if (arrows[i].parentNode) {
+ arrows[i].parentNode.removeChild(arrows[i]);
+ }
+ }
+
+ for (let i = 0; i < labels.length; i++) {
+ if (labels[i].parentNode) {
+ labels[i].parentNode.removeChild(labels[i]);
+ }
+ }
+
+ // Also clear any element references
+ let elements = window.document.querySelectorAll(`[${DATA_BRACKETS_ID_ATTR}]`);
+ for (let j = 0; j < elements.length; j++) {
+ delete elements[j]._dropMarker;
+ delete elements[j]._dropArrow;
+ delete elements[j]._dropLabel;
+ // only restore the styles that were modified by drag operations
+ if (elements[j]._originalDragBackgroundColor !== undefined) {
+ elements[j].style.backgroundColor = elements[j]._originalDragBackgroundColor;
+ delete elements[j]._originalDragBackgroundColor;
+ }
+ if (elements[j]._originalDragOutline !== undefined) {
+ elements[j].style.outline = elements[j]._originalDragOutline;
+ delete elements[j]._originalDragOutline;
+ }
+ if (elements[j]._originalDragTransform !== undefined) {
+ elements[j].style.transform = elements[j]._originalDragTransform;
+ delete elements[j]._originalDragTransform;
+ }
+ if (elements[j]._originalDragTransition !== undefined) {
+ elements[j].style.transition = elements[j]._originalDragTransition;
+ delete elements[j]._originalDragTransition;
+ }
+ }
+}
+
+
+
+
+// ============================ DRAG-DROP EVENT HANDLERS FOR HTML ELEMENTS ============================
+
+function onDragStart(event, element) {
+ // if the element is currently being edited then we don't want drag to operate
+ if (SHARED_STATE._currentlyEditingElement === element) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ event.stopPropagation();
+ event.dataTransfer.setData("text/plain", element.getAttribute(DATA_BRACKETS_ID_ATTR));
+ SHARED_STATE._dragStartX = event.clientX;
+ SHARED_STATE._dragStartY = event.clientY;
+ dragStartChores(element);
+ clearDropMarkers();
+ SHARED_STATE._currentDraggedElement = element;
+ dismissUIAndCleanupState();
+ event.dataTransfer.effectAllowed = "move";
+}
+
+function onDragEnd(event, element) {
+ event.preventDefault();
+ event.stopPropagation();
+ dragEndChores(element);
+ clearDropMarkers();
+ stopAutoScroll();
+ delete SHARED_STATE._currentDraggedElement;
+ delete SHARED_STATE._dragStartX;
+ delete SHARED_STATE._dragStartY;
+}
+
+function onDragOver(event) {
+ // we set this on dragStart
+ if (!SHARED_STATE._currentDraggedElement) {
+ return;
+ }
+
+ event.preventDefault();
+
+ // get the element under the cursor
+ let target = document.elementFromPoint(event.clientX, event.clientY);
+ if (!target || target === SHARED_STATE._currentDraggedElement) {
+ return;
+ }
+
+ // get the closest editable element
+ while (target && !target.hasAttribute(DATA_BRACKETS_ID_ATTR)) {
+ target = target.parentElement;
+ }
+
+ if (!LivePreviewView.isElementEditable(target) || target === SHARED_STATE._currentDraggedElement) {
+ if (SHARED_STATE.isAutoScrolling) { return; }
+ // if direct detection fails, we try to find a nearby valid target
+ target = findNearestValidTarget(event.clientX, event.clientY);
+ if (!target) { return; }
+ }
+
+ // Check if we should prefer a parent when all edges are aligned
+ const bestParent = findBestParentTarget(target);
+ const initialTarget = bestParent || target;
+
+ // calc indicator type and drop zone for initial target
+ const indicatorType = getIndicatorType(initialTarget);
+ const dropZone = getDropZone(
+ initialTarget, event.clientX, event.clientY, indicatorType, SHARED_STATE._currentDraggedElement
+ );
+
+ // get the list of all the valid candidates for section selection
+ const candidates = getValidTargetCandidates(initialTarget, dropZone, indicatorType);
+
+ // select the target based on mouse position within sections
+ const selectedTarget = selectTargetFromSection(
+ initialTarget, candidates, event.clientX, event.clientY, indicatorType, dropZone
+ );
+
+ // if the target or drop zone changes, then we can update
+ if (lastDragTarget !== selectedTarget || lastDropZone !== dropZone) {
+ lastDragTarget = selectedTarget;
+ lastDropZone = dropZone;
+
+ clearDropMarkers();
+ createDropMarker(selectedTarget, dropZone, indicatorType);
+ }
+
+ startAutoScroll(event.clientY);
+}
+
+function onDragLeave(event) {
+ if (!event.relatedTarget) {
+ clearDropMarkers();
+ stopAutoScroll();
+ lastDragTarget = null;
+ lastDropZone = null;
+ }
+}
+
+function onDrop(event) {
+ if (!SHARED_STATE._currentDraggedElement) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ // this is to validate that actual drag happened, otherwise sometimes just little mouse movements was causing
+ // the drag to happen which was unexpected
+ const DRAG_THRESHOLD = 5;
+
+ if (SHARED_STATE._dragStartX !== undefined && SHARED_STATE._dragStartY !== undefined) {
+ const deltaX = Math.abs(event.clientX - SHARED_STATE._dragStartX);
+ const deltaY = Math.abs(event.clientY - SHARED_STATE._dragStartY);
+
+ if (deltaX <= DRAG_THRESHOLD && deltaY <= DRAG_THRESHOLD) {
+ clearDropMarkers();
+ stopAutoScroll();
+ dragEndChores(SHARED_STATE._currentDraggedElement);
+ dismissUIAndCleanupState();
+ delete SHARED_STATE._currentDraggedElement;
+ delete SHARED_STATE._dragStartX;
+ delete SHARED_STATE._dragStartY;
+ delete SHARED_STATE._isDraggingSVG;
+ return;
+ }
+ }
+
+ let target = lastDragTarget;
+
+ if (!target) {
+ // get the element under the cursor
+ target = document.elementFromPoint(event.clientX, event.clientY);
+
+ // get the closest editable element
+ while (target && !target.hasAttribute(DATA_BRACKETS_ID_ATTR)) {
+ target = target.parentElement;
+ }
+
+ if (!LivePreviewView.isElementEditable(target) || target === SHARED_STATE._currentDraggedElement) {
+ if (SHARED_STATE.isAutoScrolling) { return; }
+
+ // if direct detection fails, we try to find a nearby valid target
+ target = findNearestValidTarget(event.clientX, event.clientY);
+ }
+
+ // Check if we should prefer a parent when all edges are aligned
+ const bestParent = findBestParentTarget(target);
+ if (bestParent) {
+ target = bestParent;
+ }
+ }
+
+ // skip if no valid target found or if it's the dragged element
+ if (!LivePreviewView.isElementEditable(target) || target === SHARED_STATE._currentDraggedElement) {
+ clearDropMarkers();
+ stopAutoScroll();
+ dragEndChores(SHARED_STATE._currentDraggedElement);
+ dismissUIAndCleanupState();
+ delete SHARED_STATE._currentDraggedElement;
+ return;
+ }
+
+ // Determine drop position based on container layout and cursor position
+ const indicatorType = getIndicatorType(target);
+ const dropZone = getDropZone(
+ target, event.clientX, event.clientY, indicatorType, SHARED_STATE._currentDraggedElement
+ );
+
+ // IDs of the source and target elements
+ const sourceId = SHARED_STATE._currentDraggedElement.getAttribute(DATA_BRACKETS_ID_ATTR);
+ const targetId = target.getAttribute(DATA_BRACKETS_ID_ATTR);
+
+ // Handle different drop zones
+ let messageData = {
+ livePreviewEditEnabled: true,
+ sourceElement: SHARED_STATE._currentDraggedElement,
+ targetElement: target,
+ sourceId: Number(sourceId),
+ targetId: Number(targetId),
+ move: true
+ };
+
+ if (dropZone === "inside") {
+ // For inside drops, we want to insert as a child of the target element
+ messageData.insertInside = true;
+ messageData.insertAfter = false; // Will be handled differently in backend
+ } else {
+ // For before/after drops, use the existing logic
+ messageData.insertAfter = dropZone === "after";
+ }
+
+ // send message to the editor
+ window._Brackets_MessageBroker.send(messageData);
+
+ clearDropMarkers();
+ stopAutoScroll();
+ dragEndChores(SHARED_STATE._currentDraggedElement);
+ dismissUIAndCleanupState();
+ delete SHARED_STATE._currentDraggedElement;
+ delete SHARED_STATE._dragStartX;
+ delete SHARED_STATE._dragStartY;
+}
+
+
+
+
+// ============================ DRAG-DROP EVENT HANDLERS FOR SVG ELEMENTS ============================
+// we need special handling for SVG elements as they drag listeners don't work for SVG elements
+// for SVG elements we need mouse related handlers using which we can internally call drag listeners
+
+function onDragMouseDown(event, element) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ SHARED_STATE._dragStartX = event.clientX;
+ SHARED_STATE._dragStartY = event.clientY;
+ SHARED_STATE._currentDraggedElement = element;
+ dragStartChores(element);
+ clearDropMarkers();
+ dismissUIAndCleanupState();
+ SHARED_STATE._isDraggingSVG = true;
+}
+
+function onDragMouseUp(event) {
+ if (SHARED_STATE._isDraggingSVG) {
+ event.preventDefault();
+ onDrop(event);
+ delete SHARED_STATE._isDraggingSVG;
+ }
+}
+
+function onDragMouseMove(event) {
+ if (SHARED_STATE._isDraggingSVG) {
+ event.preventDefault();
+ onDragOver(event);
+ }
+}
+
+/**
+ * This function is to end the svg drag drop when escape key is pressed if its currently active
+ * note: onMouseOut normally handles when a drop operation completes for svg elements
+ * but for escape key handling (cancelling the drag-drop) onMouseOut will not work as it expects the event param,
+ * but since the escape key handler is handled in phoenix-pro/main.js, so we cannot pass any events from there.
+ * that's the reason we need this function
+ *
+ * Also this is only for svgs as normal HTML elements uses draggable attribute and drag listeners
+ * so browsers automatically handle the escape key
+ */
+function cancelSVGDragIfActive() {
+ // check if any element is being dragged
+ if (!SHARED_STATE._isDraggingSVG || !SHARED_STATE._currentDraggedElement) {
+ return;
+ }
+
+ // clear the last highlighted element's background
+ if (lastDragTarget) {
+ if (lastDragTarget._originalDragBackgroundColor !== undefined) {
+ lastDragTarget.style.backgroundColor = lastDragTarget._originalDragBackgroundColor;
+ delete lastDragTarget._originalDragBackgroundColor;
+ }
+ if (lastDragTarget._originalDragOutline !== undefined) {
+ lastDragTarget.style.outline = lastDragTarget._originalDragOutline;
+ delete lastDragTarget._originalDragOutline;
+ }
+ }
+
+ // revert everything back
+ clearDropMarkers();
+ stopAutoScroll();
+ dragEndChores(SHARED_STATE._currentDraggedElement);
+ delete SHARED_STATE._currentDraggedElement;
+ delete SHARED_STATE._isDraggingSVG;
+ lastDragTarget = null;
+ lastDropZone = null;
+}
+
+
+
+
+// ============================ DRAG-DROP REGISTER EVENT LISTENERS ============================
+
+function unregisterDragDropListeners() {
+ window.document.removeEventListener("dragover", onDragOver);
+ window.document.removeEventListener("drop", onDrop);
+ window.document.removeEventListener("dragleave", onDragLeave);
+ window.document.removeEventListener("mousemove", onDragMouseMove);
+ window.document.removeEventListener("mouseup", onDragMouseUp);
+}
+
+function registerDragDropListeners() {
+ window.document.addEventListener("dragover", onDragOver);
+ window.document.addEventListener("drop", onDrop);
+ window.document.addEventListener("dragleave", onDragLeave);
+ window.document.addEventListener("mousemove", onDragMouseMove);
+ window.document.addEventListener("mouseup", onDragMouseUp);
+}
+
+function registerDragDropForElement(element) {
+ // disable dragging on all elements and then enable it on the current element
+ const allElements = document.querySelectorAll(`[${DATA_BRACKETS_ID_ATTR}]`);
+ allElements.forEach(el => el.setAttribute("draggable", "false"));
+
+ // we handle the svg elements differently as draggable attribute doesn't work on SVG namespace
+ // so for svgs we add mouse related listeners and internally we call the drag related functions
+ // NOTE: we don't allow svg child elements dragging. we drag the whole svg element as a single unit
+ if (element && element.namespaceURI === "http://www.w3.org/2000/svg") {
+ let svgElement = element;
+ while(svgElement && svgElement.tagName.toLowerCase() !== 'svg') {
+ if (svgElement.tagName.toLowerCase() === 'html' || svgElement.tagName.toLowerCase() === 'body') {
+ return;
+ }
+ svgElement = svgElement.parentElement;
+ }
+ if (svgElement && svgElement.tagName.toLowerCase() === 'svg') {
+ if (svgElement._dragMouseDownHandler) {
+ svgElement.removeEventListener("mousedown", svgElement._dragMouseDownHandler);
+ }
+ svgElement._dragMouseDownHandler = (event) => { onDragMouseDown(event, svgElement); };
+ svgElement.addEventListener("mousedown", svgElement._dragMouseDownHandler);
+ }
+
+ } else { // for normal elements
+ if (element._dragStartHandler) {
+ element.removeEventListener("dragstart", element._dragStartHandler);
+ }
+ if (element._dragEndHandler) {
+ element.removeEventListener("dragend", element._dragEndHandler);
+ }
+
+ if (SHARED_STATE._currentlyEditingElement !== element) {
+ element.setAttribute("draggable", "true");
+ }
+
+ element._dragStartHandler = (event) => { onDragStart(event, element); };
+ element._dragEndHandler = (event) => { onDragEnd(event, element); };
+ element.addEventListener("dragstart", element._dragStartHandler);
+ element.addEventListener("dragend", element._dragEndHandler);
+ }
+}
+
+function _reRegisterEventHandlers() {
+ unregisterDragDropListeners();
+ if (config.mode === 'edit') {
+ registerDragDropListeners();
+ }
+}
+
+function _onElementSelected(element) {
+ registerDragDropForElement(element);
+}
+
+LivePreviewView.registerToolHandler("DragAndDrop", {
+ dismiss: ()=>{},
+ reRegisterEventHandlers: _reRegisterEventHandlers,
+ onElementSelected: _onElementSelected,
+ handleEscapePressFromEditor: cancelSVGDragIfActive
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/generic-tools.js b/src/extensionsIntegrated/phoenix-pro/browser-context/generic-tools.js
new file mode 100644
index 0000000000..ff8e27eb1e
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/generic-tools.js
@@ -0,0 +1,319 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global GLOBALS, LivePreviewView, customReturns, strings, icons, proConstants*/
+
+let selectedElement;
+
+// duplicate option
+/**
+ * this is for duplicate button. Read '_handleDeleteOptionClick' jsdoc to understand more on how this works
+ * returns tru as we need to keep the exiting node highlighted.
+ * @param {Event} event
+ * @param {DOMElement} element - the HTML DOM element that was clicked.
+ */
+function _handleDuplicateOptionClick(event, element) {
+ if (LivePreviewView.isElementEditable(element)) {
+ const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ element: element,
+ event: event,
+ tagId: Number(tagId),
+ duplicate: true
+ });
+ } else {
+ console.error("The TagID might be unavailable or the element tag is directly body or html");
+ }
+ return true;
+}
+
+function _renderDuplicateIcon(element, shadow) {
+ if (element) {
+ return {
+ listOrder: proConstants.TOOLBOX_ORDERING.DUPLICATE,
+ htmlContent: `${icons.duplicate}`
+ };
+ }
+ return null;
+}
+
+// Register with LivePreviewView
+LivePreviewView.registerToolHandler("duplicate", {
+ renderToolBoxItem: _renderDuplicateIcon,
+ handleClick: _handleDuplicateOptionClick
+});
+
+// delete option
+function _handleDelete(element) {
+ if (LivePreviewView.isElementEditable(element)) {
+ const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ element: element,
+ tagId: Number(tagId),
+ delete: true
+ });
+ } else {
+ console.error("The TagID might be unavailable or the element tag is directly body or html");
+ }
+}
+/**
+ * This function gets called when the delete button is clicked
+ * it sends a message to the editor using postMessage to delete the element from the source code
+ * @param {Event} event
+ * @param {DOMElement} element - the HTML DOM element that was clicked.
+ */
+function _handleDeleteOptionClick(event, element) {
+ _handleDelete(element);
+}
+
+function _renderDeleteIcon(element, shadow) {
+ if (element) {
+ return {
+ listOrder: proConstants.TOOLBOX_ORDERING.DELETE,
+ htmlContent: `${icons.trash}`
+ };
+ }
+ return null;
+}
+
+LivePreviewView.registerToolHandler("delete", {
+ renderToolBoxItem: _renderDeleteIcon,
+ handleClick: _handleDeleteOptionClick
+});
+
+// select parent option
+
+/**
+ * this is for select-parent button
+ * When user clicks on this option for a particular element, we get its parent element and trigger a click on it
+ * @param {Event} event
+ * @param {DOMElement} element - the HTML DOM element that was clicked
+ */
+function _handleSelectParentOptionClick(event, element) {
+ if (!LivePreviewView.isElementEditable(element)) {
+ return;
+ }
+
+ const parentElement = element.parentElement;
+ if (LivePreviewView.isElementEditable(parentElement)) {
+ // Check if parent element has .click() method (HTML elements)
+ // SVG elements don't have .click() method, so we use selectElement for them
+ if (typeof parentElement.click === 'function') {
+ parentElement.click();
+ } else {
+ LivePreviewView.brieflyDisableHoverListeners();
+ LivePreviewView.selectElement(parentElement);
+ }
+ } else {
+ console.error("The TagID might be unavailable or the parent element tag is directly body or html");
+ }
+}
+
+/**
+ * this function is to check if an element should show the 'select-parent' option
+ * because we don't want to show the select parent option when the parent is directly the body/html tag
+ * or the parent doesn't have the 'data-brackets-id'
+ * @param {Element} element - DOM element to check
+ * @returns {boolean} - true if we should show the select parent option otherwise false
+ */
+function _shouldShowSelectParentOption(element) {
+ if(!LivePreviewView.isElementEditable(element)) {
+ return false;
+ }
+
+ const parentElement = element.parentElement;
+ if(!LivePreviewView.isElementEditable(parentElement)) {
+ return false;
+ }
+ return true;
+}
+
+function _renderSelectParentIcon(element, shadow) {
+ if (element) {
+ const showSelectParentOption = _shouldShowSelectParentOption(element);
+ if(showSelectParentOption){
+ return {
+ listOrder: proConstants.TOOLBOX_ORDERING.SELECT_PARENT,
+ htmlContent: `
+
+ ${icons.arrowUp}
+ `
+ };
+ }
+ }
+ return null;
+}
+
+LivePreviewView.registerToolHandler("selectParent", {
+ renderToolBoxItem: _renderSelectParentIcon,
+ handleClick: _handleSelectParentOptionClick
+});
+
+// cut, copy , paste option
+
+function _renderCutDropdown() {
+ return {
+ listOrder: proConstants.DROPDOWN_ORDERING.CUT,
+ htmlContent: `
+
+ ${strings.cut}
+ `
+ };
+}
+
+function _handleCut(element) {
+ if (LivePreviewView.isElementEditable(element)) {
+ const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ element: element,
+ tagId: Number(tagId),
+ cut: true
+ });
+ } else {
+ console.error("The TagID might be unavailable or the element tag is directly body or html");
+ }
+}
+
+/**
+ * this is for cut button, when user clicks on cut button we copy the element's source code
+ * into the clipboard and remove it from the src code. read `_cutElementToClipboard` in `LivePreviewEdit.js`
+ * @param {Event} _event
+ * @param {DOMElement} element - the element we need to cut
+ * @param {DOMElement} _dropdown
+ */
+function _handleCutOptionClick(_event, element, _dropdown) {
+ _handleCut(element);
+ return true;
+}
+
+LivePreviewView.registerToolHandler("cut", {
+ renderDropdownItems: _renderCutDropdown,
+ handleDropdownClick: _handleCutOptionClick
+});
+
+
+function _renderCopyDropdown() {
+ return {
+ listOrder: proConstants.DROPDOWN_ORDERING.COPY,
+ htmlContent: `
+
+ ${strings.copy}
+ `
+ };
+}
+
+function _handleCopy(element) {
+ if (LivePreviewView.isElementEditable(element)) {
+ const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ element: element,
+ tagId: Number(tagId),
+ copy: true
+ });
+ } else {
+ console.error("The TagID might be unavailable or the element tag is directly body or html");
+ }
+ return true;
+}
+
+/**
+ * this is for copy button, similar to cut just we don't remove the elements source code
+ */
+function _handleCopyOptionClick(_event, element, _dropdown) {
+ _handleCopy(element);
+ return true;
+}
+
+LivePreviewView.registerToolHandler("copy", {
+ renderDropdownItems: _renderCopyDropdown,
+ handleDropdownClick: _handleCopyOptionClick
+});
+
+function _renderPasteDropdown() {
+ return {
+ listOrder: proConstants.DROPDOWN_ORDERING.PASTE,
+ htmlContent: `
+
+ ${strings.paste}
+ `
+ };
+}
+
+function _handlePaste(targetElement) {
+ if (LivePreviewView.isElementEditable(targetElement)) {
+ const targetTagId = targetElement.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ element: targetElement,
+ tagId: Number(targetTagId),
+ paste: true
+ });
+ } else {
+ console.error("The TagID might be unavailable or the element tag is directly body or html");
+ }
+ return true;
+}
+
+/**
+ * this is for paste button, this inserts the saved content from clipboard just above this element
+ */
+function _handlePasteOptionClick(_event, targetElement) {
+ _handlePaste(targetElement);
+ return true;
+}
+
+LivePreviewView.registerToolHandler("paste", {
+ renderDropdownItems: _renderPasteDropdown,
+ handleDropdownClick: _handlePasteOptionClick
+});
+
+
+LivePreviewView.registerToolHandler("cutPasteSeparator", {
+ renderDropdownItems: ()=>{
+ return {
+ listOrder: proConstants.DROPDOWN_ORDERING.CUT_PASTE_SEPARATOR,
+ htmlContent: ``
+ };
+ }
+});
+
+LivePreviewView.registerToolHandler("elementSelectionObserver", {
+ onElementSelected: (_selectedElement)=>{
+ selectedElement = _selectedElement;
+ },
+ dismiss: ()=>{
+ selectedElement = null;
+ }
+});
+
+customReturns.handleCutElement = ()=> {
+ if(selectedElement){
+ _handleCut(selectedElement);
+ }
+};
+customReturns.handleCopyElement = ()=> {
+ if(selectedElement){
+ _handleCopy(selectedElement);
+ }
+};
+customReturns.handlePasteElement = ()=> {
+ if(selectedElement){
+ _handlePaste(selectedElement);
+ }
+};
+customReturns.handleDeleteElement = ()=> {
+ if(selectedElement){
+ _handleDelete(selectedElement);
+ }
+};
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/helper.js b/src/extensionsIntegrated/phoenix-pro/browser-context/helper.js
new file mode 100644
index 0000000000..df0cfe0b16
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/helper.js
@@ -0,0 +1,343 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global LivePreviewView, SHARED_STATE*/
+
+/**
+ * Check if two rectangles overlap
+ * @param {Object} rect1 - First rectangle {left, top, right, bottom}
+ * @param {Object} rect2 - Second rectangle {left, top, right, bottom}
+ * @param {Number} padding - Optional padding to add around rectangles
+ * @returns {Boolean} - True if rectangles overlap
+ */
+function _doBoxesOverlap(rect1, rect2, padding = 0) {
+ return !(
+ rect1.right + padding < rect2.left ||
+ rect1.left - padding > rect2.right ||
+ rect1.bottom + padding < rect2.top ||
+ rect1.top - padding > rect2.bottom
+ );
+}
+
+/**
+ * Check if a box position is within viewport bounds
+ * @param {Number} left - Left position
+ * @param {Number} top - Top position
+ * @param {Number} width - Box width
+ * @param {Number} height - Box height
+ * @returns {Boolean} - True if within viewport
+ */
+function _isWithinViewport(left, top, width, height) {
+ return left >= 0 && top >= 0 && left + width <= window.innerWidth && top + height <= window.innerHeight;
+}
+
+/**
+ * Convert position coordinates to rectangle object
+ * @param {Number} left - Left position
+ * @param {Number} top - Top position
+ * @param {Number} width - Width
+ * @param {Number} height - Height
+ * @returns {Object} - Rectangle {left, top, right, bottom}
+ */
+function _coordsToRect(left, top, width, height) {
+ return {
+ left: left,
+ top: top,
+ right: left + width,
+ bottom: top + height
+ };
+}
+
+/**
+ * Calculate coordinates for a specific position type
+ * @param {String} position - Position type (e.g., 'top-left', 'bottom-right')
+ * @param {DOMElement} element - The element to position relative to
+ * @param {Object} boxDimensions - Box dimensions {width, height}
+ * @param {Number} verticalOffset - Vertical spacing
+ * @param {Number} horizontalOffset - Horizontal spacing
+ * @returns {Object} - {leftPos, topPos}
+ */
+function _getCoordinatesForPosition(position, element, boxDimensions, verticalOffset, horizontalOffset) {
+ const offsetBounds = LivePreviewView.screenOffset(element);
+ const elemBounds = element.getBoundingClientRect();
+
+ let leftPos, topPos;
+
+ switch (position) {
+ case "top-left":
+ leftPos = offsetBounds.left;
+ topPos = offsetBounds.top - boxDimensions.height - verticalOffset;
+ break;
+
+ case "top-right":
+ leftPos = offsetBounds.left + elemBounds.width - boxDimensions.width;
+ topPos = offsetBounds.top - boxDimensions.height - verticalOffset;
+ break;
+
+ case "bottom-left":
+ leftPos = offsetBounds.left;
+ topPos = offsetBounds.top + elemBounds.height + verticalOffset;
+ break;
+
+ case "bottom-right":
+ leftPos = offsetBounds.left + elemBounds.width - boxDimensions.width;
+ topPos = offsetBounds.top + elemBounds.height + verticalOffset;
+ break;
+
+ case "left":
+ leftPos = offsetBounds.left - boxDimensions.width - horizontalOffset;
+ topPos = offsetBounds.top;
+ break;
+
+ case "right":
+ leftPos = offsetBounds.left + elemBounds.width + horizontalOffset;
+ topPos = offsetBounds.top;
+ break;
+
+ case "top-adjusted":
+ // 30px is estimated height of tool box
+ leftPos = offsetBounds.left;
+ topPos = offsetBounds.top - boxDimensions.height - verticalOffset - 30;
+ break;
+
+ case "bottom-adjusted":
+ leftPos = offsetBounds.left;
+ topPos = offsetBounds.top + elemBounds.height + verticalOffset + 30;
+ break;
+
+ case "left-adjusted":
+ // 5px is just some extra spacing to handle some edge cases
+ leftPos = offsetBounds.left - boxDimensions.width + elemBounds.width - 5;
+ topPos = offsetBounds.top + elemBounds.height + verticalOffset + 5;
+ break;
+
+ case "right-adjusted":
+ leftPos = offsetBounds.left + elemBounds.width + horizontalOffset + 5;
+ topPos = offsetBounds.top + elemBounds.height + verticalOffset - 5;
+ break;
+
+ case "inside-top-left":
+ leftPos = offsetBounds.left;
+ topPos = offsetBounds.top;
+ break;
+
+ case "inside-top-right":
+ leftPos = offsetBounds.left + elemBounds.width - boxDimensions.width;
+ topPos = offsetBounds.top;
+ break;
+
+ default:
+ leftPos = -1000;
+ topPos = -1000;
+ }
+
+ return { leftPos, topPos };
+}
+
+/**
+ * Check if a proposed position is valid
+ * @param {Number} leftPos - Proposed left position
+ * @param {Number} topPos - Proposed top position
+ * @param {Object} boxDimensions - Box dimensions {width, height}
+ * @param {DOMElement} element - Element being positioned relative to
+ * @param {String} boxType - Type of box ('info-box' or 'tool-box')
+ * @param {Number} padding - Padding for overlap checks
+ * @returns {Boolean} - True if position is valid
+ */
+function _isPositionValid(leftPos, topPos, boxDimensions, element, boxType, padding = 6) {
+ // Convert page coordinates to viewport coordinates for validation
+ const viewportLeft = leftPos - window.pageXOffset;
+ const viewportTop = topPos - window.pageYOffset;
+
+ // 1. Check viewport overflow
+ if (!_isWithinViewport(viewportLeft, viewportTop, boxDimensions.width, boxDimensions.height)) {
+ return false;
+ }
+
+ // 2. Create rectangle for proposed position (in viewport coordinates)
+ const proposedRect = _coordsToRect(viewportLeft, viewportTop, boxDimensions.width, boxDimensions.height);
+
+ // 3. Check overlap with element (with padding)
+ const elemBounds = element.getBoundingClientRect();
+ const elemRect = _coordsToRect(elemBounds.left, elemBounds.top, elemBounds.width, elemBounds.height);
+
+ if (_doBoxesOverlap(proposedRect, elemRect, -padding)) {
+ // Negative padding means we need separation
+ return false;
+ }
+
+ // 4. Check overlap with other box (if it exists)
+ if (boxType === "info-box" && SHARED_STATE._toolBox) {
+ const toolBoxElement =
+ SHARED_STATE._toolBox._shadow && SHARED_STATE._toolBox._shadow.querySelector(".phoenix-tool-box");
+
+ if (toolBoxElement) {
+ const toolBoxBounds = toolBoxElement.getBoundingClientRect();
+ const toolBoxRect = _coordsToRect(
+ toolBoxBounds.left,
+ toolBoxBounds.top,
+ toolBoxBounds.width,
+ toolBoxBounds.height
+ );
+
+ if (_doBoxesOverlap(proposedRect, toolBoxRect, -4)) {
+ // Need at least 4px separation between boxes
+ return false;
+ }
+ }
+ } else if (boxType === "tool-box" && SHARED_STATE._infoBox) {
+ const infoBoxElement =
+ SHARED_STATE._infoBox._shadow && SHARED_STATE._infoBox._shadow.querySelector(".phoenix-info-box");
+
+ if (infoBoxElement) {
+ const infoBoxBounds = infoBoxElement.getBoundingClientRect();
+ const infoBoxRect = _coordsToRect(
+ infoBoxBounds.left,
+ infoBoxBounds.top,
+ infoBoxBounds.width,
+ infoBoxBounds.height
+ );
+
+ if (_doBoxesOverlap(proposedRect, infoBoxRect, -4)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+/**
+ * This function is responsible to calculate the position of the box
+ * it handles all the checks like whether the element is in viewport or not
+ * whether they overlap with the element or some other internal boxes
+ *
+ * @param {DOMElement} element - the DOM element that was clicked
+ * @param {String} boxType - the type of the box ('info-box' or 'tool-box')
+ * @param {Object} boxInstance - the instance of the box
+ * @returns {{leftPos: number, topPos: number}}
+ */
+function calcBoxPosition(element, boxType, boxInstance) {
+ // Default fallback (invisible to user)
+ const fallbackPos = { leftPos: -1000, topPos: -1000 };
+
+ // Get the box element to determine dimensions
+ const boxSelector = boxType === "info-box" ? ".phoenix-info-box" : ".phoenix-tool-box";
+ const boxElement = boxInstance._shadow && boxInstance._shadow.querySelector(boxSelector);
+
+ if (!boxElement) {
+ return fallbackPos;
+ }
+
+ const boxDimensions = boxElement.getBoundingClientRect();
+ const verticalOffset = 6; // Space above/below element
+ const horizontalOffset = 8; // Space left/right of element
+
+ // Define position priority order for each box type
+ const POSITION_PRIORITIES = {
+ "info-box": [
+ "top-left",
+ "left",
+ "right",
+ "bottom-left",
+ "top-adjusted",
+ "bottom-adjusted",
+ "left-adjusted",
+ "right-adjusted",
+ "inside-top-left"
+ ],
+ "tool-box": [
+ // we don't need adjusted positioning for tool box as it gets placed before info box
+ "top-right",
+ "right",
+ "left",
+ "bottom-right",
+ "inside-top-right"
+ ]
+ };
+
+ const positions = POSITION_PRIORITIES[boxType];
+
+ // Try each position in priority order
+ for (let position of positions) {
+ const coords = _getCoordinatesForPosition(position, element, boxDimensions, verticalOffset, horizontalOffset);
+
+ // For 'inside' positions, always use them as last resort
+ if (position.startsWith("inside-")) {
+ return coords;
+ }
+
+ // Check if this position is valid
+ if (_isPositionValid(coords.leftPos, coords.topPos, boxDimensions, element, boxType)) {
+ return coords;
+ }
+ }
+
+ // Should never reach here as 'inside' position should always be tried
+ return fallbackPos;
+}
+
+// Helper function to dismiss boxes only for elements that don't move with scroll
+// this is needed for fixed positioned elements because otherwise the boxes will move along with scroll,
+// but the element stays at position which will lead to drift between the element & boxes
+function _dismissBoxesForFixedElements() {
+ function _checkAndDismiss(calcNewDifference, prevDifference) {
+ // 4 is just for pixelated differences
+ if (Math.abs(calcNewDifference - prevDifference) > 4) {
+ const infoBoxHandler = LivePreviewView.getToolHandler("InfoBox");
+ if (infoBoxHandler) { infoBoxHandler.dismiss(); }
+
+ const toolBoxHandler = LivePreviewView.getToolHandler("ToolBox");
+ if (toolBoxHandler) { toolBoxHandler.dismiss(); }
+
+ LivePreviewView.cleanupPreviousElementState();
+ }
+ }
+
+ // first we try tool box, because its position is generally fixed even in overlapping cases
+ if (SHARED_STATE._toolBox && SHARED_STATE._toolBox.element && SHARED_STATE._toolBox._shadow) {
+ const toolBoxElement = SHARED_STATE._toolBox._shadow.querySelector(".phoenix-tool-box");
+ if (toolBoxElement) {
+ // get the position of both the toolBox as well as the element
+ const toolBoxBounds = toolBoxElement.getBoundingClientRect();
+ const elementBounds = SHARED_STATE._toolBox.element.getBoundingClientRect();
+
+ // this is to store the prev value, so that we can compare it the second time
+ if (!SHARED_STATE._toolBox._possDifference) {
+ SHARED_STATE._toolBox._possDifference = toolBoxBounds.top - elementBounds.top;
+ } else {
+ const calcNewDifference = toolBoxBounds.top - elementBounds.top;
+ const prevDifference = SHARED_STATE._toolBox._possDifference;
+ _checkAndDismiss(calcNewDifference, prevDifference);
+ }
+ }
+ } else if (SHARED_STATE._infoBox && SHARED_STATE._infoBox.element && SHARED_STATE._infoBox._shadow) {
+ // if tool box didn't exist, we check with info box (logic is same)
+ const infoBoxElement = SHARED_STATE._infoBox._shadow.querySelector(".phoenix-info-box");
+ if (infoBoxElement) {
+ // here just we make sure that the element is same
+ if (!SHARED_STATE._infoBox._prevElement) {
+ SHARED_STATE._infoBox._prevElement = SHARED_STATE._infoBox.element;
+ } else if (SHARED_STATE._infoBox._prevElement !== SHARED_STATE._infoBox.element) {
+ return;
+ } else {
+ const infoBoxBounds = infoBoxElement.getBoundingClientRect();
+ const elementBounds = SHARED_STATE._infoBox.element.getBoundingClientRect();
+
+ if (!SHARED_STATE._infoBox._possDifference) {
+ SHARED_STATE._infoBox._possDifference = infoBoxBounds.top - elementBounds.top;
+ } else {
+ const calcNewDifference = infoBoxBounds.top - elementBounds.top;
+ const prevDifference = SHARED_STATE._infoBox._possDifference;
+ _checkAndDismiss(calcNewDifference, prevDifference);
+ }
+ }
+ }
+ }
+}
+
+window.addEventListener("scroll", _dismissBoxesForFixedElements);
+
+LivePreviewView.calcBoxPosition = calcBoxPosition;
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/hot-corners.js b/src/extensionsIntegrated/phoenix-pro/browser-context/hot-corners.js
new file mode 100644
index 0000000000..30e8180c1e
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/hot-corners.js
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+/*global GLOBALS, strings, icons, cssStyles, config, LivePreviewView, SHARED_STATE*/
+
+// we load this file in unit test window and dont want it to do anything unexpected in the unit test runner context
+const isUnitTestWindow = window.Phoenix && window.Phoenix.isTestWindow;
+
+/**
+ * hot corner class,
+ * to switch to preview mode and back
+ */
+class HotCorner {
+ constructor() {
+ this.element = null;
+ this.button = null;
+ this.box = null;
+ this.wasPreviewMode = false;
+ this._setup();
+ }
+
+ _setup() {
+ const container = document.createElement("div");
+ container.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
+
+ const shadow = container.attachShadow({ mode: "open" });
+
+ const content = `
+
+
+ `;
+
+ shadow.innerHTML = `${content}`;
+
+ this.element = container;
+ this.button = shadow.querySelector(".hot-corner-btn");
+ this.hotCorner = shadow.querySelector(".phoenix-hot-corner");
+
+ this.button.addEventListener("click", () => {
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ type: "hotCornerPreviewToggle"
+ });
+ });
+ document.body.appendChild(this.element);
+ }
+
+ updateState(isPreviewMode, showAnimation = false) {
+ if (isPreviewMode) {
+ this.button.classList.add("selected");
+
+ if (!this.wasPreviewMode && showAnimation && this.hotCorner) {
+ this.hotCorner.classList.add("peek-animation");
+
+ setTimeout(() => {
+ if (this.hotCorner) {
+ this.hotCorner.classList.remove("peek-animation");
+ }
+ }, 1200);
+ }
+ } else {
+ this.button.classList.remove("selected");
+ }
+ this.wasPreviewMode = isPreviewMode;
+ }
+
+ remove() {
+ if (this.element && this.element.parentNode) {
+ this.element.parentNode.removeChild(this.element);
+ }
+ this.element = null;
+ this.button = null;
+ this.hotCorner = null;
+ }
+}
+
+// just a helper function so that its easier for us to export with LivePreviewView
+function udpateHotCornerState(isPreviewMode, showAnimation = false) {
+ SHARED_STATE._hotCorner.updateState(config.mode === 'preview');
+}
+
+// init the hot corner after the DOM is ready
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", () => {
+ if (!SHARED_STATE._hotCorner && !isUnitTestWindow) {
+ SHARED_STATE._hotCorner = new HotCorner();
+ udpateHotCornerState(config.mode === "preview");
+ }
+ });
+} else {
+ // or if the DOM is already ready then init directly
+ if (!SHARED_STATE._hotCorner && !isUnitTestWindow) {
+ SHARED_STATE._hotCorner = new HotCorner();
+ udpateHotCornerState(config.mode === "preview");
+ }
+}
+
+// Register with LivePreviewView
+LivePreviewView.registerToolHandler("hot-corner", {
+ udpateHotCornerState: udpateHotCornerState
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/hyperlink-editor.js b/src/extensionsIntegrated/phoenix-pro/browser-context/hyperlink-editor.js
new file mode 100644
index 0000000000..8085433bbb
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/hyperlink-editor.js
@@ -0,0 +1,160 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global GLOBALS, LivePreviewView, cssStyles, strings, icons, proConstants*/
+
+let _hyperlinkEditor;
+
+function dismissHyperlinkEditor() {
+ if (_hyperlinkEditor) {
+ _hyperlinkEditor.remove();
+ _hyperlinkEditor = null;
+ }
+}
+
+/**
+ * This shows a floating input box above the element which allows you to edit the link of the 'a' tag
+ */
+function HyperlinkEditor(element) {
+ this.element = element;
+ this.remove = this.remove.bind(this);
+ this.create();
+}
+
+HyperlinkEditor.prototype = {
+ create: function() {
+ const currentHref = this.element.getAttribute('href') || '';
+
+ // Create shadow DOM container
+ this.body = document.createElement('div');
+ this.body.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
+ document.body.appendChild(this.body);
+
+ const shadow = this.body.attachShadow({ mode: 'open' });
+
+ // Create input HTML + styles
+ const html = `
+
+
+
+
+
+ `;
+
+ shadow.innerHTML = html;
+ this._shadow = shadow;
+
+ this._positionInput();
+
+ // setup the event listeners
+ const input = shadow.querySelector('input');
+ input.focus();
+ input.select();
+
+ input.addEventListener('keydown', (e) => this._handleKeydown(e));
+ input.addEventListener('blur', () => this._handleBlur());
+ },
+
+ _positionInput: function() {
+ const inputBoxElement = this._shadow.querySelector('.hyperlink-input-box');
+ if (!inputBoxElement) {
+ return;
+ }
+
+ const boxRect = inputBoxElement.getBoundingClientRect();
+ const elemBounds = this.element.getBoundingClientRect();
+ const offset = LivePreviewView.screenOffset(this.element);
+
+ let topPos = offset.top - boxRect.height - 6;
+ let leftPos = offset.left + elemBounds.width - boxRect.width;
+
+ // If would go off top, position below
+ if (elemBounds.top - boxRect.height < 6) {
+ topPos = offset.top + elemBounds.height + 6;
+ }
+
+ // If would go off left, align left
+ if (leftPos < 0) {
+ leftPos = offset.left;
+ }
+
+ inputBoxElement.style.left = leftPos + 'px';
+ inputBoxElement.style.top = topPos + 'px';
+ },
+
+ _handleKeydown: function(event) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this._save();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ dismissHyperlinkEditor();
+ }
+ },
+
+ _handleBlur: function() {
+ setTimeout(() => this._save(), 100);
+ },
+
+ _save: function() {
+ const input = this._shadow.querySelector('input');
+ const newHref = input.value.trim();
+ const oldHref = this.element.getAttribute('href') || '';
+
+ if (newHref !== oldHref) {
+ this.element.setAttribute('href', newHref);
+
+ const tagId = this.element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ livePreviewHyperlinkEdit: true,
+ element: this.element,
+ tagId: Number(tagId),
+ newHref: newHref
+ });
+ }
+
+ this.remove();
+ },
+
+ remove: function() {
+ if (this.body && this.body.parentNode) {
+ this.body.parentNode.removeChild(this.body);
+ this.body = null;
+ }
+ }
+};
+
+/**
+ * This function gets called when the edit hyperlink button is clicked
+ * @param {Event} event
+ * @param {DOMElement} element - the HTML link element
+ */
+function _handleEditHyperlinkOptionClick(event, element) {
+ dismissHyperlinkEditor();
+ _hyperlinkEditor = new HyperlinkEditor(element);
+}
+
+function renderHyperlinkOptions(element, shadow) {
+ if (element && element.tagName.toLowerCase() === 'a') {
+ return {
+ listOrder: proConstants.TOOLBOX_ORDERING.EDIT_HYPERLINK,
+ htmlContent: `
+ ${icons.link}
+ `
+ };
+ }
+ return null;
+}
+
+LivePreviewView.registerToolHandler("HyperlinkEditor", {
+ dismiss: dismissHyperlinkEditor,
+ handleClick: _handleEditHyperlinkOptionClick,
+ renderToolBoxItem: renderHyperlinkOptions
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/image-gallery.js b/src/extensionsIntegrated/phoenix-pro/browser-context/image-gallery.js
new file mode 100644
index 0000000000..1e6900cbeb
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/image-gallery.js
@@ -0,0 +1,1262 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+/*global GLOBALS, LivePreviewView, proConstants, config, cssStyles,
+ strings, icons, dismissUIAndCleanupState, customReturns*/
+
+const DATA_BRACKETS_ID_ATTR = GLOBALS.DATA_BRACKETS_ID_ATTR;
+const PHCODE_INTERNAL_ATTR = GLOBALS.PHCODE_INTERNAL_ATTR;
+
+// image ribbon gallery cache, to store th e last query and its results
+const CACHE_EXPIRY_TIME = 168 * 60 * 60 * 1000; // 7 days
+const CACHE_MAX_IMAGES = 50; // max number of images that we store in the localStorage
+
+// initialized from config, defaults to true if not set
+let imageGallerySelected = config.imageGalleryState !== undefined ? config.imageGalleryState : true;
+
+let _imageRibbonGallery; // to store the instance of the image gallery
+
+/**
+ * this function checks whether the image gallery overlaps the image element
+ * because if it does we scroll the image element a bit above so that users can see the whole image clearly
+ * @param {DOMElement} element - the image element
+ * @param {DOMElement} imageGalleryElement - the image gallery container
+ */
+function scrollImageToViewportIfRequired(element, imageGalleryElement) {
+ let elementRect = element.getBoundingClientRect();
+ let galleryRect = imageGalleryElement._shadow
+ .querySelector(".phoenix-image-gallery-container")
+ .getBoundingClientRect();
+
+ // this will get true when the image element and the image gallery overlaps each other
+ if (elementRect.bottom >= galleryRect.top) {
+ const scrollValue = window.scrollY + (elementRect.bottom - galleryRect.top) + 10;
+ window.scrollTo(0, scrollValue);
+ }
+}
+
+/**
+ * This function gets called when the image gallery button is clicked
+ * it shows the image ribbon gallery at the bottom of the live preview
+ * @param {Event} event
+ * @param {DOMElement} element - the HTML DOM element that was clicked (should be an image)
+ * @param {DOMElement} toolBoxShadow - the HTML DOM element that was clicked (should be an image)
+ */
+function handleImageGalleryOptionClick(event, element, toolBoxShadow) {
+ dismissImageRibbonGallery();
+
+ if (imageGallerySelected) {
+ imageGallerySelected = false;
+ } else {
+ imageGallerySelected = true;
+ _imageRibbonGallery = new ImageRibbonGallery(element);
+ scrollImageToViewportIfRequired(element, _imageRibbonGallery);
+ }
+
+ handleImageGalleryStateChange();
+ event.preventDefault();
+ event.stopPropagation();
+ if(!toolBoxShadow || !toolBoxShadow.querySelector(".img-gallery-toggle")){
+ console.warn("No toolbox shadow div found to update image gallery state");
+ return true;
+ }
+ const imgGalleryIcon = toolBoxShadow.querySelector(".img-gallery-toggle");
+ if(imageGallerySelected) {
+ imgGalleryIcon.classList.add("selected");
+ } else {
+ imgGalleryIcon.classList.remove("selected");
+ }
+ return true;
+}
+
+/**
+ * image gallery state means whether the image gallery is selected or not
+ * when selected: when an image element is clicked, image gallery opens automatically
+ */
+function handleImageGalleryStateChange() {
+ // send image gallery state change message to editor to save preference in state manager
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ type: "imageGalleryStateChange",
+ selected: imageGallerySelected
+ });
+}
+
+/**
+ * to dismiss the image ribbon gallery if its available
+ */
+function dismissImageRibbonGallery() {
+ if (_imageRibbonGallery) {
+ _imageRibbonGallery.remove();
+ _imageRibbonGallery = null;
+ }
+}
+
+/**
+ * when user clicks on an image we call this,
+ * this creates a image ribbon gallery at the bottom of the live preview
+ */
+function ImageRibbonGallery(element) {
+ this.element = element;
+ this.remove = this.remove.bind(this);
+ this.currentPage = 1;
+ this.totalPages = 1;
+ this.allImages = [];
+ this.scrollPosition = 0;
+
+ this._isOffline = !navigator.onLine;
+ this._offlineBannerDismissed = false;
+ this._onlineHandler = null;
+ this._offlineHandler = null;
+
+ this.create();
+}
+
+ImageRibbonGallery.prototype = {
+ _style: function () {
+ this.body = window.document.createElement("div");
+ this.body.setAttribute(PHCODE_INTERNAL_ATTR, "true");
+ this._shadow = this.body.attachShadow({ mode: "open" });
+
+ this._shadow.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+ ${strings.imageGallery}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ‹
+
+
+ ${strings.imageGalleryLoadingInitial}
+
+
+ ›
+
+
+ `;
+ },
+
+ _getDefaultQuery: function () {
+ // this are the default queries, so when image ribbon gallery is shown, we select a random query and show it
+ const qualityQueries = [
+ "nature",
+ "coffee",
+ "mountains",
+ "city",
+ "flowers",
+ "ocean",
+ "sunset",
+ "forest",
+ "travel",
+ "sky"
+ ];
+
+ const randIndex = Math.floor(Math.random() * qualityQueries.length);
+ return qualityQueries[randIndex];
+ },
+
+ _checkNetworkStatus: function () {
+ const wasOffline = this._isOffline;
+ this._isOffline = !navigator.onLine;
+ return wasOffline !== this._isOffline;
+ },
+
+ _showOfflineBanner: function () {
+ if (this._offlineBannerDismissed) {
+ return;
+ }
+
+ const banner = this._shadow.querySelector(".phoenix-image-gallery-offline-banner");
+ if (!banner) {
+ return;
+ }
+
+ banner.classList.remove("hidden", "fade-out");
+ this._setSearchInputDisabled(true);
+ },
+
+ _hideOfflineBanner: function (withAnimation = true) {
+ const banner = this._shadow.querySelector(".phoenix-image-gallery-offline-banner");
+ if (!banner) {
+ return;
+ }
+
+ if (withAnimation) {
+ banner.classList.add("fade-out");
+ setTimeout(() => {
+ banner.classList.add("hidden");
+ banner.classList.remove("fade-out");
+ }, 300);
+ } else {
+ banner.classList.add("hidden");
+ }
+
+ this._setSearchInputDisabled(false);
+ },
+
+ _setSearchInputDisabled: function (disabled) {
+ const searchWrapper = this._shadow.querySelector(".search-wrapper");
+ const searchInput = this._shadow.querySelector(".search-wrapper input");
+ const searchButton = this._shadow.querySelector(".search-icon");
+
+ if (disabled) {
+ if (searchWrapper) {
+ searchWrapper.classList.add("disabled");
+ }
+ if (searchInput) {
+ searchInput.disabled = true;
+ }
+ if (searchButton) {
+ searchButton.disabled = true;
+ }
+ } else {
+ if (searchWrapper) {
+ searchWrapper.classList.remove("disabled");
+ }
+ if (searchInput) {
+ searchInput.disabled = false;
+ }
+ if (searchButton) {
+ searchButton.disabled = false;
+ }
+ }
+ },
+
+ _setupNetworkListeners: function () {
+ this._removeNetworkListeners();
+
+ this._onlineHandler = () => {
+ const statusChanged = this._checkNetworkStatus();
+ if (statusChanged && !this._isOffline) {
+ this._hideOfflineBanner(true);
+ this._offlineBannerDismissed = false;
+
+ // when user is back online, check if there are already loaded images
+ // if not, fetch it
+ if (this._currentSearchQuery && (!this.allImages || this.allImages.length === 0)) {
+ this.currentPage = 1;
+ this.allImages = [];
+ this.scrollPosition = 0;
+ this._fetchImages(this._currentSearchQuery);
+ }
+ }
+ };
+
+ this._offlineHandler = () => {
+ const statusChanged = this._checkNetworkStatus();
+ if (statusChanged && this._isOffline) {
+ this._showOfflineBanner();
+ }
+ };
+
+ window.addEventListener("online", this._onlineHandler);
+ window.addEventListener("offline", this._offlineHandler);
+ },
+
+ _removeNetworkListeners: function () {
+ if (this._onlineHandler) {
+ window.removeEventListener("online", this._onlineHandler);
+ this._onlineHandler = null;
+ }
+ if (this._offlineHandler) {
+ window.removeEventListener("offline", this._offlineHandler);
+ this._offlineHandler = null;
+ }
+ },
+
+ _fetchImages: function (searchQuery, page = 1, append = false) {
+ this._currentSearchQuery = searchQuery;
+ this._checkNetworkStatus();
+
+ if (!append && this._loadFromCache(searchQuery)) {
+ // try cache first
+ // if offline and cache loaded successfully, show offline banner
+ if (this._isOffline) {
+ this._showOfflineBanner();
+ }
+ return;
+ }
+ if (append && this._loadPageFromCache(searchQuery, page)) {
+ // try to load new page from cache
+ return;
+ }
+
+ // if offline and no cache, show banner and don't make API call
+ if (this._isOffline) {
+ if (!append) {
+ this._showOfflineBanner();
+ this._showError(strings.imageGalleryLoadError);
+ } else {
+ this._isLoadingMore = false;
+ this._hideLoadingMore();
+ }
+ return;
+ }
+
+ // if unable to load from cache, we make the API call
+ this._fetchFromAPI(searchQuery, page, append);
+ },
+
+ _fetchFromAPI: function (searchQuery, page, append) {
+ // when we fetch from API, we clear the previous query from local storage and then store a fresh copy
+ if (searchQuery !== _imageGalleryCache.currentQuery) {
+ this._clearCache();
+ }
+
+ const apiUrl = `https://images.phcode.dev/api/images/search?` +
+ `q=${encodeURIComponent(searchQuery)}&per_page=10&page=${page}&safe=true`;
+
+ if (!append) {
+ this._showLoading();
+ }
+
+ fetch(apiUrl)
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`API request failed: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then((data) => {
+ if (data.results && data.results.length > 0) {
+ if (append) {
+ this.allImages = this.allImages.concat(data.results);
+ this._renderImages(data.results, true); // true means need to append new images at the end
+ } else {
+ this.allImages = data.results;
+ this._renderImages(this.allImages, false); // false means its a new search
+ }
+ this.totalPages = data.total_pages || 1;
+ this.currentPage = page;
+ this._handleNavButtonsDisplay("visible");
+ this._updateSearchInput(searchQuery);
+ this._updateCache(searchQuery, data, append);
+ } else if (!append) {
+ this._showError(strings.imageGalleryNoImages);
+ }
+
+ if (append) {
+ this._isLoadingMore = false;
+ this._hideLoadingMore();
+ }
+ })
+ .catch((error) => {
+ console.error("Failed to fetch images:", error);
+ // check if error is because of no network
+ this._checkNetworkStatus();
+
+ if (!append) {
+ this._showError(strings.imageGalleryLoadError);
+ // if user is offline, show the offline banner
+ if (this._isOffline) {
+ this._showOfflineBanner();
+ }
+ } else {
+ this._isLoadingMore = false;
+ this._hideLoadingMore();
+ // if user is offline during pagination, show banner
+ if (this._isOffline) {
+ this._showOfflineBanner();
+ }
+ }
+ });
+ },
+
+ _updateCache: function (searchQuery, data, append) {
+ // Update cache with new data for current query
+ _imageGalleryCache.currentQuery = searchQuery;
+ _imageGalleryCache.totalPages = data.total_pages || 1;
+ _imageGalleryCache.currentPage = this.currentPage;
+
+ if (append) {
+ const currentImages = _imageGalleryCache.allImages || [];
+ const newImages = currentImages.concat(data.results);
+
+ if (newImages.length > CACHE_MAX_IMAGES) {
+ _imageGalleryCache.allImages = newImages.slice(0, CACHE_MAX_IMAGES);
+ } else {
+ _imageGalleryCache.allImages = newImages;
+ }
+ } else {
+ // new search replace cache
+ _imageGalleryCache.allImages = data.results;
+ }
+ },
+
+ _clearCache: function () {
+ try {
+ window.localStorage.removeItem("imageGalleryCache");
+ } catch (error) {
+ console.error("Failed to clear image cache:", error);
+ }
+ },
+
+ _updateSearchInput: function (searchQuery) {
+ // write the current query in the search input
+ const searchInput = this._shadow.querySelector(".search-wrapper input");
+ if (searchInput && searchQuery) {
+ searchInput.value = searchQuery;
+ searchInput.placeholder = searchQuery;
+ }
+ },
+
+ _loadFromCache: function (searchQuery) {
+ const cachedImages = _imageGalleryCache.allImages;
+ if (searchQuery === _imageGalleryCache.currentQuery && cachedImages && cachedImages.length > 0) {
+ this.allImages = cachedImages;
+ this.totalPages = _imageGalleryCache.totalPages;
+ this.currentPage = _imageGalleryCache.currentPage;
+
+ this._renderImages(this.allImages, false);
+ this._handleNavButtonsDisplay("visible");
+ this._updateSearchInput(searchQuery);
+ return true;
+ }
+ return false;
+ },
+
+ _loadPageFromCache: function (searchQuery, page) {
+ const cachedImages = _imageGalleryCache.allImages;
+ if (
+ searchQuery === _imageGalleryCache.currentQuery &&
+ cachedImages &&
+ page <= Math.ceil(cachedImages.length / 10)
+ ) {
+ const startIdx = (page - 1) * 10;
+ const endIdx = startIdx + 10;
+ const pageImages = cachedImages.slice(startIdx, endIdx);
+
+ if (pageImages.length > 0) {
+ this.allImages = this.allImages.concat(pageImages);
+ this._renderImages(pageImages, true);
+ this.currentPage = page;
+ this._handleNavButtonsDisplay("visible");
+ this._isLoadingMore = false;
+ this._hideLoadingMore();
+ return true;
+ }
+ }
+ return false;
+ },
+
+ _handleNavLeft: function () {
+ const container = this._shadow.querySelector(".phoenix-image-gallery-strip");
+ if (!container) {
+ return;
+ }
+
+ const containerWidth = container.clientWidth;
+ const imageWidth = 117; // image width + gap
+
+ // calculate how many images are visible
+ const visibleImages = Math.floor(containerWidth / imageWidth);
+
+ // scroll by (visible images - 2), minimum 1 image, maximum 5 images
+ const imagesToScroll = Math.max(1, Math.min(5, visibleImages - 2));
+ const scrollAmount = imagesToScroll * imageWidth;
+
+ this.scrollPosition = Math.max(0, this.scrollPosition - scrollAmount);
+ container.scrollTo({ left: this.scrollPosition, behavior: "smooth" });
+ this._handleNavButtonsDisplay("visible");
+ },
+
+ _handleNavRight: function () {
+ const container = this._shadow.querySelector(".phoenix-image-gallery-strip");
+ if (!container) {
+ return;
+ }
+
+ const containerWidth = container.clientWidth;
+ const totalWidth = container.scrollWidth;
+ const imageWidth = 117; // image width + gap
+
+ // calculate how many images are visible
+ const visibleImages = Math.floor(containerWidth / imageWidth);
+
+ // scroll by (visible images - 2), minimum 1 image, maximum 5 images
+ const imagesToScroll = Math.max(1, Math.min(5, visibleImages - 2));
+ const scrollAmount = imagesToScroll * imageWidth;
+
+ // if we're near the end, we need to load more images
+ const nearEnd = this.scrollPosition + containerWidth + scrollAmount >= totalWidth - 100;
+ if (nearEnd && this.currentPage < this.totalPages && !this._isLoadingMore) {
+ this._isLoadingMore = true;
+ this._showLoadingMore();
+ this._fetchImages(this._currentSearchQuery, this.currentPage + 1, true);
+ }
+
+ this.scrollPosition = Math.min(totalWidth - containerWidth, this.scrollPosition + scrollAmount);
+ container.scrollTo({ left: this.scrollPosition, behavior: "smooth" });
+ this._handleNavButtonsDisplay("visible");
+ },
+
+ _handleNavButtonsDisplay: function (state) {
+ // state can be 'visible' or 'hidden'
+ const navLeft = this._shadow.querySelector(".phoenix-image-gallery-nav.left");
+ const navRight = this._shadow.querySelector(".phoenix-image-gallery-nav.right");
+ const container = this._shadow.querySelector(".phoenix-image-gallery-strip");
+
+ if (!navLeft || !navRight) {
+ return;
+ }
+
+ if (state === "hidden") {
+ navLeft.style.setProperty("display", "none", "important");
+ navRight.style.setProperty("display", "none", "important");
+ return;
+ }
+
+ if (state === "visible") {
+ if (!container) {
+ return;
+ }
+
+ // show/hide the nav-left button
+ if (this.scrollPosition <= 0) {
+ navLeft.style.setProperty("display", "none", "important");
+ } else {
+ navLeft.style.setProperty("display", "flex", "important");
+ }
+
+ // show/hide the nav-right button
+ const containerWidth = container.clientWidth;
+ const totalWidth = container.scrollWidth;
+ const atEnd = this.scrollPosition + containerWidth >= totalWidth - 10;
+ const hasMorePages = this.currentPage < this.totalPages;
+
+ if (atEnd && !hasMorePages) {
+ navRight.style.setProperty("display", "none", "important");
+ } else {
+ navRight.style.setProperty("display", "flex", "important");
+ }
+ }
+ },
+
+ _showLoading: function () {
+ const rowElement = this._shadow.querySelector(".phoenix-image-gallery-row");
+ if (!rowElement) {
+ return;
+ }
+
+ rowElement.innerHTML = strings.imageGalleryLoadingInitial;
+ rowElement.className = "phoenix-image-gallery-row phoenix-image-gallery-loading";
+
+ this._handleNavButtonsDisplay("hidden");
+ },
+
+ _showLoadingMore: function () {
+ const rowElement = this._shadow.querySelector(".phoenix-image-gallery-row");
+ if (!rowElement) {
+ return;
+ }
+
+ // when loading more images we need to show the message at the end of the image ribbon
+ const loadingIndicator = window.document.createElement("div");
+ loadingIndicator.className = "phoenix-loading-more";
+ loadingIndicator.textContent = strings.imageGalleryLoadingMore;
+ rowElement.appendChild(loadingIndicator);
+ },
+
+ _hideLoadingMore: function () {
+ const loadingIndicator = this._shadow.querySelector(".phoenix-loading-more");
+ if (loadingIndicator) {
+ loadingIndicator.remove();
+ }
+ },
+
+ _attachEventHandlers: function () {
+ const ribbonContainer = this._shadow.querySelector(".phoenix-image-gallery-container");
+ const ribbonStrip = this._shadow.querySelector(".phoenix-image-gallery-strip");
+ const searchInput = this._shadow.querySelector(".search-wrapper input");
+ const searchButton = this._shadow.querySelector(".search-icon");
+ const closeButton = this._shadow.querySelector(".phoenix-image-gallery-close-button");
+ const folderSettingsButton = this._shadow.querySelector(".phoenix-image-gallery-download-folder-button");
+ const navLeft = this._shadow.querySelector(".phoenix-image-gallery-nav.left");
+ const navRight = this._shadow.querySelector(".phoenix-image-gallery-nav.right");
+ const selectImageBtn = this._shadow.querySelector(".phoenix-image-gallery-upload-container button");
+ const fileInput = this._shadow.querySelector(".phoenix-file-input");
+
+ if (searchInput && searchButton) {
+ const performSearch = (e) => {
+ e.stopPropagation();
+ const query = searchInput.value.trim();
+ if (query) {
+ // reset pagination when searching
+ this.currentPage = 1;
+ this.allImages = [];
+ this.scrollPosition = 0;
+ this._fetchImages(query);
+ }
+ };
+
+ // disable/enable search button as per input container text
+ const updateSearchButtonState = () => {
+ searchButton.disabled = searchInput.value.trim().length === 0;
+ };
+
+ searchInput.addEventListener("input", updateSearchButtonState);
+
+ searchInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ performSearch(e);
+ }
+ });
+
+ searchInput.addEventListener("click", (e) => {
+ e.stopPropagation();
+ });
+
+ searchButton.addEventListener("click", performSearch);
+ }
+
+ if (selectImageBtn && fileInput) {
+ selectImageBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ fileInput.click();
+ });
+
+ fileInput.addEventListener("change", (e) => {
+ e.stopPropagation();
+ const file = e.target.files[0];
+ if (file) {
+ this._handleLocalImageSelection(file);
+ fileInput.value = "";
+ }
+ });
+ }
+
+ if (closeButton) {
+ closeButton.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.remove();
+ imageGallerySelected = false;
+ handleImageGalleryStateChange();
+ dismissUIAndCleanupState();
+ });
+ }
+
+ if (folderSettingsButton) {
+ folderSettingsButton.addEventListener("click", (e) => {
+ e.stopPropagation();
+ // send message to LivePreviewEdit to show folder selection dialog
+ const tagId = this.element.getAttribute(DATA_BRACKETS_ID_ATTR);
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ resetImageFolderSelection: true,
+ element: this.element,
+ tagId: Number(tagId)
+ });
+ });
+ }
+
+ if (navLeft) {
+ navLeft.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this._handleNavLeft();
+ });
+ }
+
+ if (navRight) {
+ navRight.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this._handleNavRight();
+ });
+ }
+
+ // Restore original image when mouse leaves the entire ribbon strip
+ if (ribbonStrip) {
+ ribbonStrip.addEventListener("mouseleave", () => {
+ this.element.src = this._originalImageSrc;
+ });
+ }
+
+ // Prevent clicks anywhere inside the ribbon from bubbling up
+ if (ribbonContainer) {
+ ribbonContainer.addEventListener("click", (e) => {
+ e.stopPropagation();
+ });
+ }
+
+ // no network banner event handlers
+ const retryButton = this._shadow.querySelector(".phoenix-image-gallery-offline-retry-button");
+
+ if (retryButton) {
+ retryButton.addEventListener("click", (e) => {
+ e.stopPropagation();
+
+ const textElement = this._shadow.querySelector(".phoenix-image-gallery-offline-banner-text");
+ if (!textElement) {
+ return;
+ }
+
+ const originalText = textElement.textContent;
+
+ // add checking state - loading cursor and dots animation
+ retryButton.classList.add("checking");
+ textElement.classList.add("checking");
+ textElement.textContent = strings.imageGalleryCheckingConnection;
+
+ // just a small 600ms timer because if its instant then it feels like we didn't even try to connect
+ setTimeout(() => {
+ this._checkNetworkStatus();
+
+ retryButton.classList.remove("checking");
+ textElement.classList.remove("checking");
+
+ if (this._isOffline) {
+ // user is still offline, show message and keep it
+ textElement.textContent = strings.imageGalleryStillOffline;
+ } else {
+ // user became online
+ textElement.textContent = originalText;
+ this._hideOfflineBanner(true);
+
+ if (this._currentSearchQuery && (!this.allImages || this.allImages.length === 0)) {
+ this.currentPage = 1;
+ this.allImages = [];
+ this.scrollPosition = 0;
+ this._fetchImages(this._currentSearchQuery);
+ }
+ }
+ }, 600);
+ });
+ }
+ },
+
+ // append true means load more images (user clicked on nav-right)
+ // append false means its a new query
+ _renderImages: function (images, append = false) {
+ const rowElement = this._shadow.querySelector(".phoenix-image-gallery-row");
+ if (!rowElement) {
+ return;
+ }
+
+ const container = this._shadow.querySelector(".phoenix-image-gallery-strip");
+ const savedScrollPosition = container ? container.scrollLeft : 0;
+
+ // if not appending we clear the phoenix ribbon
+ if (!append) {
+ rowElement.innerHTML = "";
+ rowElement.className = "phoenix-image-gallery-row";
+ } else {
+ // when appending we add the new images at the end
+ const loadingIndicator = this._shadow.querySelector(".phoenix-loading-more");
+ if (loadingIndicator) {
+ loadingIndicator.remove();
+ }
+ }
+
+ // Create thumbnails from API data
+ images.forEach((image) => {
+ const thumbDiv = window.document.createElement("div");
+ thumbDiv.className = "phoenix-ribbon-thumb";
+
+ const img = window.document.createElement("img");
+ img.src = image.thumb_url || image.url;
+ img.alt = image.alt_text || "";
+ img.loading = "lazy";
+
+ // show hovered image along with dimensions
+ thumbDiv.addEventListener("mouseenter", () => {
+ this.element.style.width = this._originalImageStyle.width;
+ this.element.style.height = this._originalImageStyle.height;
+
+ this.element.style.objectFit = this._originalImageStyle.objectFit || "cover";
+ this.element.src = image.url || image.thumb_url;
+ });
+
+ // attribution overlay, we show this only in the image ribbon gallery
+ const attribution = window.document.createElement("div");
+ attribution.className = "phoenix-ribbon-attribution";
+
+ const photographer = window.document.createElement("a");
+ photographer.className = "photographer";
+ photographer.href = image.photographer_url;
+ photographer.target = "_blank";
+ photographer.rel = "noopener noreferrer";
+ photographer.textContent = (image.user && image.user.name) || "Anonymous";
+ photographer.addEventListener("click", (e) => {
+ e.stopPropagation();
+ });
+
+ const source = window.document.createElement("a");
+ source.className = "source";
+ source.href = image.unsplash_url;
+ source.target = "_blank";
+ source.rel = "noopener noreferrer";
+ source.textContent = "on Unsplash";
+ source.addEventListener("click", (e) => {
+ e.stopPropagation();
+ });
+
+ attribution.appendChild(photographer);
+ attribution.appendChild(source);
+
+ // download icon
+ const downloadIcon = window.document.createElement("div");
+ downloadIcon.className = "phoenix-download-icon";
+ downloadIcon.title = strings.imageGalleryUseImage;
+ downloadIcon.innerHTML = icons.downloadImage;
+
+ // when the image is clicked we download the image
+ thumbDiv.addEventListener("click", (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ // prevent multiple downloads of the same image
+ if (thumbDiv.classList.contains("downloading")) {
+ return;
+ }
+
+ // check if internet connection is there or not before downloading
+ this._checkNetworkStatus();
+ if (this._isOffline) {
+ return;
+ }
+
+ // show download indicator
+ this._showDownloadIndicator(thumbDiv);
+
+ const filename = this._generateFilename(image);
+ const extnName = ".jpg";
+
+ const downloadUrl = image.url || image.thumb_url;
+ const downloadLocation = image.download_location;
+
+ this._useImage(downloadUrl, filename, extnName, false, thumbDiv, downloadLocation);
+ });
+
+ thumbDiv.appendChild(img);
+ thumbDiv.appendChild(attribution);
+ thumbDiv.appendChild(downloadIcon);
+ rowElement.appendChild(thumbDiv);
+ });
+
+ if (append && container && savedScrollPosition > 0) {
+ setTimeout(() => {
+ container.scrollLeft = savedScrollPosition;
+ }, 0);
+ }
+
+ this._handleNavButtonsDisplay("visible");
+ },
+
+ _showError: function (message) {
+ const rowElement = this._shadow.querySelector(".phoenix-image-gallery-row");
+ if (!rowElement) {
+ return;
+ }
+
+ rowElement.innerHTML = message;
+ rowElement.className = "phoenix-image-gallery-row phoenix-ribbon-error";
+
+ this._handleNavButtonsDisplay("hidden");
+ },
+
+ // file name with which we need to save the image
+ _generateFilename: function (image) {
+ const photographerName = (image.user && image.user.name) || "Anonymous";
+ const searchTerm = this._currentSearchQuery || "image";
+
+ // clean the search term and the photograper name to write in file name
+ const cleanSearchTerm = searchTerm
+ .toLowerCase()
+ .replace(new RegExp("[^a-z0-9]", "g"), "-")
+ .replace(new RegExp("-+", "g"), "-")
+ .replace(new RegExp("^-|-$", "g"), "");
+ const cleanPhotographerName = photographerName
+ .toLowerCase()
+ .replace(new RegExp("[^a-z0-9]", "g"), "-")
+ .replace(new RegExp("-+", "g"), "-")
+ .replace(new RegExp("^-|-$", "g"), "");
+
+ return `${cleanSearchTerm}-by-${cleanPhotographerName}`;
+ },
+
+ _useImage: function (imageUrl, filename, extnName, isLocalFile, thumbDiv, downloadLocation) {
+ const tagId = this.element.getAttribute(DATA_BRACKETS_ID_ATTR);
+ const downloadId = Date.now() + Math.random();
+
+ const messageData = {
+ livePreviewEditEnabled: true,
+ useImage: true,
+ imageUrl: imageUrl,
+ filename: filename,
+ extnName: extnName,
+ element: this.element,
+ tagId: Number(tagId),
+ downloadLocation: downloadLocation,
+ downloadId: downloadId
+ };
+
+ // if this is a local file we need some more data before sending it to the editor
+ if (isLocalFile) {
+ messageData.isLocalFile = true;
+ // Convert data URL to binary data array for local files
+ const byteCharacters = atob(imageUrl.split(",")[1]);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ messageData.imageData = byteNumbers;
+ }
+
+ _activeDownloads.set(downloadId, {
+ thumbDiv: thumbDiv,
+ timestamp: Date.now()
+ });
+
+ window._Brackets_MessageBroker.send(messageData);
+ },
+
+ _handleLocalImageSelection: function (file) {
+ if (!file || !file.type.startsWith("image/")) {
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const imageDataUrl = e.target.result;
+
+ const originalName = file.name;
+ const nameWithoutExt = originalName.substring(0, originalName.lastIndexOf(".")) || originalName;
+ const extension = originalName.substring(originalName.lastIndexOf(".")) || ".jpg";
+
+ // we clean the file name because the file might have some chars which might not be compatible
+ const cleanName = nameWithoutExt
+ .toLowerCase()
+ .replace(new RegExp("[^a-z0-9]", "g"), "-")
+ .replace(new RegExp("-+", "g"), "-")
+ .replace(new RegExp("^-|-$", "g"), "");
+ const filename = cleanName || "selected-image";
+
+ // Use the unified _useImage method with isLocalFile flag
+ this._useImage(imageDataUrl, filename, extension, true, null);
+
+ // Close the ribbon after successful selection
+ this.remove();
+ };
+
+ reader.onerror = (error) => {
+ console.error("Something went wrong when reading the image:", error);
+ };
+
+ reader.readAsDataURL(file);
+ },
+
+ create: function () {
+ this.remove(); // remove existing ribbon if already present
+
+ // when image ribbon gallery is created we get the original image along with its dimensions
+ // so that on hover in we can show the hovered image and on hover out we can restore the original image
+ this._originalImageSrc = this.element.src;
+ this._originalImageStyle = {
+ width: window.getComputedStyle(this.element).width,
+ height: window.getComputedStyle(this.element).height,
+ objectFit: window.getComputedStyle(this.element).objectFit
+ };
+
+ this._style();
+ window.document.body.appendChild(this.body);
+ this._attachEventHandlers();
+
+ this._setupNetworkListeners();
+ this._checkNetworkStatus();
+
+ const queryToUse = _imageGalleryCache.currentQuery || this._getDefaultQuery();
+ this._fetchImages(queryToUse);
+ },
+
+ remove: function () {
+ this._removeNetworkListeners();
+
+ _imageRibbonGallery = null;
+ if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) {
+ window.document.body.removeChild(this.body);
+ this.body = null;
+ }
+ },
+
+ _showDownloadIndicator: function (thumbDiv) {
+ // add downloading class
+ thumbDiv.classList.add("downloading");
+
+ // create download indicator
+ const indicator = window.document.createElement("div");
+ indicator.className = "phoenix-download-indicator";
+
+ const spinner = window.document.createElement("div");
+ spinner.className = "phoenix-download-spinner";
+
+ indicator.appendChild(spinner);
+ thumbDiv.appendChild(indicator);
+ },
+
+ _hideDownloadIndicator: function (thumbDiv) {
+ // remove downloading class
+ thumbDiv.classList.remove("downloading");
+
+ // remove download indicator
+ const indicator = thumbDiv.querySelector(".phoenix-download-indicator");
+ if (indicator) {
+ indicator.remove();
+ }
+ }
+};
+
+const _imageGalleryCache = {
+ get currentQuery() {
+ const data = this._getFromStorage();
+ return data ? data.currentQuery : null;
+ },
+ set currentQuery(val) {
+ this._updateStorage({ currentQuery: val });
+ },
+
+ get allImages() {
+ const data = this._getFromStorage();
+ return data ? data.allImages : [];
+ },
+ set allImages(val) {
+ this._updateStorage({ allImages: val });
+ },
+
+ get totalPages() {
+ const data = this._getFromStorage();
+ return data ? data.totalPages : 1;
+ },
+ set totalPages(val) {
+ this._updateStorage({ totalPages: val });
+ },
+
+ get currentPage() {
+ const data = this._getFromStorage();
+ return data ? data.currentPage : 1;
+ },
+ set currentPage(val) {
+ this._updateStorage({ currentPage: val });
+ },
+
+ _getFromStorage() {
+ try {
+ const data = window.localStorage.getItem("imageGalleryCache");
+ if (!data) {
+ return null;
+ }
+
+ const parsed = JSON.parse(data);
+
+ if (Date.now() > parsed.expires) {
+ window.localStorage.removeItem("imageGalleryCache");
+ return null;
+ }
+
+ return parsed;
+ } catch (error) {
+ return null;
+ }
+ },
+
+ _updateStorage(updates) {
+ try {
+ const current = this._getFromStorage() || {};
+ const newData = {
+ ...current,
+ ...updates,
+ expires: Date.now() + CACHE_EXPIRY_TIME
+ };
+ window.localStorage.setItem("imageGalleryCache", JSON.stringify(newData));
+ } catch (error) {
+ if (error.name === "QuotaExceededError") {
+ try {
+ window.localStorage.removeItem("imageGalleryCache");
+ window.localStorage.setItem("imageGalleryCache", JSON.stringify(updates));
+ } catch (retryError) {
+ console.error("Failed to save image cache even after clearing:", retryError);
+ }
+ }
+ }
+ }
+};
+
+const DOWNLOAD_EVENTS = {
+ DIALOG_OPENED: "dialogOpened",
+ DIALOG_CLOSED: "dialogClosed",
+ STARTED: "downloadStarted",
+ COMPLETED: "downloadCompleted",
+ CANCELLED: "downloadCancelled",
+ ERROR: "downloadError"
+};
+
+let _activeDownloads = new Map();
+let _dialogOverlay = null;
+
+function _showDialogOverlay() {
+ // don't create multiple overlays
+ if (_dialogOverlay) {
+ return;
+ }
+
+ // create overlay container
+ const overlay = window.document.createElement("div");
+ overlay.setAttribute(PHCODE_INTERNAL_ATTR, "true");
+
+ // use shadow DOM for style isolation
+ const shadow = overlay.attachShadow({ mode: "open" });
+ const styles = cssStyles.dialogOverlay;
+
+ const content = `
+
+ `;
+
+ shadow.innerHTML = `${content}`;
+ window.document.body.appendChild(overlay);
+ _dialogOverlay = overlay;
+}
+
+function _hideDialogOverlay() {
+ if (_dialogOverlay && _dialogOverlay.parentNode) {
+ _dialogOverlay.parentNode.removeChild(_dialogOverlay);
+ _dialogOverlay = null;
+ }
+}
+
+function handleDownloadEvent(eventType, data) {
+ const downloadId = data && data.downloadId;
+ if (!downloadId) {
+ return;
+ }
+
+ // handle dialog events (these don't require download to exist)
+ if (eventType === DOWNLOAD_EVENTS.DIALOG_OPENED) {
+ _showDialogOverlay();
+ return;
+ }
+
+ if (eventType === DOWNLOAD_EVENTS.DIALOG_CLOSED) {
+ _hideDialogOverlay();
+ return;
+ }
+
+ // handle download events (these require download to exist)
+ const download = _activeDownloads.get(downloadId);
+ if (!download) {
+ return;
+ }
+
+ switch (eventType) {
+ case DOWNLOAD_EVENTS.STARTED:
+ break;
+
+ case DOWNLOAD_EVENTS.COMPLETED:
+ case DOWNLOAD_EVENTS.CANCELLED:
+ case DOWNLOAD_EVENTS.ERROR:
+ if (_imageRibbonGallery && download.thumbDiv) {
+ _imageRibbonGallery._hideDownloadIndicator(download.thumbDiv);
+ }
+ _activeDownloads.delete(downloadId);
+ break;
+ }
+}
+
+function renderImageGalleryIcon(element, shadow) {
+ if (element && element.tagName.toLowerCase() === 'img') {
+ const selectedClass = imageGallerySelected ? 'class="img-gallery-toggle selected"' : "img-gallery-toggle";
+
+ return {
+ listOrder: proConstants.TOOLBOX_ORDERING.IMAGE_GALLERY,
+ htmlContent: `
+ ${icons.imageGallery}
+ `
+ };
+ }
+ return null;
+}
+
+function handleElementSelected(element) {
+ if (element && element.tagName.toLowerCase() === 'img' && imageGallerySelected) {
+ if (!_imageRibbonGallery || _imageRibbonGallery.element !== element) {
+ dismissImageRibbonGallery();
+ _imageRibbonGallery = new ImageRibbonGallery(element);
+ scrollImageToViewportIfRequired(element, _imageRibbonGallery);
+ }
+ } else {
+ // Not an image or gallery not selected, dismiss
+ dismissImageRibbonGallery();
+ }
+}
+
+// Register with LivePreviewView
+LivePreviewView.registerToolHandler("image-gallery", {
+ renderToolBoxItem: renderImageGalleryIcon,
+ handleClick: handleImageGalleryOptionClick,
+ dismiss: dismissImageRibbonGallery,
+ onElementSelected: handleElementSelected
+});
+
+customReturns.handleDownloadEvent = handleDownloadEvent;
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/info-box.js b/src/extensionsIntegrated/phoenix-pro/browser-context/info-box.js
new file mode 100644
index 0000000000..03d6fa46f4
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/info-box.js
@@ -0,0 +1,141 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global GLOBALS, LivePreviewView, cssStyles, icons, config, SHARED_STATE*/
+
+function InfoBox(element) {
+ this.element = element;
+ this.remove = this.remove.bind(this);
+ this.create();
+}
+
+InfoBox.prototype = {
+ _style: function () {
+ this.body = window.document.createElement("div");
+ this.body.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
+
+ // this is shadow DOM.
+ // we need it because if we add the box directly to the DOM then users style might override it.
+ // {mode: "open"} allows us to access the shadow DOM to get actual height/position of the boxes
+ const shadow = this.body.attachShadow({ mode: "open" });
+
+ // get the ID and classes for that element, as we need to display it in the box
+ const id = this.element.id;
+ const classes = Array.from(this.element.classList || []);
+
+ // get the dimensions of the element
+ const elemBounds = this.element.getBoundingClientRect();
+ // we only show integers, because showing decimal places will take up a lot more space
+ const elemWidth = Math.round(elemBounds.width);
+ const elemHeight = Math.round(elemBounds.height);
+
+ let content = ""; // this will hold the main content that will be displayed
+
+ // add the tag name and dimensions in the same line
+ content += "";
+ content += "" + this.element.tagName.toLowerCase() + "";
+ content += `${elemWidth} × ${elemHeight}`;
+ content += "";
+
+ // Add ID if present
+ if (id) {
+ content += "#" + id + "";
+ }
+
+ // Add classes (limit to 3 with dropdown indicator)
+ if (classes.length > 0) {
+ content += "";
+ for (var i = 0; i < Math.min(classes.length, 3); i++) {
+ content += "." + classes[i] + " ";
+ }
+ if (classes.length > 3) {
+ content += "+" + (classes.length - 3) + " more";
+ }
+ content += "";
+ }
+
+ // for 'a' tags we also show the href
+ if (this.element.tagName.toLowerCase() === "a") {
+ let href = this.element.getAttribute("href");
+ if (href && href.trim()) {
+ let displayHref = href.trim();
+
+ // this is just to safeguard from very large URLs. 35 char limit should be fine
+ if (displayHref.length > 35) {
+ displayHref = displayHref.substring(0, 35) + "...";
+ }
+ content += `${icons.link} ${displayHref}`;
+ }
+ }
+
+ // if element is non-editable we use gray bg color in info box, otherwise normal blue color
+ const bgColor = this.element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR) ? "#4285F4" : "#3C3F41";
+
+ // add everything to the shadow box with CSS variables for dynamic color
+ shadow.innerHTML = `
+
+ ${content}
+ `;
+ this._shadow = shadow;
+ },
+
+ create: function () {
+ this.remove(); // remove existing box if already present
+
+ // isElementVisible is used to check if the element is visible to the user
+ // because there might be cases when element is inside a closed/collapsed menu
+ // then in that case we don't want to show the box
+ if (config.mode !== "edit" || !LivePreviewView.isElementVisible(this.element)) {
+ return;
+ }
+
+ this._style(); // style the box
+ window.document.body.appendChild(this.body);
+
+ // initially when we append the box the body, we position it by -1000px on top as well as left
+ // and then once its added to the body then we reposition it to the actual place
+ const boxElement = this._shadow.querySelector(".phoenix-info-box");
+ if (boxElement) {
+ const boxPos = LivePreviewView.calcBoxPosition(this.element, "info-box", this);
+ boxElement.style.left = boxPos.leftPos + "px";
+ boxElement.style.top = boxPos.topPos + "px";
+ }
+ },
+
+ remove: function () {
+ if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) {
+ window.document.body.removeChild(this.body);
+ this.body = null;
+ SHARED_STATE._infoBox = null;
+ }
+ }
+};
+
+function dismissInfoBox() {
+ if (SHARED_STATE._infoBox) {
+ SHARED_STATE._infoBox.remove();
+ SHARED_STATE._infoBox = null;
+ }
+}
+
+function createInfoBox(element) {
+ SHARED_STATE._infoBox = new InfoBox(element);
+}
+
+function handleElementSelected(element) {
+ dismissInfoBox();
+ createInfoBox(element);
+}
+
+LivePreviewView.registerToolHandler("InfoBox", {
+ dismiss: dismissInfoBox,
+ createInfoBox: createInfoBox,
+ onElementSelected: handleElementSelected
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/more-options-dropdown.js b/src/extensionsIntegrated/phoenix-pro/browser-context/more-options-dropdown.js
new file mode 100644
index 0000000000..9edd898ab1
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/more-options-dropdown.js
@@ -0,0 +1,217 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global GLOBALS, LivePreviewView, cssStyles, SHARED_STATE*/
+
+function MoreOptionsDropdown(targetElement, ellipsisButton) {
+ this.targetElement = targetElement;
+ this.ellipsisButton = ellipsisButton;
+ this.remove = this.remove.bind(this);
+ this.create();
+}
+
+MoreOptionsDropdown.prototype = {
+ _getDropdownPosition: function (dropdownWidth, dropdownHeight) {
+ const buttonBounds = this.ellipsisButton.getBoundingClientRect();
+ const viewportHeight = window.innerHeight;
+ const viewportWidth = window.innerWidth;
+
+ const toolBox = SHARED_STATE._toolBox._shadow.querySelector(".phoenix-tool-box");
+ const toolBoxBounds = toolBox.getBoundingClientRect();
+ const targetElementBounds = this.targetElement.getBoundingClientRect();
+
+ let topPos, leftPos;
+
+ const checkOverlap = function (dTop, dLeft, dWidth, dHeight) {
+ const dropdownRight = dLeft + dWidth;
+ const dropdownBottom = dTop + dHeight;
+ const elemRight = targetElementBounds.left + targetElementBounds.width;
+ const elemBottom = targetElementBounds.top + targetElementBounds.height;
+
+ return !(
+ dLeft > elemRight ||
+ dropdownRight < targetElementBounds.left ||
+ dTop > elemBottom ||
+ dropdownBottom < targetElementBounds.top
+ );
+ };
+
+ const isToolBoxAboveElement = toolBoxBounds.bottom < targetElementBounds.top;
+
+ if (isToolBoxAboveElement) {
+ const spaceAbove = toolBoxBounds.top;
+
+ if (spaceAbove >= dropdownHeight + 6) {
+ topPos = toolBoxBounds.top + window.pageYOffset - dropdownHeight - 6;
+ } else {
+ topPos = toolBoxBounds.bottom + window.pageYOffset + 6;
+
+ const tempTop = toolBoxBounds.bottom;
+ const tempLeft = buttonBounds.right - dropdownWidth;
+
+ if (checkOverlap(tempTop, tempLeft, dropdownWidth, dropdownHeight)) {
+ let shiftedLeft = targetElementBounds.right + 6;
+ if (shiftedLeft + dropdownWidth <= viewportWidth - 6) {
+ leftPos = shiftedLeft + window.pageXOffset;
+ return { topPos: topPos, leftPos: leftPos };
+ }
+
+ shiftedLeft = targetElementBounds.left - dropdownWidth - 6;
+ if (shiftedLeft >= 6) {
+ leftPos = shiftedLeft + window.pageXOffset;
+ return { topPos: topPos, leftPos: leftPos };
+ }
+ }
+ }
+ } else {
+ const spaceBelow = viewportHeight - toolBoxBounds.bottom;
+
+ if (spaceBelow >= dropdownHeight + 6) {
+ topPos = toolBoxBounds.bottom + window.pageYOffset + 6;
+ } else {
+ topPos = toolBoxBounds.top + window.pageYOffset - dropdownHeight - 6;
+ }
+ }
+
+ leftPos = buttonBounds.right + window.pageXOffset - dropdownWidth;
+
+ if (leftPos < 6) {
+ leftPos = 6;
+ }
+
+ if (leftPos + dropdownWidth > viewportWidth - 6) {
+ leftPos = viewportWidth - dropdownWidth - 6;
+ }
+
+ return { topPos: topPos, leftPos: leftPos };
+ },
+
+ _style: function () {
+ this.body = window.document.createElement("div");
+ this.body.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
+
+ const shadow = this.body.attachShadow({ mode: "open" });
+
+ // Render handler dropdown items with ordering support
+ const handlerItems = LivePreviewView.getAllToolHandlers()
+ .map((handler, index) => {
+ if (!handler.renderDropdownItems) {
+ return null;
+ }
+ const result = handler.renderDropdownItems();
+ if (!result) {
+ return null;
+ }
+ return {
+ listOrder: result.listOrder !== undefined ? result.listOrder : Number.MAX_SAFE_INTEGER,
+ htmlContent: result.htmlContent || "",
+ handlerName: handler.name || `handler_${index}`
+ };
+ })
+ .filter((item) => item && item.htmlContent)
+ .sort((a, b) => {
+ if (a.listOrder !== b.listOrder) {
+ return a.listOrder - b.listOrder;
+ }
+ // Same order - sort alphabetically by handler name
+ return a.handlerName.localeCompare(b.handlerName);
+ })
+ .map((item) => item.htmlContent)
+ .join("");
+
+ let content = `
+
+ `;
+
+ let styles = cssStyles.toolBoxDropdown;
+ shadow.innerHTML = `${content}`;
+ this._shadow = shadow;
+ },
+
+ create: function () {
+ this.remove();
+ this._style();
+ window.document.body.appendChild(this.body);
+
+ // to position the dropdown element at the right position
+ const dropdownElement = this._shadow.querySelector(".phoenix-dropdown");
+ if (dropdownElement) {
+ const dropdownRect = dropdownElement.getBoundingClientRect();
+ const pos = this._getDropdownPosition(dropdownRect.width, dropdownRect.height);
+
+ dropdownElement.style.left = pos.leftPos + "px";
+ dropdownElement.style.top = pos.topPos + "px";
+ }
+
+ // click handlers for the dropdown items
+ const items = this._shadow.querySelectorAll(".dropdown-item");
+ items.forEach((item) => {
+ item.addEventListener("click", (event) => {
+ event.stopPropagation();
+ event.preventDefault();
+ const action = event.currentTarget.getAttribute("data-action");
+
+ // Check if any handler wants to handle this dropdown action
+ let handlerHandledIt = false;
+ const handler = LivePreviewView.getToolHandler(action);
+ if (handler && handler.handleDropdownClick) {
+ handlerHandledIt = handler.handleDropdownClick(event, this.targetElement, this);
+ if (handlerHandledIt) {
+ this.remove();
+ }
+ }
+
+ if (!handlerHandledIt) {
+ // Standard option - handle and close dropdown
+ const actionHandler = LivePreviewView.getToolHandler(action);
+ const keepExitingToolBox = actionHandler && actionHandler.handleClick &&
+ actionHandler.handleClick(
+ event, this.targetElement, SHARED_STATE._toolBox && SHARED_STATE._toolBox._shadow
+ );
+
+ if (!keepExitingToolBox) {
+ this.remove();
+ if (SHARED_STATE._toolBox) {
+ SHARED_STATE._toolBox.remove();
+ }
+ }
+ }
+ });
+ });
+ },
+
+ remove: function () {
+ if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) {
+ window.document.body.removeChild(this.body);
+ this.body = null;
+ SHARED_STATE._moreOptionsDropdown = null;
+ }
+ }
+};
+
+function dismissMoreOptionsDropdown() {
+ if (SHARED_STATE._moreOptionsDropdown) {
+ SHARED_STATE._moreOptionsDropdown.remove();
+ SHARED_STATE._moreOptionsDropdown = null;
+ }
+}
+
+function createMoreOptionsDropdown(targetElement, ellipsisButton) {
+ dismissMoreOptionsDropdown();
+ SHARED_STATE._moreOptionsDropdown = new MoreOptionsDropdown(targetElement, ellipsisButton);
+}
+
+// this function ignores all the params and stuff, it is just to dismiss the more options dropdown
+function handleConfigChange(oldConfig, newConfig) {
+ dismissMoreOptionsDropdown();
+}
+
+LivePreviewView.registerToolHandler("MoreOptionsDropdown", {
+ dismiss: dismissMoreOptionsDropdown,
+ createMoreOptionsDropdown: createMoreOptionsDropdown,
+ handleConfigChange: handleConfigChange
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/ruler-lines.js b/src/extensionsIntegrated/phoenix-pro/browser-context/ruler-lines.js
new file mode 100644
index 0000000000..a226b67665
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/ruler-lines.js
@@ -0,0 +1,304 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global GLOBALS, LivePreviewView, cssStyles, strings, icons, proConstants*/
+
+let _currentRulerLines;
+let _rulerLinesConfig = { enabled: false };
+let _previouslySelectedElement = null;
+
+function dismissRulerLines() {
+ if (_currentRulerLines) {
+ _currentRulerLines.remove();
+ _currentRulerLines = null;
+ }
+}
+
+/**
+ * RulerLines shows measurement lines from the edges of a selected element
+ * to the edges of the document, with pixel coordinate labels
+ */
+function RulerLines(element) {
+ this.element = element;
+ this.editable = element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+
+ this.sides = ["left", "right", "top", "bottom"];
+ this.lineElements = {};
+ this.labelElements = {};
+
+ this.create();
+ this.update();
+}
+
+RulerLines.prototype = {
+ create: function() {
+ this.container = window.document.createElement("div");
+ this.container.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
+
+ // Prevent ruler lines from extending document scroll area
+ // set container to match current document dimensions with overflow clip
+ this.container.style.position = "absolute";
+ this.container.style.top = "0";
+ this.container.style.left = "0";
+ this.container.style.width = document.documentElement.scrollWidth + "px";
+ this.container.style.height = document.documentElement.scrollHeight + "px";
+ this.container.style.overflow = "clip";
+ this.container.style.pointerEvents = "none";
+
+ const shadow = this.container.attachShadow({ mode: "open" });
+ this._shadow = shadow;
+
+ const lineClass = this.editable ? "phoenix-ruler-line-editable" : "phoenix-ruler-line-non-editable";
+ const labelClass = this.editable ? "phoenix-ruler-label-editable" : "phoenix-ruler-label-non-editable";
+
+ let html = "";
+ for (const side of this.sides) {
+ html += ``;
+ html += ``;
+ }
+
+ shadow.innerHTML = `
+
+ ${html}
+ `;
+
+ window.document.body.appendChild(this.container);
+
+ this.lineElements = {
+ left: shadow.querySelector('.phoenix-ruler-line[data-side="left"]'),
+ right: shadow.querySelector('.phoenix-ruler-line[data-side="right"]'),
+ top: shadow.querySelector('.phoenix-ruler-line[data-side="top"]'),
+ bottom: shadow.querySelector('.phoenix-ruler-line[data-side="bottom"]')
+ };
+
+ this.labelElements = {
+ left: shadow.querySelector('.phoenix-ruler-label[data-side="left"]'),
+ right: shadow.querySelector('.phoenix-ruler-label[data-side="right"]'),
+ top: shadow.querySelector('.phoenix-ruler-label[data-side="top"]'),
+ bottom: shadow.querySelector('.phoenix-ruler-label[data-side="bottom"]')
+ };
+ },
+
+ update: function() {
+ if (!this.element) {
+ return;
+ }
+
+ const rect = this.element.getBoundingClientRect();
+ const scrollTop = window.pageYOffset;
+ const scrollLeft = window.pageXOffset;
+
+ const edges = {
+ left: rect.left + scrollLeft - 0.8,
+ right: rect.right + scrollLeft,
+ top: rect.top + scrollTop - 0.8,
+ bottom: rect.bottom + scrollTop
+ };
+
+ const docHeight = document.documentElement.scrollHeight;
+ const docWidth = document.documentElement.scrollWidth;
+
+ this.lineElements.left.style.width = "1px";
+ this.lineElements.left.style.height = docHeight + "px";
+ this.lineElements.left.style.left = edges.left + "px";
+ this.lineElements.left.style.top = "0px";
+
+ this.lineElements.right.style.width = "1px";
+ this.lineElements.right.style.height = docHeight + "px";
+ this.lineElements.right.style.left = edges.right + "px";
+ this.lineElements.right.style.top = "0px";
+
+ this.lineElements.top.style.height = "1px";
+ this.lineElements.top.style.width = docWidth + "px";
+ this.lineElements.top.style.top = edges.top + "px";
+ this.lineElements.top.style.left = "0px";
+
+ this.lineElements.bottom.style.height = "1px";
+ this.lineElements.bottom.style.width = docWidth + "px";
+ this.lineElements.bottom.style.top = edges.bottom + "px";
+ this.lineElements.bottom.style.left = "0px";
+
+ const x1 = Math.floor(edges.left + 1);
+ const x2 = x1 + rect.width;
+ const y1 = Math.floor(edges.top + 1);
+ const y2 = y1 + rect.height;
+
+ this.labelElements.left.textContent = Math.round(x1) + "px";
+ this.labelElements.right.textContent = Math.round(x2) + "px";
+ this.labelElements.top.textContent = Math.round(y1) + "px";
+ this.labelElements.bottom.textContent = Math.round(y2) + "px";
+
+ const LABEL_MARGIN = 6;
+
+ const THRESHOLD_Y = Math.min(150, window.innerHeight * 0.15);
+ const THRESHOLD_X = Math.min(150, window.innerWidth * 0.15);
+
+ const leftLabelWidth = this.labelElements.left.offsetWidth;
+ const rightLabelWidth = this.labelElements.right.offsetWidth;
+ const topLabelHeight = this.labelElements.top.offsetHeight;
+ const bottomLabelHeight = this.labelElements.bottom.offsetHeight;
+
+ let labelsVerticalPosition = scrollTop + 10;
+
+ if (rect.top < THRESHOLD_Y && rect.height < window.innerHeight - 2 * THRESHOLD_Y) {
+ labelsVerticalPosition = scrollTop + window.innerHeight - 30;
+ }
+
+ let leftLabelLeft = edges.left - leftLabelWidth - LABEL_MARGIN;
+ if (rect.left - leftLabelWidth - LABEL_MARGIN < LABEL_MARGIN) {
+ leftLabelLeft = edges.left + LABEL_MARGIN;
+ }
+ this.labelElements.left.style.left = leftLabelLeft + "px";
+ this.labelElements.left.style.top = labelsVerticalPosition + "px";
+
+ let rightLabelLeft = edges.right + LABEL_MARGIN;
+ if (rect.right + rightLabelWidth + LABEL_MARGIN > window.innerWidth - LABEL_MARGIN) {
+ rightLabelLeft = edges.right - rightLabelWidth - LABEL_MARGIN;
+ }
+ this.labelElements.right.style.left = rightLabelLeft + "px";
+ this.labelElements.right.style.top = labelsVerticalPosition + "px";
+
+ let labelsHorizontalPosition = scrollLeft + 10;
+
+ if (rect.left < THRESHOLD_X && rect.width < window.innerWidth - 2 * THRESHOLD_X) {
+ const maxLabelWidth = Math.max(
+ this.labelElements.top.offsetWidth,
+ this.labelElements.bottom.offsetWidth
+ );
+ labelsHorizontalPosition = scrollLeft + window.innerWidth - maxLabelWidth - 20;
+ }
+
+ let topLabelTop = edges.top - topLabelHeight - LABEL_MARGIN;
+ if (rect.top - topLabelHeight - LABEL_MARGIN < LABEL_MARGIN) {
+ topLabelTop = edges.top + LABEL_MARGIN;
+ }
+ this.labelElements.top.style.left = labelsHorizontalPosition + "px";
+ this.labelElements.top.style.top = topLabelTop + "px";
+
+ let bottomLabelTop = edges.bottom + LABEL_MARGIN;
+ if (rect.bottom + bottomLabelHeight + LABEL_MARGIN > window.innerHeight - LABEL_MARGIN) {
+ bottomLabelTop = edges.bottom - bottomLabelHeight - LABEL_MARGIN;
+ }
+ this.labelElements.bottom.style.left = labelsHorizontalPosition + "px";
+ this.labelElements.bottom.style.top = bottomLabelTop + "px";
+ },
+
+ remove: function() {
+ if (this.container && this.container.parentNode) {
+ window.document.body.removeChild(this.container);
+ }
+
+ this.container = null;
+ this._shadow = null;
+ this.lineElements = {};
+ this.labelElements = {};
+ }
+};
+
+// Helper function to redraw ruler lines
+function redrawRulerLines() {
+ if (_currentRulerLines) {
+ _currentRulerLines.update();
+ }
+}
+
+// Scroll handler for ruler lines
+function _rulerLinesScrollHandler(e) {
+ // Document scrolls can be updated immediately. Any other scrolls
+ // need to be updated on a timer to ensure the layout is correct.
+ if (e.target === window.document) {
+ redrawRulerLines();
+ } else {
+ if (_currentRulerLines) {
+ window.setTimeout(redrawRulerLines, 0);
+ }
+ }
+}
+
+// Lifecycle callback: called when an element is selected
+function onRulerLinesElementSelected(element) {
+ _previouslySelectedElement = element;
+ if (_rulerLinesConfig.enabled) {
+ _currentRulerLines = new RulerLines(element);
+ }
+}
+
+// Lifecycle callback: called when element state is cleaned up
+function onRulerLinesElementCleanup() {
+ dismissRulerLines();
+ _previouslySelectedElement = null;
+}
+
+// Lifecycle callback: called when config changes
+function handleRulerLinesConfigChange(oldConfig, newConfig) {
+ const rulerLinesChanged = oldConfig.showRulerLines !== newConfig.showRulerLines;
+ _rulerLinesConfig.enabled = newConfig.showRulerLines;
+
+ if (rulerLinesChanged && _previouslySelectedElement) {
+ if (newConfig.showRulerLines) {
+ if (!_currentRulerLines) {
+ _currentRulerLines = new RulerLines(_previouslySelectedElement);
+ }
+ } else {
+ dismissRulerLines();
+ }
+ }
+}
+
+// Dropdown handler: render ruler lines dropdown item
+function renderRulerLinesDropdownItems() {
+ return {
+ listOrder: proConstants.DROPDOWN_ORDERING.SHOW_MEASUREMENTS,
+ htmlContent: `
+
+ ${strings.showRulerLines}
+
+ `
+ };
+}
+
+// Dropdown handler: handle clicks on ruler lines dropdown item
+function handleRulerLinesDropdownClick(_event, _targetElement, dropdown) {
+ _rulerLinesConfig.enabled = !_rulerLinesConfig.enabled;
+
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ type: "toggleRulerLines",
+ enabled: _rulerLinesConfig.enabled
+ });
+
+ // Update checkmark
+ const checkmark = dropdown._shadow.querySelector('[data-action="RulerLines"] .item-checkmark');
+ if (checkmark) {
+ checkmark.style.visibility = _rulerLinesConfig.enabled ? 'visible' : 'hidden';
+ }
+
+ // Apply or remove ruler lines
+ if (_rulerLinesConfig.enabled && _previouslySelectedElement) {
+ if (!_currentRulerLines) {
+ _currentRulerLines = new RulerLines(_previouslySelectedElement);
+ }
+ } else {
+ dismissRulerLines();
+ }
+
+ return true; // We handled it
+}
+
+// Register scroll listener
+window.addEventListener("scroll", _rulerLinesScrollHandler, true);
+
+// Register with LivePreviewView
+LivePreviewView.registerToolHandler("RulerLines", {
+ dismiss: dismissRulerLines,
+ redraw: redrawRulerLines,
+ onElementSelected: onRulerLinesElementSelected,
+ onElementCleanup: onRulerLinesElementCleanup,
+ handleConfigChange: handleRulerLinesConfigChange,
+ renderDropdownItems: renderRulerLinesDropdownItems,
+ handleDropdownClick: handleRulerLinesDropdownClick
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/text-edit.js b/src/extensionsIntegrated/phoenix-pro/browser-context/text-edit.js
new file mode 100644
index 0000000000..6f234090a8
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/text-edit.js
@@ -0,0 +1,389 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+/*global GLOBALS, LivePreviewView, dismissUIAndCleanupState, customReturns, proConstants, strings, icons, SHARED_STATE*/
+
+// we store references to interaction blocker event handlers so we can remove them when switching modes
+let _interactionBlockerHandlers = null;
+
+// helper function to check if an element is inside an SVG tag
+// we need this because SVG elements don't support contenteditable
+function isInsideSVGTag(element) {
+ let parent = element;
+ while (parent && parent !== window.document) {
+ if (parent.tagName.toLowerCase() === "svg") {
+ return true;
+ }
+ parent = parent.parentElement;
+ }
+ return false;
+}
+
+/**
+ * this function is to check if an element should show the edit text option
+ * it is needed because edit text option doesn't make sense with many elements like images, videos, hr tag etc
+ * @param {Element} element - DOM element to check
+ * @returns {boolean} - true if we should show the edit text option otherwise false
+ */
+function shouldShowEditTextOption(element) {
+ if (!element || !element.tagName) {
+ return false;
+ }
+
+ if (element.hasAttribute("disabled")) {
+ return false;
+ }
+
+ const tagName = element.tagName.toLowerCase();
+
+ // these are self-closing tags and don't allow any text content
+ const voidElements = [
+ "img",
+ "br",
+ "hr",
+ "input",
+ "meta",
+ "link",
+ "area",
+ "base",
+ "col",
+ "embed",
+ "source",
+ "track",
+ "wbr"
+ ];
+
+ // these elements are non-editable as they have their own mechanisms
+ const nonEditableElements = [
+ "script",
+ "style",
+ "noscript",
+ "canvas",
+ "svg",
+ "video",
+ "audio",
+ "iframe",
+ "object",
+ "select",
+ "textarea"
+ ];
+
+ if (voidElements.includes(tagName) || nonEditableElements.includes(tagName) || isInsideSVGTag(element)) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * this acts as the "KILL SWITCH", it blocks all the click related events from user elements
+ * but we exclude all the phoenix interal elements
+ * we call this function from inside registerHandlers
+ */
+function registerInteractionBlocker() {
+ // Create an object to store handler references
+ _interactionBlockerHandlers = {};
+
+ const eventsToBlock = ["click", "dblclick"];
+
+ eventsToBlock.forEach((eventType) => {
+ // Create a named handler function so we can remove it later
+ const handler = function (event) {
+ const element = event.target;
+
+ // WHITELIST: Allow Phoenix internal UI elements to work normally
+ if (element.closest(`[${GLOBALS.PHCODE_INTERNAL_ATTR}]`)) {
+ return;
+ }
+
+ // allow clicks within the element that user is currently editing
+ // this is done to prevent the interaction blocker from interfering with text editing
+ if (SHARED_STATE._currentlyEditingElement &&
+ (SHARED_STATE._currentlyEditingElement === element ||
+ SHARED_STATE._currentlyEditingElement.contains(element))) {
+ return;
+ }
+
+ // BLOCK: Kill all user page interactions in edit mode
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ // HANDLE: Process clicks and double-clicks for element selection/editing
+ if (eventType === "click") {
+ // Skip click handling on the second click of a double-click
+ // event.detail = 1 for first click, 2 for second click (during double-click)
+ if (event.detail !== 2) {
+ LivePreviewView.handleElementClick(element, event);
+ }
+ } else if (eventType === "dblclick") {
+ if (LivePreviewView.isElementEditable(element) && shouldShowEditTextOption(element)) {
+ startEditing(element);
+ }
+ }
+ };
+
+ // Store the handler reference
+ _interactionBlockerHandlers[eventType] = handler;
+
+ // Register the handler in capture phase
+ window.document.addEventListener(eventType, handler, true);
+ });
+}
+
+/**
+ * this function is to remove all the interaction blocker
+ * this is needed when user is in preview/highlight mode
+ */
+function unregisterInteractionBlocker() {
+ if (_interactionBlockerHandlers) {
+ Object.keys(_interactionBlockerHandlers).forEach((eventType) => {
+ window.document.removeEventListener(eventType, _interactionBlockerHandlers[eventType], true);
+ });
+ _interactionBlockerHandlers = null;
+ }
+}
+
+/**
+ * This function is responsible to move the cursor to the end of the text content when we start editing
+ * @param {DOMElement} element
+ */
+function moveCursorToEnd(selection, element) {
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
+}
+
+/**
+ * gets the current selection as character offsets relative to the element's text content
+ *
+ * @param {Element} element - the contenteditable element
+ * @returns {Object|null} selection info with startOffset, endOffset, selectedText or null
+ */
+function getSelectionInfo(element) {
+ const selection = window.getSelection();
+ if (!selection.rangeCount) {
+ return null;
+ }
+
+ const range = selection.getRangeAt(0);
+
+ // make sure selection is within the element we're editing
+ if (!element.contains(range.commonAncestorContainer)) {
+ return null;
+ }
+
+ // create a range from element start to selection start to calculate offset
+ const preSelectionRange = document.createRange();
+ preSelectionRange.selectNodeContents(element);
+ preSelectionRange.setEnd(range.startContainer, range.startOffset);
+
+ const startOffset = preSelectionRange.toString().length;
+ const selectedText = range.toString();
+ const endOffset = startOffset + selectedText.length;
+
+ return {
+ startOffset: startOffset,
+ endOffset: endOffset,
+ selectedText: selectedText
+ };
+}
+
+/**
+ * handles text formatting commands when user presses ctrl+b/i/u
+ * sends a message to backend to apply consistent formatting tags
+ * @param {Element} element - the contenteditable element
+ * @param {string} formatKey - the format key ('b', 'i', or 'u')
+ */
+function handleFormatting(element, formatKey) {
+ const selection = getSelectionInfo(element);
+
+ // need an actual selection, not just cursor position
+ if (!selection || selection.startOffset === selection.endOffset) {
+ return;
+ }
+
+ const formatCommand = {
+ b: "bold",
+ i: "italic",
+ u: "underline"
+ }[formatKey];
+
+ if (!formatCommand) {
+ return;
+ }
+
+ const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+ if (!tagId) {
+ return;
+ }
+
+ // send formatting message to backend
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ livePreviewFormatCommand: formatCommand,
+ tagId: Number(tagId),
+ element: element,
+ selection: selection,
+ currentHTML: element.innerHTML
+ });
+
+ // exit edit mode after applying format
+ finishEditingCleanup(element);
+}
+
+// Function to handle direct editing of elements in the live preview
+function startEditing(element) {
+ if (!LivePreviewView.isElementEditable(element)) {
+ return;
+ }
+
+ SHARED_STATE._currentlyEditingElement = element;
+ element.setAttribute("contenteditable", "true");
+ element.setAttribute("draggable", "false");
+ element.focus();
+ // to compare with the new text content, if same we don't make any changes in the editor area
+ const oldContent = element.textContent;
+
+ // Move cursor to end if no existing selection
+ const selection = window.getSelection();
+ if (selection.rangeCount === 0 || selection.isCollapsed) {
+ moveCursorToEnd(selection, element);
+ }
+
+ dismissUIAndCleanupState();
+
+ // flag to check if escape is pressed, if pressed we prevent onBlur from handling it as keydown already handles
+ let isEscapePressed = false;
+
+ function onBlur() {
+ // Small delay so that keydown can handle things first
+ setTimeout(() => {
+ if (isEscapePressed) {
+ isEscapePressed = false;
+ finishEditingCleanup(element);
+ return;
+ }
+
+ const newContent = element.textContent;
+ if (oldContent !== newContent) {
+ finishEditing(element);
+ } else {
+ // if same content, we just cleanup things
+ finishEditingCleanup(element);
+ }
+ }, 10);
+ }
+
+ function onKeyDown(event) {
+ if (event.key === "Escape") {
+ isEscapePressed = true;
+ // Cancel editing
+ event.preventDefault();
+ const newContent = element.textContent;
+ if (oldContent !== newContent) {
+ finishEditing(element, false); // false means that the edit operation was cancelled
+ } else {
+ // no content change we can avoid sending details to the editor
+ finishEditingCleanup(element);
+ }
+ } else if (event.key === "Enter" && !event.shiftKey) {
+ isEscapePressed = false;
+ // Finish editing on Enter (unless Shift is held)
+ event.preventDefault();
+ finishEditing(element);
+ } else if ((event.key === " " || event.key === "Spacebar") && element.tagName.toLowerCase() === "button") {
+ event.preventDefault();
+ document.execCommand("insertText", false, " ");
+ } else if ((event.ctrlKey || event.metaKey) && (event.key === "b" || event.key === "i" || event.key === "u")) {
+ // handle formatting commands (ctrl+b/i/u)
+ event.preventDefault();
+
+ // check if user has typed text that hasn't been saved yet
+ const currentContent = element.textContent;
+ const hasTextChanges = oldContent !== currentContent;
+
+ // so if user already has some text changes, we just save that
+ // we do formatting only when there are no text changes
+ if (hasTextChanges) {
+ finishEditing(element, true);
+ } else {
+ handleFormatting(element, event.key);
+ }
+ }
+ }
+
+ element.addEventListener("blur", onBlur);
+ element.addEventListener("keydown", onKeyDown);
+
+ // Store the event listeners for later removal
+ element._editListeners = {
+ blur: onBlur,
+ keydown: onKeyDown
+ };
+}
+
+function finishEditingCleanup(element) {
+ if (!LivePreviewView.isElementEditable(element) || !element.hasAttribute("contenteditable")) {
+ return;
+ }
+
+ SHARED_STATE._currentlyEditingElement = null;
+ element.removeAttribute("contenteditable");
+ element.removeAttribute("draggable");
+ dismissUIAndCleanupState();
+
+ // Remove event listeners
+ if (element._editListeners) {
+ element.removeEventListener("blur", element._editListeners.blur);
+ element.removeEventListener("keydown", element._editListeners.keydown);
+ delete element._editListeners;
+ }
+}
+
+// Function to finish editing and apply changes
+// isEditSuccessful: this is a boolean value, defaults to true. false only when the edit operation is cancelled
+function finishEditing(element, isEditSuccessful = true) {
+ finishEditingCleanup(element);
+
+ const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR);
+ window._Brackets_MessageBroker.send({
+ livePreviewEditEnabled: true,
+ livePreviewTextEdit: true,
+ element: element,
+ newContent: element.outerHTML,
+ tagId: Number(tagId),
+ isEditSuccessful: isEditSuccessful
+ });
+}
+
+function renderEditTextOption(element, shadow) {
+ const showEditTextOption = shouldShowEditTextOption(element);
+ if (showEditTextOption) {
+ return {
+ listOrder: proConstants.TOOLBOX_ORDERING.EDIT_TEXT,
+ htmlContent: `
+ ${icons.edit}
+ `
+ };
+ }
+ return null;
+}
+
+// just a wrapper function to get rid of all the params we don't need
+function handleEditTextClick(event, element, shadow) {
+ startEditing(element);
+}
+
+// Register with LivePreviewView
+LivePreviewView.registerToolHandler("edit-text", {
+ renderToolBoxItem: renderEditTextOption,
+ handleClick: handleEditTextClick,
+ registerInteractionBlocker: registerInteractionBlocker,
+ unregisterInteractionBlocker: unregisterInteractionBlocker
+});
+
+customReturns.startEditing = startEditing;
+customReturns.finishEditing = finishEditing;
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/toast-message.js b/src/extensionsIntegrated/phoenix-pro/browser-context/toast-message.js
new file mode 100644
index 0000000000..d8ab693105
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/toast-message.js
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+/*global GLOBALS, strings, cssStyles, LivePreviewView, customReturns*/
+
+let _toastTimeout = null;
+
+const TOAST_TYPE_MAPPING = {
+ notEditable: strings.toastNotEditable,
+ copyFirstTime: strings.toastCopyFirstTime
+};
+
+/**
+ * this function is to show a toast notification at the bottom center of the screen
+ * this toast message is used when user tries to edit a non-editable element
+ * @param {String} toastType - toastType determines the message to display in the toast
+ * @param {Number} duration - optional duration in milliseconds (default: 3000)
+ */
+function showToastMessage(toastType, duration = 3000) {
+ // clear any existing toast & timer, if there are any
+ dismissToastMessage();
+
+ // create a new fresh toast container
+ const toast = window.document.createElement("div");
+ toast.id = "phoenix-toast-notification";
+ toast.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
+ const shadow = toast.attachShadow({ mode: "open" });
+
+ const styles = cssStyles.toastMessage;
+
+ const content = `
+
+
+
+ `;
+
+ shadow.innerHTML = `${content}`;
+ window.document.body.appendChild(toast);
+
+ // Auto-dismiss after the given time
+ _toastTimeout = setTimeout(() => {
+ if (toast && toast.parentNode) {
+ toast.remove();
+ }
+ _toastTimeout = null;
+ }, duration);
+}
+
+/**
+ * this function is to dismiss the toast message
+ * and clear its timeout (if any)
+ */
+function dismissToastMessage() {
+ const toastMessage = window.document.getElementById("phoenix-toast-notification");
+ if (toastMessage) {
+ toastMessage.remove();
+ }
+ if (_toastTimeout) {
+ clearTimeout(_toastTimeout);
+ }
+ _toastTimeout = null;
+}
+
+// this is just a wrapper function which calls the show toast message
+// its needed to discard the element param that is passed to it by remote functions
+function onNonEditableElementClick(element) {
+ showToastMessage("notEditable");
+}
+
+// Register with LivePreviewView
+LivePreviewView.registerToolHandler("toast-message", {
+ onNonEditableElementClick: onNonEditableElementClick,
+ dismiss: dismissToastMessage
+});
+
+customReturns.showToastMessage = showToastMessage;
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-context/tool-box.js b/src/extensionsIntegrated/phoenix-pro/browser-context/tool-box.js
new file mode 100644
index 0000000000..176307a4b2
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-context/tool-box.js
@@ -0,0 +1,170 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global GLOBALS, LivePreviewView, cssStyles, strings, icons, config, SHARED_STATE*/
+
+function ToolBox(element) {
+ this.element = element;
+ this.remove = this.remove.bind(this);
+ this.create();
+}
+
+ToolBox.prototype = {
+ _style: function () {
+ this.body = window.document.createElement("div");
+ this.body.setAttribute(GLOBALS.PHCODE_INTERNAL_ATTR, "true");
+
+ // this is shadow DOM.
+ // we need it because if we add the box directly to the DOM then users style might override it.
+ // {mode: "open"} allows us to access the shadow DOM to get actual height/position of the boxes
+ const shadow = this.body.attachShadow({ mode: "open" });
+
+ let content = ``;
+ // Render handler options with ordering support
+ const handlerOptions = LivePreviewView.getAllToolHandlers()
+ .map((handler, index) => {
+ if (!handler.renderToolBoxItem) {
+ return null;
+ }
+ const result = handler.renderToolBoxItem(this.element);
+ if (!result) {
+ return null;
+ }
+ return {
+ listOrder: result.listOrder !== undefined ? result.listOrder : Number.MAX_SAFE_INTEGER,
+ htmlContent: result.htmlContent || "",
+ handlerName: handler.name || `handler_${index}`
+ };
+ })
+ .filter((item) => item && item.htmlContent)
+ .sort((a, b) => {
+ if (a.listOrder !== b.listOrder) {
+ return a.listOrder - b.listOrder;
+ }
+ // Same order - sort alphabetically by handler name
+ return a.handlerName.localeCompare(b.handlerName);
+ });
+
+ handlerOptions.forEach((item) => {
+ content += item.htmlContent;
+ });
+
+ // Always include more option ellipses at the end
+ content += `
+
+ ${icons.verticalEllipsis}
+
+ `;
+
+ let styles = cssStyles.toolBox;
+
+ // add everything to the shadow box
+ shadow.innerHTML = `${content}`;
+ this._shadow = shadow;
+ },
+
+ create: function () {
+ this.remove(); // remove existing box if already present
+
+ // isElementVisible is used to check if the element is visible to the user
+ // because there might be cases when element is inside a closed/collapsed menu
+ // then in that case we don't want to show the box
+ if (config.mode !== "edit" || !LivePreviewView.isElementVisible(this.element)) {
+ return;
+ }
+
+ this._style(); // style the box
+ window.document.body.appendChild(this.body);
+
+ // initially when we append the box the body, we position it by -1000px on top as well as left
+ // then we get the actual rendered dimensions of the box and then we reposition it to the actual place
+ const boxElement = this._shadow.querySelector(".phoenix-tool-box");
+ if (boxElement) {
+ const boxPos = LivePreviewView.calcBoxPosition(this.element, "tool-box", this);
+ boxElement.style.left = boxPos.leftPos + "px";
+ boxElement.style.top = boxPos.topPos + "px";
+ }
+
+ // add click handler to all the buttons
+ const spans = this._shadow.querySelectorAll(".tool-items span");
+ spans.forEach((span) => {
+ span.addEventListener("click", (event) => {
+ event.stopPropagation();
+ event.preventDefault();
+ // data-action is to differentiate between the buttons (duplicate, delete, select-parent etc)
+ const action = event.currentTarget.getAttribute("data-action");
+
+ if (action === "more-options") {
+ // to toggle the dropdown on more options button click
+ const dropdownHandler = LivePreviewView.getToolHandler("MoreOptionsDropdown");
+ if (dropdownHandler) {
+ if (SHARED_STATE._moreOptionsDropdown) {
+ dropdownHandler.dismiss();
+ } else {
+ dropdownHandler.createMoreOptionsDropdown(this.element, event.currentTarget);
+ }
+ }
+ } else {
+ const keepExitingToolBox = handleOptionClick(event, action, this.element);
+ // as we don't want to remove the tool box on certain items, for eg: duplicate
+ if (!keepExitingToolBox) {
+ this.remove();
+ }
+ }
+ });
+ });
+ },
+
+ remove: function () {
+ if (this.body && this.body.parentNode && this.body.parentNode === window.document.body) {
+ window.document.body.removeChild(this.body);
+ this.body = null;
+ SHARED_STATE._toolBox = null;
+ }
+ }
+};
+
+/**
+ * This function will get triggered when from the multiple advance DOM buttons, one is clicked
+ * this function just checks which exact button was clicked and call the required function
+ * @param {Event} e
+ * @param {String} action - the data-action attribute to differentiate between buttons
+ * @param {DOMElement} element - the selected DOM element
+ */
+function handleOptionClick(e, action, element) {
+ if (LivePreviewView.getToolHandler(action)) {
+ const handler = LivePreviewView.getToolHandler(action);
+ return (
+ handler &&
+ handler.handleClick &&
+ handler.handleClick(e, element, SHARED_STATE._toolBox && SHARED_STATE._toolBox._shadow)
+ );
+ }
+}
+
+function dismissToolBox() {
+ if (SHARED_STATE._toolBox) {
+ SHARED_STATE._toolBox.remove();
+ SHARED_STATE._toolBox = null;
+ }
+}
+
+function createToolBox(element) {
+ SHARED_STATE._toolBox = new ToolBox(element);
+}
+
+function handleElementSelected(element) {
+ dismissToolBox();
+ // as we show toolbox only for editable elements
+ if (LivePreviewView.isElementEditable(element)) {
+ createToolBox(element);
+ }
+}
+
+LivePreviewView.registerToolHandler("ToolBox", {
+ dismiss: dismissToolBox,
+ createToolBox: createToolBox,
+ onElementSelected: handleElementSelected
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/ai-prompt-box.css b/src/extensionsIntegrated/phoenix-pro/browser-css/ai-prompt-box.css
new file mode 100644
index 0000000000..1f82b732c3
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/ai-prompt-box.css
@@ -0,0 +1,107 @@
+:host {
+ all: initial !important;
+}
+
+.phoenix-ai-prompt-box {
+ position: absolute !important;
+ background: #3C3F41 !important;
+ border: 1px solid #4285F4 !important;
+ border-radius: 4px !important;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
+ font-family: Arial, sans-serif !important;
+ z-index: 2147483647 !important;
+ width: var(--ai-box-width) !important;
+ padding: 0 !important;
+ box-sizing: border-box !important;
+}
+
+.phoenix-ai-prompt-input-container {
+ position: relative !important;
+}
+
+.phoenix-ai-prompt-textarea {
+ width: 100% !important;
+ height: var(--ai-box-height) !important;
+ border: none !important;
+ border-radius: 4px 4px 0 0 !important;
+ padding: 12px 40px 12px 16px !important;
+ font-size: 14px !important;
+ font-family: Arial, sans-serif !important;
+ resize: none !important;
+ outline: none !important;
+ box-sizing: border-box !important;
+ background: transparent !important;
+ color: #c5c5c5 !important;
+ transition: background 0.2s ease !important;
+}
+
+.phoenix-ai-prompt-textarea:focus {
+ background: rgba(255, 255, 255, 0.03) !important;
+}
+
+.phoenix-ai-prompt-textarea::placeholder {
+ color: #a0a0a0 !important;
+ opacity: 0.7 !important;
+}
+
+.phoenix-ai-prompt-send-button {
+ background-color: transparent !important;
+ border: 1px solid transparent !important;
+ color: #a0a0a0 !important;
+ border-radius: 4px !important;
+ cursor: pointer !important;
+ padding: 3px 6px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ font-size: 14px !important;
+ transition: all 0.2s ease !important;
+}
+
+.phoenix-ai-prompt-send-button:hover:not(:disabled) {
+ border: 1px solid rgba(0, 0, 0, 0.24) !important;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important;
+}
+
+.phoenix-ai-prompt-send-button:disabled {
+ opacity: 0.5 !important;
+ cursor: not-allowed !important;
+}
+
+.phoenix-ai-bottom-controls {
+ border-top: 1px solid rgba(255,255,255,0.14) !important;
+ padding: 8px 16px !important;
+ background: transparent !important;
+ border-radius: 0 0 4px 4px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+}
+
+.phoenix-ai-model-select {
+ padding: 4px 8px !important;
+ border: 1px solid transparent !important;
+ border-radius: 4px !important;
+ font-size: 12px !important;
+ background: transparent !important;
+ color: #a0a0a0 !important;
+ outline: none !important;
+ cursor: pointer !important;
+ transition: all 0.2s ease !important;
+}
+
+.phoenix-ai-model-select:hover {
+ border: 1px solid rgba(0, 0, 0, 0.24) !important;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important;
+}
+
+.phoenix-ai-model-select:focus {
+ border: 1px solid rgba(0, 0, 0, 0.24) !important;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important;
+}
+
+.phoenix-ai-model-select option {
+ background: #000 !important;
+ color: #fff !important;
+ padding: 4px 8px !important;
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/dialog-overlay.css b/src/extensionsIntegrated/phoenix-pro/browser-css/dialog-overlay.css
new file mode 100644
index 0000000000..b17c6cb100
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/dialog-overlay.css
@@ -0,0 +1,29 @@
+:host {
+ all: initial !important;
+}
+
+.phoenix-dialog-overlay {
+ position: fixed !important;
+ top: 0 !important;
+ left: 0 !important;
+ width: 100% !important;
+ height: 100% !important;
+ background: rgba(0, 0, 0, 0.5) !important;
+ z-index: 2147483646 !important;
+ pointer-events: auto !important;
+}
+
+.phoenix-dialog-message-bar {
+ position: absolute !important;
+ top: 50% !important;
+ left: 50% !important;
+ transform: translate(-50%, -50%) !important;
+ color: #ffffff !important;
+ background-color: #333333 !important;
+ padding: 1em 1.5em !important;
+ text-align: center !important;
+ font-size: 16px !important;
+ border-radius: 3px !important;
+ font-family: "SourceSansPro", Helvetica, Arial, sans-serif !important;
+ z-index: 2147483647 !important;
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/hot-corners.css b/src/extensionsIntegrated/phoenix-pro/browser-css/hot-corners.css
new file mode 100644
index 0000000000..c6f509ccb2
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/hot-corners.css
@@ -0,0 +1,76 @@
+:host {
+ all: initial !important;
+}
+
+.phoenix-hot-corner {
+ position: fixed !important;
+ top: -35px !important;
+ left: 50% !important;
+ transform: translateX(-50%) !important;
+ width: 70px !important;
+ height: 40px !important;
+ background-color: rgba(60, 63, 65, 0.95) !important;
+ border-radius: 0 0 8px 8px !important;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ z-index: 2147483647 !important;
+ transition: top 0.3s ease-out !important;
+ cursor: pointer !important;
+}
+
+.phoenix-hot-corner:hover {
+ top: 0 !important;
+}
+
+.phoenix-hot-corner.peek-animation {
+ animation: peekDown 1.2s ease-in-out !important;
+}
+
+@keyframes peekDown {
+ 0% {
+ top: -35px;
+ }
+ 25% {
+ top: 0;
+ }
+ 75% {
+ top: 0;
+ }
+ 100% {
+ top: -35px;
+ }
+}
+
+.hot-corner-btn {
+ background-color: transparent !important;
+ border: none !important;
+ color: #a0a0a0 !important;
+ font-size: 16px !important;
+ width: 100% !important;
+ height: 100% !important;
+ cursor: pointer !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ transition: color 0.2s ease !important;
+ padding: 0 !important;
+}
+
+.hot-corner-btn:hover {
+ color: #c0c0c0 !important;
+}
+
+.hot-corner-btn.selected {
+ color: #FBB03B !important;
+}
+
+.hot-corner-btn.selected:hover {
+ color: #FCC04B !important;
+}
+
+.hot-corner-btn svg {
+ width: 22px !important;
+ height: 22px !important;
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/hyperlink-editor.css b/src/extensionsIntegrated/phoenix-pro/browser-css/hyperlink-editor.css
new file mode 100644
index 0000000000..1160e9a1ef
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/hyperlink-editor.css
@@ -0,0 +1,49 @@
+:host {
+ all: initial !important;
+}
+
+.hyperlink-input-box {
+ position: absolute;
+ background-color: #2c2c2c !important;
+ border: 1px solid #4a4a4a !important;
+ border-radius: 6px !important;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25) !important;
+ z-index: 2147483647;
+ min-width: 200px;
+ max-width: 400px;
+ box-sizing: border-box;
+ padding: 7px 14px !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 8px !important;
+}
+
+.link-icon {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ flex-shrink: 0 !important;
+ width: 16px !important;
+ height: 16px !important;
+ color: #cdcdcd !important;
+}
+
+.link-icon svg {
+ width: 16px !important;
+ height: 16px !important;
+ display: block !important;
+}
+
+input {
+ flex: 1 !important;
+ border: none;
+ outline: none;
+ font-size: 14px !important;
+ font-family: Arial, sans-serif !important;
+ color: #cdcdcd !important;
+ background: transparent;
+}
+
+input::placeholder {
+ color: #6a6a6a !important;
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/image-gallery.css b/src/extensionsIntegrated/phoenix-pro/browser-css/image-gallery.css
new file mode 100644
index 0000000000..fa12a8f8d7
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/image-gallery.css
@@ -0,0 +1,524 @@
+:host {
+ all: initial !important;
+}
+
+.phoenix-image-gallery-container {
+ position: fixed !important;
+ bottom: 0 !important;
+ left: 50% !important;
+ transform: translateX(-50%) !important;
+ width: calc(100% - 24px) !important;
+ max-width: 1160px !important;
+ background-color: #2c2c2c !important;
+ border-radius: 6px 6px 0 0 !important;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ "Segoe UI",
+ Roboto,
+ Arial !important;
+ border: 2px solid rgba(255, 255, 255, 0.3) !important;
+ border-bottom: none !important;
+ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3) !important;
+ z-index: 2147483647 !important;
+ overflow: hidden !important;
+}
+
+.phoenix-image-gallery-header {
+ display: flex !important;
+ flex-direction: column !important;
+ padding: 10px 8px 6px 8px !important;
+}
+
+.phoenix-image-gallery-header-row {
+ display: flex !important;
+}
+
+.phoenix-image-gallery-header-title {
+ display: flex !important;
+ align-items: center !important;
+ color: #a0a0a0 !important;
+ gap: 4px !important;
+ font-size: 14px !important;
+ margin-bottom: 2px !important;
+ margin-right: 10px !important;
+}
+
+@media (max-width: 580px) {
+ .phoenix-image-gallery-header-title {
+ display: none !important;
+ }
+}
+
+.phoenix-image-gallery-header-icon {
+ height: 16px !important;
+ width: 18px !important;
+}
+
+.phoenix-image-gallery-header-icon svg {
+ display: block !important;
+}
+
+.phoenix-image-gallery-header-text {
+ line-height: 1 !important;
+ display: flex !important;
+ align-items: center !important;
+}
+
+.phoenix-image-gallery-search-container {
+ display: flex !important;
+ align-items: center !important;
+}
+
+.search-wrapper {
+ position: relative !important;
+ margin-right: 6px !important;
+}
+
+.search-wrapper input {
+ padding: 5px 4px 6px 36px !important;
+ border-radius: 4px !important;
+ border: none !important;
+ background-color: #1e1e1e !important;
+ color: #e0e0e0 !important;
+}
+
+.search-wrapper .search-icon {
+ position: absolute !important;
+ left: 6px !important;
+ top: 55% !important;
+ transform: translateY(-50%) !important;
+ background: none !important;
+ border: none !important;
+ color: #aaa !important;
+ cursor: pointer !important;
+}
+
+.search-wrapper .search-icon:hover {
+ color: #e0e0e0 !important;
+}
+
+.search-wrapper input:focus {
+ outline: 1px solid #3a8ef6 !important;
+}
+
+@media (max-width: 370px) {
+ .search-wrapper input {
+ width: 100px !important;
+ }
+}
+
+.phoenix-file-input {
+ display: none !important;
+}
+
+.phoenix-image-gallery-upload-container button {
+ display: flex !important;
+ align-items: center !important;
+ gap: 2px !important;
+ background: transparent !important;
+ color: #a0a0a0 !important;
+ border: none !important;
+ border-radius: 3px !important;
+ padding: 3px 8px 3px 3px !important;
+ margin-top: 1px !important;
+ font-size: 12px !important;
+ cursor: pointer !important;
+}
+
+.phoenix-image-gallery-upload-container button:hover {
+ background: #3c3f41 !important;
+}
+
+@media (max-width: 470px) {
+ .phoenix-image-gallery-upload-container button {
+ font-size: 0 !important;
+ padding: 3px 5px 3px 6px !important;
+ }
+
+ .phoenix-image-gallery-upload-container button svg {
+ font-size: 16px !important;
+ }
+}
+
+.phoenix-image-gallery-right-buttons {
+ display: flex !important;
+ align-items: center !important;
+ gap: 3px !important;
+ margin-left: auto !important;
+ margin-bottom: 2px !important;
+}
+
+.phoenix-image-gallery-right-buttons button {
+ display: flex !important;
+ background: transparent !important;
+ color: #a0a0a0 !important;
+ border: none !important;
+ border-radius: 3px !important;
+ padding: 4px 8px !important;
+ cursor: pointer !important;
+}
+
+.phoenix-image-gallery-right-buttons button:hover {
+ background: #3c3f41 !important;
+}
+
+.phoenix-image-gallery-right-buttons svg {
+ width: 16px !important;
+ height: 16px !important;
+}
+
+.phoenix-image-gallery-strip-container {
+ position: relative !important;
+}
+
+.phoenix-image-gallery-strip {
+ overflow: hidden !important;
+ scroll-behavior: smooth !important;
+ padding: 6px !important;
+}
+
+.phoenix-image-gallery-row {
+ display: flex !important;
+ gap: 5px !important;
+}
+
+.phoenix-ribbon-thumb {
+ flex: 0 0 auto !important;
+ width: 112px !important;
+ height: 112px !important;
+ border-radius: 4px !important;
+ overflow: hidden !important;
+ position: relative !important;
+ cursor: pointer !important;
+ outline: 1px solid rgba(255, 255, 255, 0.08) !important;
+ transition:
+ transform 0.15s ease,
+ outline-color 0.15s ease,
+ box-shadow 0.15s ease !important;
+ background: #0b0e14 !important;
+}
+
+.phoenix-ribbon-thumb img {
+ width: 100% !important;
+ height: 100% !important;
+ object-fit: cover !important;
+ display: block !important;
+}
+
+.phoenix-ribbon-thumb:hover {
+ transform: translateY(-2px) scale(1.02) !important;
+ outline-color: rgba(255, 255, 255, 0.25) !important;
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.36) !important;
+}
+
+.phoenix-image-gallery-nav {
+ position: absolute !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+ border-radius: 12px !important;
+ border: 1px solid rgba(255, 255, 255, 0.14) !important;
+ color: #eaeaf0 !important;
+ background: rgba(21, 25, 36, 0.65) !important;
+ cursor: pointer !important;
+ font-size: 22px !important;
+ font-weight: 600 !important;
+ user-select: none !important;
+ transition: all 0.2s ease !important;
+ z-index: 2147483647 !important;
+ padding: 2.5px 11px 7px 11px !important;
+ display: none !important;
+ align-items: center !important;
+ justify-content: center !important;
+ line-height: 1 !important;
+ text-align: center !important;
+}
+
+.phoenix-image-gallery-nav:hover {
+ background: rgba(21, 25, 36, 0.85) !important;
+ border-color: rgba(255, 255, 255, 0.25) !important;
+}
+
+.phoenix-image-gallery-nav.left {
+ left: 15px !important;
+}
+
+.phoenix-image-gallery-nav.right {
+ right: 15px !important;
+}
+
+.phoenix-image-gallery-loading {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ min-height: 112px !important;
+ color: #eaeaf0 !important;
+ font-size: 14px !important;
+}
+
+.phoenix-ribbon-error {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ min-height: 112px !important;
+ color: #ff6b6b !important;
+ font-size: 14px !important;
+}
+
+.phoenix-loading-more {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ min-width: 120px !important;
+ min-height: 110px !important;
+ margin-left: 2px !important;
+ background: rgba(255, 255, 255, 0.03) !important;
+ border-radius: 8px !important;
+ color: #e8eaf0 !important;
+ font-size: 12px !important;
+ border: 1px dashed rgba(255, 255, 255, 0.1) !important;
+}
+
+.phoenix-ribbon-attribution {
+ position: absolute !important;
+ bottom: 6px !important;
+ left: 6px !important;
+ background: rgba(0, 0, 0, 0.8) !important;
+ color: white !important;
+ padding: 4px 6px !important;
+ border-radius: 5px !important;
+ font-size: 10px !important;
+ line-height: 1.2 !important;
+ max-width: calc(100% - 12px) !important;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9) !important;
+ pointer-events: auto !important;
+ opacity: 0 !important;
+ transition: all 0.2s ease !important;
+}
+
+.phoenix-ribbon-attribution .photographer {
+ display: block !important;
+ font-weight: 500 !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ color: white !important;
+ text-decoration: none !important;
+}
+
+.phoenix-ribbon-attribution .photographer:hover {
+ text-decoration: underline !important;
+}
+
+.phoenix-ribbon-attribution .source {
+ display: block !important;
+ font-size: 9px !important;
+ opacity: 0.85 !important;
+ color: white !important;
+ text-decoration: none !important;
+}
+
+.phoenix-ribbon-attribution .source:hover {
+ text-decoration: underline !important;
+}
+
+.phoenix-download-icon {
+ position: absolute !important;
+ top: 8px !important;
+ right: 8px !important;
+ background: rgba(0, 0, 0, 0.7) !important;
+ border: none !important;
+ color: #eee !important;
+ border-radius: 50% !important;
+ width: 18px !important;
+ height: 18px !important;
+ padding: 4px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ cursor: pointer !important;
+ font-size: 16px !important;
+ z-index: 2147483647 !important;
+ transition: all 0.2s ease !important;
+ pointer-events: none !important;
+ opacity: 0 !important;
+}
+
+.phoenix-ribbon-thumb:hover .phoenix-download-icon {
+ opacity: 1 !important;
+ pointer-events: auto !important;
+}
+
+.phoenix-ribbon-thumb:hover .phoenix-ribbon-attribution {
+ opacity: 1 !important;
+}
+
+.phoenix-ribbon-attribution:hover {
+ opacity: 1 !important;
+}
+
+.phoenix-download-icon:hover {
+ background: rgba(0, 0, 0, 0.9) !important;
+ transform: scale(1.1) !important;
+}
+
+.phoenix-ribbon-thumb.downloading {
+ opacity: 0.6 !important;
+ pointer-events: none !important;
+}
+
+.phoenix-download-indicator {
+ position: absolute !important;
+ top: 50% !important;
+ left: 50% !important;
+ transform: translate(-50%, -50%) !important;
+ background: rgba(0, 0, 0, 0.8) !important;
+ border-radius: 50% !important;
+ width: 40px !important;
+ height: 40px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ z-index: 10 !important;
+}
+
+.phoenix-download-spinner {
+ width: 20px !important;
+ height: 20px !important;
+ border: 2px solid rgba(255, 255, 255, 0.3) !important;
+ border-top: 2px solid #fff !important;
+ border-radius: 50% !important;
+ animation: phoenix-spin 1s linear infinite !important;
+}
+
+@keyframes phoenix-spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.phoenix-image-gallery-offline-banner {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+ width: 100% !important;
+ background: #1e1e1e !important;
+ padding: 6px 12px !important;
+ margin: 0 0 8px 0 !important;
+ border-bottom: 1px solid rgba(107, 107, 107, 0.2) !important;
+ font-size: 13px !important;
+ font-weight: 400 !important;
+ gap: 8px !important;
+ box-sizing: border-box !important;
+}
+
+.phoenix-image-gallery-offline-banner.hidden {
+ display: none !important;
+}
+
+.phoenix-image-gallery-offline-banner-content {
+ display: flex !important;
+ align-items: center !important;
+ gap: 6px !important;
+ flex: 1 !important;
+}
+
+.phoenix-image-gallery-offline-banner-icon {
+ display: flex !important;
+ align-items: center !important;
+ width: 14px !important;
+ height: 14px !important;
+}
+
+.phoenix-image-gallery-offline-banner-icon svg {
+ color: #6b6b6b !important;
+}
+
+.phoenix-image-gallery-offline-banner-text {
+ line-height: 1.3 !important;
+ color: #a0a0a0 !important;
+}
+
+.phoenix-image-gallery-offline-banner-actions {
+ display: flex !important;
+ gap: 6px !important;
+}
+
+.phoenix-image-gallery-offline-retry-button {
+ display: flex !important;
+ align-items: center !important;
+ gap: 3px !important;
+ background: transparent !important;
+ color: #a0a0a0 !important;
+ border: none !important;
+ border-radius: 3px !important;
+ padding: 3px 9px 3px 6px !important;
+ font-size: 13px !important;
+ cursor: pointer !important;
+}
+
+.phoenix-image-gallery-offline-retry-button:hover {
+ background: #3c3f41 !important;
+}
+
+.phoenix-image-gallery-offline-retry-button span {
+ padding-top: 0.9px !important;
+}
+
+.phoenix-image-gallery-offline-retry-button.checking {
+ cursor: wait !important;
+ opacity: 0.7 !important;
+}
+
+@keyframes phoenix-dots {
+ 0%,
+ 20% {
+ content: ".";
+ }
+ 40% {
+ content: "..";
+ }
+ 60%,
+ 100% {
+ content: "...";
+ }
+}
+
+.phoenix-image-gallery-offline-banner-text.checking::after {
+ content: ".";
+ animation: phoenix-dots 1s infinite;
+}
+
+.search-wrapper.disabled {
+ opacity: 0.5 !important;
+ pointer-events: none !important;
+}
+
+.search-wrapper.disabled input {
+ background-color: #141414 !important;
+ cursor: not-allowed !important;
+}
+
+.search-wrapper.disabled .search-icon {
+ cursor: not-allowed !important;
+ color: #666 !important;
+}
+
+@keyframes phoenix-banner-fade-out {
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+}
+
+.phoenix-image-gallery-offline-banner.fade-out {
+ animation: phoenix-banner-fade-out 0.3s ease forwards !important;
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/info-box.css b/src/extensionsIntegrated/phoenix-pro/browser-css/info-box.css
new file mode 100644
index 0000000000..7354a57731
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/info-box.css
@@ -0,0 +1,62 @@
+:host {
+ all: initial !important;
+}
+
+.phoenix-info-box {
+ background-color: var(--info-box-bg-color) !important;
+ color: white !important;
+ border-radius: 3px !important;
+ padding: 5px 8px !important;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important;
+ font-size: 12px !important;
+ font-family: Arial, sans-serif !important;
+ z-index: 2147483646 !important;
+ position: absolute !important;
+ left: -1000px;
+ top: -1000px;
+ max-width: 300px !important;
+ box-sizing: border-box !important;
+ pointer-events: none !important;
+}
+
+.tag-line {
+ display: flex !important;
+ align-items: baseline !important;
+ justify-content: space-between !important;
+}
+
+.tag-name {
+ font-weight: bold !important;
+}
+
+.elem-dimensions {
+ font-size: 9px !important;
+ font-weight: 500 !important;
+ opacity: 0.9 !important;
+ margin-left: 7px !important;
+ flex-shrink: 0 !important;
+}
+
+.id-name,
+.class-name,
+.href-info {
+ margin-top: 3px !important;
+}
+
+.href-info {
+ display: flex !important;
+ align-items: center !important;
+ gap: 6px !important;
+ opacity: 0.9 !important;
+ letter-spacing: 0.6px !important;
+}
+
+.href-info svg {
+ width: 13px !important;
+ height: 13px !important;
+ flex-shrink: 0 !important;
+}
+
+.exceeded-classes {
+ opacity: 0.8 !important;
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/more-options-dropdown.css b/src/extensionsIntegrated/phoenix-pro/browser-css/more-options-dropdown.css
new file mode 100644
index 0000000000..19db4f025c
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/more-options-dropdown.css
@@ -0,0 +1,87 @@
+:host {
+ all: initial !important;
+}
+
+.phoenix-dropdown {
+ background-color: #2c2c2c !important;
+ color: #cdcdcd !important;
+ border: 1px solid #4a4a4a !important;
+ border-radius: 3px !important;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25) !important;
+ font-size: 11.5px !important;
+ font-family: Arial, sans-serif !important;
+ z-index: 2147483647 !important;
+ position: absolute !important;
+ left: -1000px;
+ top: -1000px;
+ box-sizing: border-box !important;
+ min-width: 120px !important;
+ padding: 3px 0 !important;
+ overflow: hidden !important;
+}
+
+.more-options-dropdown {
+ display: flex !important;
+ flex-direction: column !important;
+}
+
+.dropdown-item {
+ padding: 5px 10px !important;
+ cursor: pointer !important;
+ white-space: nowrap !important;
+ user-select: none !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 6px !important;
+}
+
+.dropdown-item:hover {
+ background-color: #3c3f41 !important;
+}
+
+.item-icon {
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ width: 16px !important;
+ height: 16px !important;
+ flex-shrink: 0 !important;
+}
+
+.item-icon svg {
+ width: 14px !important;
+ height: 14px !important;
+ display: block !important;
+}
+
+.item-label {
+ flex: 1 !important;
+}
+
+.item-label.show-ruler-label {
+ margin-top: 1px !important;
+}
+
+.dropdown-separator {
+ height: 1px !important;
+ background-color: #4a4a4a !important;
+ margin: 2px 0 !important;
+}
+
+.item-checkmark {
+ margin-left: auto !important;
+ padding-left: 4px !important;
+ padding-bottom: 1px !important;
+ font-size: 14px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ width: 14px !important;
+ height: 14px !important;
+}
+
+.item-checkmark svg {
+ width: 14px !important;
+ height: 14px !important;
+ display: block !important;
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/rulers.css b/src/extensionsIntegrated/phoenix-pro/browser-css/rulers.css
new file mode 100644
index 0000000000..66e2c1fd7e
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/rulers.css
@@ -0,0 +1,35 @@
+:host {
+ all: initial !important;
+}
+
+.phoenix-ruler-line {
+ position: absolute !important;
+ pointer-events: none !important;
+ z-index: 2147483645 !important;
+}
+
+.phoenix-ruler-line-editable {
+ background-color: rgba(66, 133, 244, 0.4) !important;
+}
+
+.phoenix-ruler-line-non-editable {
+ background-color: rgba(60, 63, 65, 0.8) !important;
+}
+
+.phoenix-ruler-label {
+ position: absolute !important;
+ font-size: 9px !important;
+ font-family: Arial, sans-serif !important;
+ pointer-events: none !important;
+ z-index: 2147483646 !important;
+ white-space: nowrap !important;
+ background-color: transparent !important;
+}
+
+.phoenix-ruler-label-editable {
+ color: rgba(66, 133, 244, 1) !important;
+}
+
+.phoenix-ruler-label-non-editable {
+ color: rgba(60, 63, 65, 1) !important;
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/toast-message.css b/src/extensionsIntegrated/phoenix-pro/browser-css/toast-message.css
new file mode 100644
index 0000000000..5ceb568737
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/toast-message.css
@@ -0,0 +1,34 @@
+:host {
+ all: initial !important;
+}
+
+.toast-container {
+ position: fixed !important;
+ bottom: 30px !important;
+ left: 50% !important;
+ transform: translateX(-50%) translateY(0) !important;
+ background-color: rgba(51, 51, 51, 0.95) !important;
+ color: white !important;
+ padding: 10px 14px !important;
+ border-radius: 6px !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
+ font-family: Arial, sans-serif !important;
+ font-size: 13px !important;
+ line-height: 1.4 !important;
+ z-index: 2147483647 !important;
+ text-align: center !important;
+ max-width: 90% !important;
+ box-sizing: border-box !important;
+ animation: slideUp 0.3s ease-out !important;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/src/extensionsIntegrated/phoenix-pro/browser-css/tool-box.css b/src/extensionsIntegrated/phoenix-pro/browser-css/tool-box.css
new file mode 100644
index 0000000000..6719b15cc9
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/browser-css/tool-box.css
@@ -0,0 +1,56 @@
+:host {
+ all: initial !important;
+}
+
+.phoenix-tool-box {
+ background-color: #4285F4 !important;
+ color: white !important;
+ border-radius: 3px !important;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important;
+ font-size: 12px !important;
+ font-family: Arial, sans-serif !important;
+ z-index: 2147483646 !important;
+ position: absolute !important;
+ left: -1000px;
+ top: -1000px;
+ box-sizing: border-box !important;
+}
+
+.tool-items {
+ display: flex !important;
+ align-items: center !important;
+}
+
+.tool-items span {
+ padding: 4px 3.9px !important;
+ cursor: pointer !important;
+ display: flex !important;
+ align-items: center !important;
+ border-radius: 0 !important;
+}
+
+.tool-items span:first-child {
+ border-radius: 3px 0 0 3px !important;
+}
+
+.tool-items span:last-child {
+ border-radius: 0 3px 3px 0 !important;
+}
+
+.tool-items span:hover {
+ background-color: rgba(255, 255, 255, 0.15) !important;
+}
+
+.tool-items span > svg {
+ width: 16px !important;
+ height: 16px !important;
+ display: block !important;
+}
+
+.tool-items span[data-action="image-gallery"].selected {
+ background-color: rgba(50, 50, 220, 0.5) !important;
+}
+
+.tool-items span[data-action="image-gallery"].selected:hover {
+ background-color: rgba(100, 100, 230, 0.6) !important;
+}
diff --git a/src/htmlContent/image-folder-dialog.html b/src/extensionsIntegrated/phoenix-pro/html/image-folder-dialog.html
similarity index 100%
rename from src/htmlContent/image-folder-dialog.html
rename to src/extensionsIntegrated/phoenix-pro/html/image-folder-dialog.html
diff --git a/src/extensionsIntegrated/phoenix-pro/main.js b/src/extensionsIntegrated/phoenix-pro/main.js
new file mode 100644
index 0000000000..ddc1346f10
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/main.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+define(function (require, exports, module) {
+ require("./LivePreviewEdit");
+ require("./pro-utils");
+ const remoteConstants = require("./remote-constants");
+ const remoteIcons = require("./remote-icons");
+ const remoteStyles = require("./remote-styles");
+ const RemoteScriptProvider = require("./remote-script-provider");
+
+ const KernalModeTrust = window.KernalModeTrust;
+ if(!KernalModeTrust){
+ throw new Error("KernalModeTrust is not defined. Cannot boot without trust ring");
+ }
+
+ const HelperCode = require("text!./browser-context/helper.js");
+ const ToolBoxCode = require("text!./browser-context/tool-box.js");
+ const InfoBoxCode = require("text!./browser-context/info-box.js");
+ const MoreOptionsDropdownCode = require("text!./browser-context/more-options-dropdown.js");
+ const TextEditCode = require("text!./browser-context/text-edit.js");
+ const HyperlinkEditorCode = require("text!./browser-context/hyperlink-editor.js");
+ const ImageGalleryCode = require("text!./browser-context/image-gallery.js");
+ const AICode = require("text!./browser-context/ai-pro.js");
+ const RulerLinesCode = require("text!./browser-context/ruler-lines.js");
+ const DragAndDropCode = require("text!./browser-context/dragAndDrop.js");
+ const HotCornersCode = require("text!./browser-context/hot-corners.js");
+ const ToastMessageCode = require("text!./browser-context/toast-message.js");
+ const GenericToolsCode = require("text!./browser-context/generic-tools.js");
+
+ function _addRemoteScripts() {
+ // the ordering here is important
+ // constants first
+ RemoteScriptProvider.addRemoteFunctionConstantsScript("strings",
+ `strings = ${JSON.stringify(remoteConstants.remoteStrings)};`);
+ RemoteScriptProvider.addRemoteFunctionConstantsScript("proConstants",
+ `proConstants = ${JSON.stringify(remoteConstants.proConstants)};`);
+ RemoteScriptProvider.addRemoteFunctionConstantsScript("icons",
+ `icons = ${JSON.stringify(remoteIcons.svgIcons)};`);
+ RemoteScriptProvider.addRemoteFunctionConstantsScript("styles",
+ `cssStyles = ${JSON.stringify(remoteStyles.cssStyles)};`);
+
+ // functions start here
+ RemoteScriptProvider.addRemoteFunctionScript("HelperCode", HelperCode);
+ RemoteScriptProvider.addRemoteFunctionScript("ToolBoxCode", ToolBoxCode);
+ RemoteScriptProvider.addRemoteFunctionScript("InfoBoxCode", InfoBoxCode);
+ RemoteScriptProvider.addRemoteFunctionScript("MoreOptionsDropdownCode", MoreOptionsDropdownCode);
+ RemoteScriptProvider.addRemoteFunctionScript("TextEditCode", TextEditCode);
+ RemoteScriptProvider.addRemoteFunctionScript("HyperlinkEditorCode", HyperlinkEditorCode);
+ RemoteScriptProvider.addRemoteFunctionScript("ImageGalleryCode", ImageGalleryCode);
+ RemoteScriptProvider.addRemoteFunctionScript("AICode", AICode);
+ RemoteScriptProvider.addRemoteFunctionScript("RulerLinesCode", RulerLinesCode);
+ RemoteScriptProvider.addRemoteFunctionScript("DragAndDrop", DragAndDropCode);
+ RemoteScriptProvider.addRemoteFunctionScript("HotCornersCode", HotCornersCode);
+ RemoteScriptProvider.addRemoteFunctionScript("ToastMessageCode", ToastMessageCode);
+ RemoteScriptProvider.addRemoteFunctionScript("GenericTools", GenericToolsCode);
+ }
+ _addRemoteScripts();
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/pro-utils.js b/src/extensionsIntegrated/phoenix-pro/pro-utils.js
new file mode 100644
index 0000000000..49eca4f0a1
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/pro-utils.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+define(function (require, exports, module) {
+ const KernalModeTrust = window.KernalModeTrust;
+ if(!KernalModeTrust){
+ throw new Error("KernalModeTrust is not defined. Cannot boot without trust ring");
+ }
+
+ const AppInit = require("utils/AppInit"),
+ LiveDevelopment = require("LiveDevelopment/main");
+
+ // this will later be assigned its correct values once entitlementsManager loads
+ let _isProEditActivated = false;
+ // called everytime there is a change in entitlements (app start/every 15 mis, explicit check/ anytime really)
+ async function _entitlementsChanged() {
+ try {
+ const entitlement = await KernalModeTrust.EntitlementsManager.getLiveEditEntitlement();
+ _isProEditActivated = entitlement.activated;
+ } catch (error) {
+ console.error("Error updating pro user status:", error);
+ _isProEditActivated = false;
+ }
+ LiveDevelopment._liveEditCapabilityChanged(_isProEditActivated);
+ }
+
+ function isProEditActivated() {
+ return _isProEditActivated;
+ }
+
+ AppInit.appReady(function () {
+ _entitlementsChanged();
+ KernalModeTrust.EntitlementsManager.on(
+ KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, _entitlementsChanged);
+ });
+
+ exports.isProEditActivated = isProEditActivated;
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/remote-constants.js b/src/extensionsIntegrated/phoenix-pro/remote-constants.js
new file mode 100644
index 0000000000..7dfb3b215e
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/remote-constants.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+define(function (require, exports, module) {
+ const Strings = require("strings");
+
+ // list of all the strings that are used in the remoteFunctions file
+ // we dont pass in the full strings object as its more data to load in every live preview reload.
+ exports.remoteStrings = {
+ selectParent: Strings.LIVE_DEV_MORE_OPTIONS_SELECT_PARENT,
+ editText: Strings.LIVE_DEV_MORE_OPTIONS_EDIT_TEXT,
+ editHyperlink: Strings.LIVE_DEV_MORE_OPTIONS_EDIT_HYPERLINK,
+ hyperlinkNoHref: Strings.LIVE_DEV_HYPERLINK_NO_HREF,
+ duplicate: Strings.LIVE_DEV_MORE_OPTIONS_DUPLICATE,
+ delete: Strings.LIVE_DEV_MORE_OPTIONS_DELETE,
+ ai: Strings.LIVE_DEV_MORE_OPTIONS_AI,
+ imageGallery: Strings.LIVE_DEV_MORE_OPTIONS_IMAGE_GALLERY,
+ moreOptions: Strings.LIVE_DEV_MORE_OPTIONS_MORE,
+ cut: Strings.LIVE_DEV_MORE_OPTIONS_CUT,
+ copy: Strings.LIVE_DEV_MORE_OPTIONS_COPY,
+ paste: Strings.LIVE_DEV_MORE_OPTIONS_PASTE,
+ showRulerLines: Strings.LIVE_PREVIEW_SHOW_RULER_LINES,
+ aiPromptPlaceholder: Strings.LIVE_DEV_AI_PROMPT_PLACEHOLDER,
+ imageGalleryUseImage: Strings.LIVE_DEV_IMAGE_GALLERY_USE_IMAGE,
+ imageGallerySelectDownloadFolder: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_DOWNLOAD_FOLDER,
+ imageGallerySearchPlaceholder: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER,
+ imageGallerySearchButton: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_BUTTON,
+ imageGalleryLoadingInitial: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_INITIAL,
+ imageGalleryLoadingMore: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_MORE,
+ imageGalleryNoImages: Strings.LIVE_DEV_IMAGE_GALLERY_NO_IMAGES,
+ imageGalleryLoadError: Strings.LIVE_DEV_IMAGE_GALLERY_LOAD_ERROR,
+ imageGalleryClose: Strings.LIVE_DEV_IMAGE_GALLERY_CLOSE,
+ imageGallerySelectFromComputer: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER,
+ imageGallerySelectFromComputerTooltip: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER_TOOLTIP,
+ imageGalleryDialogOverlayMessage: Strings.LIVE_DEV_IMAGE_GALLERY_DIALOG_OVERLAY_MESSAGE,
+ imageGalleryOfflineBanner: Strings.LIVE_DEV_IMAGE_GALLERY_OFFLINE_BANNER,
+ imageGalleryOfflineRetry: Strings.LIVE_DEV_IMAGE_GALLERY_OFFLINE_RETRY,
+ imageGalleryCheckingConnection: Strings.LIVE_DEV_IMAGE_GALLERY_CHECKING_CONNECTION,
+ imageGalleryStillOffline: Strings.LIVE_DEV_IMAGE_GALLERY_STILL_OFFLINE,
+ toastNotEditable: Strings.LIVE_DEV_TOAST_NOT_EDITABLE,
+ toastCopyFirstTime: Strings.LIVE_DEV_COPY_TOAST_MESSAGE,
+ togglePreviewMode: Strings.LIVE_PREVIEW_MODE_TOGGLE_PREVIEW
+ };
+
+ // this determines the ordering in which the toolbox items appear in the live preview.
+ const TOOLBOX_ORDERING = {
+ SELECT_PARENT: 10,
+ EDIT_TEXT: 20,
+ EDIT_HYPERLINK: 30,
+ IMAGE_GALLERY: 40,
+ DUPLICATE: 50,
+ DELETE: 60,
+ AI: 600 // TBD
+ };
+
+ // this determines the ordering in which the toolbox items appear in the live preview.
+ const DROPDOWN_ORDERING = {
+ CUT: 10,
+ COPY: 20,
+ PASTE: 30,
+ CUT_PASTE_SEPARATOR: 40,
+ SHOW_MEASUREMENTS: 100
+ };
+
+ exports.proConstants = {
+ TOOLBOX_ORDERING,
+ DROPDOWN_ORDERING
+ };
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/remote-icons.js b/src/extensionsIntegrated/phoenix-pro/remote-icons.js
new file mode 100644
index 0000000000..d5aaf5cc8d
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/remote-icons.js
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+define(function (require, exports, module) {
+
+ // these are all the icons that are used in the remote functions file
+ exports.svgIcons = {
+ ai: `
+
+ `,
+
+ arrowUp: `
+
+ `,
+
+ edit: `
+
+ `,
+
+ duplicate: `
+
+ `,
+
+ trash: `
+
+ `,
+
+ cut: `
+
+ `,
+
+ copy: `
+
+ `,
+
+ paste: `
+
+ `,
+
+ ruler: `
+
+ `,
+
+ imageGallery: `
+
+ `,
+
+ selectImageFromComputer: `
+
+ `,
+
+ downloadImage: `
+
+ `,
+
+ folderSettings: `
+
+ `,
+
+ close: `
+
+ `,
+
+ paperPlane: `
+
+ `,
+
+ search: `
+
+ `,
+
+ wifiOff: `
+
+ `,
+
+ refresh: `
+
+ `,
+
+ verticalEllipsis: `
+
+ `,
+
+ link: `
+
+ `,
+
+ check: `
+ `,
+
+ playButton: `
+ `
+ };
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/remote-script-provider.js b/src/extensionsIntegrated/phoenix-pro/remote-script-provider.js
new file mode 100644
index 0000000000..f84760dba9
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/remote-script-provider.js
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+define(function (require, exports, module) {
+ const LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol");
+ const LiveDevMultiBrowser = require("LiveDevelopment/LiveDevMultiBrowser");
+ const RemoteFunctions = require("text!LiveDevelopment/BrowserScripts/RemoteFunctions.js");
+
+ let remoteFunctionsScripts = new Map();
+ let remoteFunctionsConstantsScripts = new Map();
+ let effectiveRemoteFunctionsScripts = RemoteFunctions;
+
+ function _computeRemoteScript() {
+ const remoteScriptText = Array.from(remoteFunctionsScripts.values()).join("\n");
+ const remoteConstantScriptText = Array.from(remoteFunctionsConstantsScripts.values()).join("\n");
+ effectiveRemoteFunctionsScripts = RemoteFunctions
+ .replace("// DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_CONSTANT_SCRIPTS", remoteConstantScriptText)
+ .replace("// DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_SCRIPTS", remoteScriptText);
+ }
+
+ const JS_RESERVED_WORDS = new Set([
+ "break","case","catch","class","const","continue","debugger","default",
+ "delete","do","else","export","extends","finally","for","function",
+ "if","import","in","instanceof","new","return","super","switch",
+ "this","throw","try","typeof","var","void","while","with","yield",
+ "enum","await","implements","interface","let","package","private",
+ "protected","public","static"
+ ]);
+
+ function isValidFunctionName(name) {
+ if (typeof name !== "string" || !name.length) {
+ return false;
+ }
+
+ // JS identifier syntax
+ if (!/^[$A-Z_][0-9A-Z_$]*$/i.test(name)) {
+ return false;
+ }
+
+ // Reserved words
+ return !JS_RESERVED_WORDS.has(name);
+ }
+
+
+ function addRemoteFunctionScript(scriptFunctionName, scriptText) {
+ if(remoteFunctionsConstantsScripts.has(scriptFunctionName) || remoteFunctionsScripts.has(scriptFunctionName)){
+ console.error(`Remote function script ${scriptFunctionName} already exists. Script wont be added.`);
+ return false;
+ }
+ if(scriptFunctionName.length > 100 || !isValidFunctionName(scriptFunctionName)){
+ console.error(`Script name ${scriptFunctionName} should be a valid function name.`);
+ return false;
+ }
+ if(Phoenix.config.environment !== "dev") {
+ // obfuscate run time environment for private code to be less debug friendly
+ scriptText = `(function (){${scriptText}})();`;
+ scriptText = `eval(${JSON.stringify(scriptText)});`;
+ } else {
+ // only dev builds have plain code
+ scriptText = `(function ${scriptFunctionName}(){${scriptText}})();`;
+ }
+ remoteFunctionsScripts.set(scriptFunctionName, scriptText);
+ if(!RemoteFunctions.includes("// DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_SCRIPTS")){
+ throw new Error("RemoteFunctions script is missing the placeholder // REPLACE_WITH_ADDED_REMOTE_SCRIPTS");
+ }
+ _computeRemoteScript();
+ return true;
+ }
+
+ function addRemoteFunctionConstantsScript(scriptName, scriptText) {
+ if(remoteFunctionsConstantsScripts.has(scriptName) || remoteFunctionsScripts.has(scriptName)){
+ console.error(`Remote function script ${scriptName} already exists. Script wont be added.`);
+ return false;
+ }
+ remoteFunctionsConstantsScripts.set(scriptName, scriptText);
+ if(!RemoteFunctions.includes("// DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_CONSTANT_SCRIPTS")){
+ throw new Error("RemoteFunctions script missing placeholder // REPLACE_WITH_ADDED_REMOTE_CONSTANT_SCRIPTS");
+ }
+ _computeRemoteScript();
+ return true;
+ }
+ LiveDevProtocol.setCustomRemoteFunctionProvider(()=>{
+ const effectiveScript = "window._LD=(" + effectiveRemoteFunctionsScripts +
+ "(" + JSON.stringify(LiveDevMultiBrowser.getConfig()) + "))";
+ if(Phoenix.config.environment === "dev") {
+ return effectiveScript;
+ }
+ // obfuscate run time environment for private code to be less debug friendly
+ return `eval(${JSON.stringify(effectiveScript)})`;
+ });
+
+ exports.addRemoteFunctionConstantsScript = addRemoteFunctionConstantsScript;
+ exports.addRemoteFunctionScript = addRemoteFunctionScript;
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/remote-styles.js b/src/extensionsIntegrated/phoenix-pro/remote-styles.js
new file mode 100644
index 0000000000..a5549f8f89
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/remote-styles.js
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+define(function (require, exports, module) {
+ // this is the box that comes up with the tools like select parent, duplicate, delete, etc..
+ const toolBoxStyles = require("text!./browser-css/tool-box.css");
+ // this is the more options dropdown that comes up when clicking the ... icon in the tool box.
+ const toolBoxDropdownStyles = require("text!./browser-css/more-options-dropdown.css");
+ // this is the info box that shows the element type (like div/image etc), css class, dimensions, etc.
+ const infoBoxStyles = require("text!./browser-css/info-box.css");
+ // this is the rulers and guidelines feature
+ const rulerStyles = require("text!./browser-css/rulers.css");
+
+ const toastMessageStyles = require("text!./browser-css/toast-message.css");
+ const dialogOverlayStyles = require("text!./browser-css/dialog-overlay.css");
+
+ // this is the AI prompt box that comes up when you click on the AI button in the ribbon
+ const aiPromptBoxStyles = require("text!./browser-css/ai-prompt-box.css");
+ // this is the image gallery that comes up at the bottom of the page when you click on the image gallery button
+ const imageGalleryStyles = require("text!./browser-css/image-gallery.css");
+
+ const hyperlinkEditorStyles = require("text!./browser-css/hyperlink-editor.css");
+ // This is the page play mode control that comes up in popped-out live preview to toggle
+ // between preview and editor mode and other controls exposed in popped out live preview.
+ const hotCornerStyles = require("text!./browser-css/hot-corners.css");
+
+
+ exports.cssStyles = {
+ toolBox: toolBoxStyles,
+ toolBoxDropdown: toolBoxDropdownStyles,
+ infoBox: infoBoxStyles,
+ toastMessage: toastMessageStyles,
+ dialogOverlay: dialogOverlayStyles,
+ aiPromptBox: aiPromptBoxStyles,
+ imageGallery: imageGalleryStyles,
+ hyperlinkEditor: hyperlinkEditorStyles,
+ ruler: rulerStyles,
+ hotCorner: hotCornerStyles
+ };
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/unit-tests/LivePreviewEdit-test.js b/src/extensionsIntegrated/phoenix-pro/unit-tests/LivePreviewEdit-test.js
new file mode 100644
index 0000000000..2e9955e039
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/unit-tests/LivePreviewEdit-test.js
@@ -0,0 +1,515 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+/*global describe, beforeAll, afterAll, awaitsFor, it, awaitsForDone, expect, awaits, jsPromise*/
+
+define(function (require, exports, module) {
+
+
+ const SpecRunnerUtils = require("spec/SpecRunnerUtils"),
+ KeyEvent = require("utils/KeyEvent"),
+ CONSTANTS = require("LiveDevelopment/LivePreviewConstants");
+
+ const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000;
+
+ describe("livepreview:Live Preview Edit", function () {
+
+ if (Phoenix.isTestWindowPlaywright && !Phoenix.browser.desktop.isChromeBased) {
+ it("All tests requiring virtual server is disabled in playwright/firefox/safari", async function () {
+ // we dont spawn virtual server in iframe playwright linux/safari as playwright linux/safari fails badly
+ // we dont need virtual server for tests except for live preview and custom extension load tests,
+ // which are disabled in playwright. We test in chrome atleast as chromium support is a baseline.
+ });
+ return;
+ }
+
+ var testWindow,
+ brackets,
+ DocumentManager,
+ LiveDevMultiBrowser,
+ LiveDevProtocol,
+ EditorManager,
+ CommandManager,
+ BeautificationManager,
+ Commands,
+ MainViewManager,
+ WorkspaceManager,
+ PreferencesManager,
+ Dialogs,
+ NativeApp;
+
+ let testFolder = SpecRunnerUtils.getTestPath("/spec/LiveDevelopment-MultiBrowser-test-files");
+
+ async function _setLivePreviewMode(mode) {
+ PreferencesManager.set(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE, mode);
+ }
+ function _setEditHighlightMode(isHoverMode) {
+ PreferencesManager.set(CONSTANTS.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT,
+ isHoverMode ? CONSTANTS.HIGHLIGHT_HOVER : CONSTANTS.HIGHLIGHT_CLICK);
+ }
+
+ let savedNativeAppOpener;
+ beforeAll(async function () {
+ // Create a new window that will be shared by ALL tests in this spec.
+ if (!testWindow) {
+ // we have to popout a new window and cant use the embedded iframe for live preview integ tests
+ // as Firefox sandbox prevents service worker access from nexted iframes.
+ // In tauri, we use node server, so this limitation doesn't apply in tauri, and we stick to iframes.
+ const useWindowInsteadOfIframe = (Phoenix.browser.desktop.isFirefox && !window.__TAURI__);
+ testWindow = await SpecRunnerUtils.createTestWindowAndRun({
+ forceReload: false, useWindowInsteadOfIframe
+ });
+ brackets = testWindow.brackets;
+ DocumentManager = brackets.test.DocumentManager;
+ LiveDevMultiBrowser = brackets.test.LiveDevMultiBrowser;
+ LiveDevProtocol = brackets.test.LiveDevProtocol;
+ CommandManager = brackets.test.CommandManager;
+ Commands = brackets.test.Commands;
+ EditorManager = brackets.test.EditorManager;
+ WorkspaceManager = brackets.test.WorkspaceManager;
+ BeautificationManager = brackets.test.BeautificationManager;
+ PreferencesManager = brackets.test.PreferencesManager;
+ NativeApp = brackets.test.NativeApp;
+ Dialogs = brackets.test.Dialogs;
+ MainViewManager = brackets.test.MainViewManager;
+ savedNativeAppOpener = NativeApp.openURLInDefaultBrowser;
+
+ await SpecRunnerUtils.loadProjectInTestWindow(testFolder);
+ await SpecRunnerUtils.deletePathAsync(testFolder + "/.phcode.json", true);
+
+ // Disable edit mode features for core live preview tests
+ // This ensures tests focus on basic live preview functionality without
+ // edit mode interference (hover/click handlers)
+ if (LiveDevMultiBrowser && LiveDevMultiBrowser.getConfig()) {
+ // Also update the remote browser configuration
+ if (LiveDevMultiBrowser.updateConfig) {
+ LiveDevMultiBrowser.updateConfig(LiveDevMultiBrowser.getConfig());
+ }
+ }
+
+ if (!WorkspaceManager.isPanelVisible('live-preview-panel')) {
+ await awaitsForDone(CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW));
+ }
+
+ testWindow._test_entitlements_exports.simulateEntitlementForTests(()=>{
+ return {
+ plan: {
+ isSubscriber: true,
+ paidSubscriber: true,
+ name: brackets.config.main_pro_plan,
+ fullName: brackets.config.main_pro_plan,
+ validTill: Date.now() + MS_IN_ONE_DAY
+ },
+ isInProTrial: false,
+ trialDaysRemaining: 20,
+ entitlements: {
+ liveEdit: {
+ activated: true,
+ subscribeURL: brackets.config.purchase_url,
+ upgradeToPlan: brackets.config.main_pro_plan,
+ validTill: Date.now() + MS_IN_ONE_DAY
+ }
+ }
+ };
+ });
+ await _setLivePreviewMode(CONSTANTS.LIVE_EDIT_MODE);
+ }
+ }, 30000);
+
+ afterAll(async function () {
+ NativeApp.openURLInDefaultBrowser = savedNativeAppOpener;
+ await _setLivePreviewMode(CONSTANTS.LIVE_HIGHLIGHT_MODE);
+ testWindow._test_entitlements_exports.simulateEntitlementForTests(null);
+ // we dont await SpecRunnerUtils.closeTestWindow(); here as tests fail eraticaly if we do this in intel macs
+ testWindow = null;
+ brackets = null;
+ LiveDevMultiBrowser = null;
+ CommandManager = null;
+ Commands = null;
+ EditorManager = null;
+ MainViewManager = null;
+ savedNativeAppOpener = null;
+ Dialogs = null;
+ NativeApp = null;
+ PreferencesManager = null;
+ BeautificationManager = null;
+ DocumentManager = null;
+ WorkspaceManager = null;
+ }, 30000);
+
+ async function endPreviewSession() {
+ LiveDevMultiBrowser.close();
+ await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }),
+ "closing all file");
+ }
+
+ async function waitsForLiveDevelopmentFileSwitch() {
+ await awaitsFor(
+ function isLiveDevelopmentActive() {
+ return LiveDevMultiBrowser.status === LiveDevMultiBrowser.STATUS_ACTIVE;
+ },
+ "livedevelopment.done.opened",
+ 20000
+ );
+ let editor = EditorManager.getActiveEditor();
+ editor && editor.setCursorPos({ line: 0, ch: 0 });
+ }
+
+ async function waitsForLiveDevelopmentToOpen() {
+ // Ensure edit mode is disabled before opening live preview
+ if (LiveDevMultiBrowser && LiveDevMultiBrowser.getConfig()) {
+ // Update the remote browser configuration to sync the disabled state
+ if (LiveDevMultiBrowser.updateConfig) {
+ LiveDevMultiBrowser.updateConfig(LiveDevMultiBrowser.getConfig());
+ }
+ }
+ LiveDevMultiBrowser.open();
+ await waitsForLiveDevelopmentFileSwitch();
+ }
+
+ async function _editFileAndVerifyLivePreview(fileName, location, editText, verifyID, verifyText) {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles([fileName]),
+ "SpecRunnerUtils.openProjectFiles " + fileName);
+
+ await awaits(1000); // todo can we remove this
+ await awaitsFor(
+ function isTextChanged() {
+ return LiveDevMultiBrowser.status === LiveDevMultiBrowser.STATUS_ACTIVE;
+ },
+ "waiting for live preview active",
+ 5000,
+ 50
+ );
+
+ let curDoc = DocumentManager.getCurrentDocument();
+ curDoc.replaceRange(editText, location);
+ let result;
+ await awaitsFor(
+ function isTextChanged() {
+ LiveDevProtocol.evaluate(`document.getElementById('${verifyID}').textContent`)
+ .done((response) => {
+ result = JSON.parse(response.result || "");
+ });
+ return result === verifyText;
+ },
+ `relatedDocuments.done.received verifying ${verifyID} to have ${verifyText}`,
+ 5000,
+ 50
+ );
+ }
+
+ it("should live preview update on editing html files", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
+ "SpecRunnerUtils.openProjectFiles simple1.html");
+
+ await waitsForLiveDevelopmentToOpen();
+ await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ',
+ "testId", "Brackets is hello world awesome!");
+ await endPreviewSession();
+ }, 30000);
+
+ it("should phoenix-pro source files should not be loaded in prod dist builds", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
+ "SpecRunnerUtils.openProjectFiles simple1.html");
+
+ await waitsForLiveDevelopmentToOpen();
+ const loadedProScripts = Array.from(testWindow.document.scripts)
+ .map(s => s.src)
+ .filter(src => src && src.includes('extensionsIntegrated/phoenix-pro'));
+ if(testWindow.Phoenix.config.environment === 'production'){
+ // in production all code should be loaded from minified files
+ expect(loadedProScripts.length).toBe(0);
+ } else {
+ // in development all code should be loaded from source files
+ expect(loadedProScripts.length).toBeGreaterThan(0);
+ }
+ await endPreviewSession();
+ }, 30000);
+
+ async function forRemoteExec(script, compareFn) {
+ let result;
+ await awaitsFor(
+ function () {
+ LiveDevProtocol.evaluate(script)
+ .done((response) => {
+ try{
+ result = response.result && JSON.parse(response.result || "");
+ } catch (e) {
+ console.error(`JSON parse failed for ${response.result}`, e);
+ }
+ });
+ if (compareFn) {
+ return compareFn(result);
+ }
+ // just exec and return if no compare function is specified
+ return true;
+ },
+ "awaitRemoteExec",
+ 5000,
+ 50
+ );
+ return result;
+ }
+
+ async function waitsForLiveDevelopmentToOpenWithEditMode(enableHoverHighlights = true) {
+ LiveDevMultiBrowser.open();
+ await waitsForLiveDevelopmentFileSwitch();
+ _setEditHighlightMode(enableHoverHighlights);
+ }
+
+ async function endEditModePreviewSession() {
+ LiveDevMultiBrowser.close();
+ // Disable edit mode after session
+ _setEditHighlightMode(true);
+ await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }),
+ "closing all file");
+ }
+
+ async function waitForInfoBox(shouldBeVisible = true, timeout = 5000) {
+ await forRemoteExec(`
+ const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot);
+ let hasInfoBox = false;
+ shadowHosts.forEach(host => {
+ if (host.shadowRoot && host.shadowRoot.innerHTML.includes('phoenix-info-box')) {
+ hasInfoBox = true;
+ }
+ });
+ hasInfoBox;
+ `, (result) => {
+ return result === shouldBeVisible;
+ });
+ }
+
+ async function waitForToolBox(shouldBeVisible = true, timeout = 5000) {
+ await forRemoteExec(`
+ const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot);
+ let hasToolBox = false;
+ shadowHosts.forEach(host => {
+ if (host.shadowRoot && host.shadowRoot.innerHTML.includes('phoenix-tool-box')) {
+ hasToolBox = true;
+ }
+ });
+ hasToolBox;
+ `, (result) => {
+ return result === shouldBeVisible;
+ });
+ }
+
+ async function waitForClickedElement(shouldBeVisible = true, timeout = 5000) {
+ await forRemoteExec(`
+ const highlightedElements = document.getElementsByClassName("__brackets-ld-highlight");
+ Array.from(highlightedElements).some(el =>
+ el.style.backgroundColor && el.style.backgroundColor.includes('rgba(0, 162, 255')
+ );
+ `, (result) => {
+ return result === shouldBeVisible;
+ });
+ }
+
+ async function waitForNoEditBoxes() {
+ // Wait for no shadow DOM boxes and no clicked element highlighting
+ await forRemoteExec(`
+ function check(){
+ const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot);
+ for (const host of shadowHosts) {
+ const shadow = host.shadowRoot;
+ const el = shadow.querySelector('.phoenix-info-box');
+ const el2 = shadow.querySelector('.phoenix-tool-box');
+ if (!el && !el2) {
+ return true;
+ }
+ }
+ return false;
+ }
+ check();
+ `, (result) => {
+ return result === true;
+ });
+
+ await waitForClickedElement(false);
+ }
+
+ it("should show info box on hover when elemHighlights is 'hover'", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
+ "SpecRunnerUtils.openProjectFiles simple1.html");
+
+ await waitsForLiveDevelopmentToOpenWithEditMode();
+
+ // Initially no boxes should be visible
+ await waitForNoEditBoxes();
+
+ // Hover over testId element
+ await forRemoteExec(`
+ const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true });
+ document.getElementById('testId').dispatchEvent(event);
+ `);
+
+ // Info box should appear on hover
+ await waitForInfoBox(true);
+ await waitForToolBox(false);
+
+ // Mouse out should hide the info box
+ await forRemoteExec(`
+ const event = new MouseEvent('mouseout', { bubbles: true, cancelable: true });
+ document.getElementById('testId').dispatchEvent(event);
+ `);
+
+ await waitForInfoBox(false);
+ await waitForNoEditBoxes();
+
+ await endEditModePreviewSession();
+ }, 30000);
+
+ it("should show tool box on click when elemHighlights is 'hover'", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
+ "SpecRunnerUtils.openProjectFiles simple1.html");
+
+ await waitsForLiveDevelopmentToOpenWithEditMode();
+
+ // Click on testId element
+ await forRemoteExec(`document.getElementById('testId').click()`);
+
+ // Tool box should appear on click
+ await waitForToolBox(true);
+ await waitForClickedElement(true);
+
+ // Clicking on a different element should move the box
+ await forRemoteExec(`document.getElementById('testId2').click()`);
+
+ await waitForToolBox(true);
+ await waitForClickedElement(true);
+
+ await endEditModePreviewSession();
+ }, 30000);
+
+ it("should show tool box on click when elemHighlights is 'click'", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
+ "SpecRunnerUtils.openProjectFiles simple1.html");
+
+ await waitsForLiveDevelopmentToOpenWithEditMode(false);
+
+ // Initially no boxes should be visible
+ await waitForNoEditBoxes();
+
+ // In click mode, hover should not show info box
+ await forRemoteExec(`
+ const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true });
+ document.getElementById('testId').dispatchEvent(event);
+ `);
+
+ // Should still be no boxes visible
+ await waitForInfoBox(false);
+ await waitForToolBox(false);
+
+ // Click should show tool box
+ await forRemoteExec(`document.getElementById('testId').click()`);
+
+ await waitForToolBox(true);
+ await waitForClickedElement(true);
+
+ await endEditModePreviewSession();
+ }, 30000);
+
+ it("should handle multiple element interactions in hover mode", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple2.html"]),
+ "SpecRunnerUtils.openProjectFiles simple2.html");
+
+ await waitsForLiveDevelopmentToOpenWithEditMode();
+
+ // Test hovering over multiple elements
+ const elementIds = ['simpId', 'simpId2', 'simpId3'];
+
+ for (let elementId of elementIds) {
+ // Hover over element
+ await forRemoteExec(`
+ const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true });
+ document.getElementById('${elementId}').dispatchEvent(event);
+ `);
+
+ // Info box should appear
+ await waitForInfoBox(true);
+
+ // Mouse out
+ await forRemoteExec(`
+ const event = new MouseEvent('mouseout', { bubbles: true, cancelable: true });
+ document.getElementById('${elementId}').dispatchEvent(event);
+ `);
+
+ // Box should disappear
+ await waitForInfoBox(false);
+ }
+
+ await endEditModePreviewSession();
+ }, 30000);
+
+ it("should handle multiple element clicks and box movement", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple2.html"]),
+ "SpecRunnerUtils.openProjectFiles simple2.html");
+
+ await waitsForLiveDevelopmentToOpenWithEditMode();
+
+ const elementIds = ['simpId', 'simpId2', 'simpId3'];
+
+ // Click on first element
+ await forRemoteExec(`document.getElementById('${elementIds[0]}').click()`);
+
+ await waitForToolBox(true);
+ await waitForClickedElement(true);
+
+ // Click on subsequent elements - box should move
+ for (let i = 1; i < elementIds.length; i++) {
+ await forRemoteExec(`document.getElementById('${elementIds[i]}').click()`);
+
+ await waitForToolBox(true);
+ await waitForClickedElement(true);
+ }
+
+ await endEditModePreviewSession();
+ }, 30000);
+
+ it("should dismiss boxes when clicking outside elements", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
+ "SpecRunnerUtils.openProjectFiles simple1.html");
+
+ await waitsForLiveDevelopmentToOpenWithEditMode();
+
+ // Click on element to show tool box
+ await forRemoteExec(`document.getElementById('testId').click()`);
+
+ await waitForToolBox(true);
+
+ // Click on body (outside any specific element)
+ await forRemoteExec(`document.body.click()`);
+
+ // Boxes should be dismissed
+ await waitForToolBox(false);
+ await waitForClickedElement(false);
+
+ await endEditModePreviewSession();
+ }, 30000);
+
+ it("should dismiss boxes when escape key pressed in editor", async function () {
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
+ "SpecRunnerUtils.openProjectFiles simple1.html");
+
+ await waitsForLiveDevelopmentToOpenWithEditMode();
+
+ // Click on element to show tool box
+ await forRemoteExec(`document.getElementById('testId').click()`);
+
+ await waitForToolBox(true);
+
+ // Click on body (outside any specific element)
+ SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE,
+ "keydown", testWindow.$("#editor-holder")[0]);
+
+ // Boxes should be dismissed
+ await waitForToolBox(false);
+ await waitForClickedElement(false);
+
+ await endEditModePreviewSession();
+ }, 30000);
+ });
+});
diff --git a/src/extensionsIntegrated/phoenix-pro/unittests.js b/src/extensionsIntegrated/phoenix-pro/unittests.js
new file mode 100644
index 0000000000..9b203b77ce
--- /dev/null
+++ b/src/extensionsIntegrated/phoenix-pro/unittests.js
@@ -0,0 +1,8 @@
+/*
+ * Copyright (c) 2021 - present core.ai
+ * SPDX-License-Identifier: LicenseRef-Proprietary
+ */
+
+define(function (require, exports, module) {
+ require("./unit-tests/LivePreviewEdit-test");
+});
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js
index a930263878..fcd665115b 100644
--- a/src/nls/root/strings.js
+++ b/src/nls/root/strings.js
@@ -183,13 +183,20 @@ define({
"LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT": "Show Live Preview Element Highlights on:",
"LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_HOVER": "hover",
"LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_CLICK": "click",
- "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE": "show live preview element highlights on 'hover' or 'click'. Defaults to 'hover'",
+ "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE": "Show live preview element highlights on 'hover' or 'click'. Defaults to 'hover'",
+ "LIVE_DEV_SETTINGS_SHOW_RULER_LINES_PREFERENCE": "Show measurements when elements are selected in live preview. Defaults to 'false'",
"LIVE_DEV_MORE_OPTIONS_SELECT_PARENT": "Select Parent",
"LIVE_DEV_MORE_OPTIONS_EDIT_TEXT": "Edit Text",
+ "LIVE_DEV_MORE_OPTIONS_EDIT_HYPERLINK": "Edit Hyperlink",
+ "LIVE_DEV_HYPERLINK_NO_HREF": "No href set",
"LIVE_DEV_MORE_OPTIONS_DUPLICATE": "Duplicate",
"LIVE_DEV_MORE_OPTIONS_DELETE": "Delete",
"LIVE_DEV_MORE_OPTIONS_AI": "Edit with AI",
"LIVE_DEV_MORE_OPTIONS_IMAGE_GALLERY": "Image Gallery",
+ "LIVE_DEV_MORE_OPTIONS_MORE": "More Options",
+ "LIVE_DEV_MORE_OPTIONS_CUT": "Cut",
+ "LIVE_DEV_MORE_OPTIONS_COPY": "Copy",
+ "LIVE_DEV_MORE_OPTIONS_PASTE": "Paste",
"LIVE_DEV_IMAGE_GALLERY_USE_IMAGE": "Use this image",
"LIVE_DEV_IMAGE_GALLERY_SELECT_DOWNLOAD_FOLDER": "Choose image download folder",
"LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER": "Search images\u2026",
@@ -201,7 +208,13 @@ define({
"LIVE_DEV_IMAGE_GALLERY_CLOSE": "Close",
"LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER_TOOLTIP": "Select an image from your device",
"LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER": "Select from device",
+ "LIVE_DEV_IMAGE_GALLERY_DIALOG_OVERLAY_MESSAGE": "Select image download location in the editor to continue",
+ "LIVE_DEV_IMAGE_GALLERY_OFFLINE_BANNER": "No connection - Working in offline mode",
+ "LIVE_DEV_IMAGE_GALLERY_OFFLINE_RETRY": "Retry",
+ "LIVE_DEV_IMAGE_GALLERY_CHECKING_CONNECTION": "Checking connection",
+ "LIVE_DEV_IMAGE_GALLERY_STILL_OFFLINE": "Still offline. Please check your connection.",
"LIVE_DEV_TOAST_NOT_EDITABLE": "Element not editable - generated by script.",
+ "LIVE_DEV_COPY_TOAST_MESSAGE": "Element copied! Click 'Paste' on any element to insert it above.",
"LIVE_DEV_IMAGE_FOLDER_DIALOG_TITLE": "Select Folder to Save Image",
"LIVE_DEV_IMAGE_FOLDER_DIALOG_DESCRIPTION": "Choose where to download the image:",
"LIVE_DEV_IMAGE_FOLDER_DIALOG_PLACEHOLDER": "Type folder path (e.g., assets/images/)",
@@ -214,7 +227,8 @@ define({
"LIVE_PREVIEW_MODE_HIGHLIGHT": "Highlight Mode",
"LIVE_PREVIEW_MODE_EDIT": "Edit Mode",
"LIVE_PREVIEW_EDIT_HIGHLIGHT_ON": "Edit Highlights on Hover",
- "LIVE_PREVIEW_MODE_PREFERENCE": "{0} shows only the webpage, {1} connects the webpage to your code - click on elements to jump to their code and vice versa, {2} provides highlighting along with advanced element manipulation",
+ "LIVE_PREVIEW_SHOW_RULER_LINES": "Show Measurements",
+ "LIVE_PREVIEW_MODE_PREFERENCE": "'{0}' shows only the webpage, '{1}' connects the webpage to your code - click on elements to jump to their code and vice versa, '{2}' provides highlighting along with advanced element manipulation",
"LIVE_PREVIEW_CONFIGURE_MODES": "Configure Live Preview Modes",
"LIVE_DEV_DETACHED_REPLACED_WITH_DEVTOOLS": "Live Preview was canceled because the browser's developer tools were opened",
@@ -529,7 +543,7 @@ define({
"CMD_LIVE_FILE_PREVIEW": "Live Preview",
"CMD_LIVE_FILE_PREVIEW_SETTINGS": "Live Preview Settings",
"CMD_TOGGLE_LIVE_PREVIEW_MB_MODE": "Enable Experimental Live Preview",
- "CMD_RELOAD_LIVE_PREVIEW": "Force Reload Live Preview",
+ "CMD_RELOAD_LIVE_PREVIEW": "Reload Live Preview",
"CMD_PROJECT_SETTINGS": "Project Settings\u2026",
"CMD_FILE_RENAME": "Rename",
"CMD_FILE_DELETE": "Delete",
@@ -606,7 +620,6 @@ define({
"CMD_TOGGLE_LINE_NUMBERS": "Line Numbers",
"CMD_TOGGLE_ACTIVE_LINE": "Highlight Active Line",
"CMD_TOGGLE_WORD_WRAP": "Word Wrap",
- "CMD_LIVE_HIGHLIGHT": "Live Preview Highlight",
"CMD_VIEW_TOGGLE_INSPECTION": "Lint Files on Save",
"CMD_VIEW_TOGGLE_PROBLEMS": "Problems",
"CMD_WORKINGSET_SORT_BY_ADDED": "Sort by Added",
@@ -1132,7 +1145,6 @@ define({
"DESCRIPTION_INDENT_LINE_COMMENT": "true to enable indenting of line comments",
"DESCRIPTION_RECENT_FILES_NAV": "Enable/disable navigation in recent files",
"DESCRIPTION_LIVEDEV_WEBSOCKET_PORT": "Port on which WebSocket Server runs for Live Preview",
- "DESCRIPTION_LIVE_DEV_HIGHLIGHT_SETTINGS": "Live Preview Highlight settings",
"DESCRIPTION_LIVEDEV_ENABLE_REVERSE_INSPECT": "false to disable live preview reverse inspect",
"DESCRIPTION_LIVEDEV_NO_PREVIEW": "Nothing to preview!",
"DESCRIPTION_LIVEDEV_EXCLUDED": "Custom Server Cannot Serve This file",
diff --git a/src/phoenix/shell.js b/src/phoenix/shell.js
index bf018f2ed0..e4fdd6ac99 100644
--- a/src/phoenix/shell.js
+++ b/src/phoenix/shell.js
@@ -177,14 +177,6 @@ Phoenix.app = {
window.__TAURI__.window.getCurrent().setFocus();
window.__TAURI__.window.getCurrent().setAlwaysOnTop(false);
},
- clipboardReadText: function () {
- if(Phoenix.isNativeApp){
- return window.__TAURI__.clipboard.readText();
- } else if(window.navigator && window.navigator.clipboard){
- return window.navigator.clipboard.readText();
- }
- return Promise.reject(new Error("clipboardReadText: Not supported."));
- },
/**
* Gets the commandline argument in desktop builds and null in browser builds.
* Will always return CLI of the current process only.
@@ -278,6 +270,14 @@ Phoenix.app = {
});
}
},
+ clipboardReadText: function () {
+ if(Phoenix.isNativeApp){
+ return window.__TAURI__.clipboard.readText();
+ } else if(window.navigator && window.navigator.clipboard){
+ return window.navigator.clipboard.readText();
+ }
+ return Promise.reject(new Error("clipboardReadText: Not supported."));
+ },
clipboardReadFiles: function () {
return new Promise((resolve, reject)=>{
if(Phoenix.isNativeApp){
diff --git a/src/robots.txt b/src/robots.txt
index 0996382567..5ffe8d9525 100644
--- a/src/robots.txt
+++ b/src/robots.txt
@@ -1,5 +1,5 @@
# The use of robots or other automated means to access the sites managed by core.ai
-# without the express permission of Adobe is strictly prohibited.
+# without the express permission of core.ai is strictly prohibited.
# Notwithstanding the foregoing, core.ai may permit automated access to
# access certain pages but solely for the limited purpose of
# including content in publicly available search engines. Any other
diff --git a/src/services/EntitlementsManager.js b/src/services/EntitlementsManager.js
index 964b494072..f4ec73ff4c 100644
--- a/src/services/EntitlementsManager.js
+++ b/src/services/EntitlementsManager.js
@@ -75,8 +75,12 @@ define(function (require, exports, module) {
});
}
+ let _entitlementFnForTests;
let effectiveEntitlementsCached = undefined; // entitlements can be null and its valid if no login/trial
async function _getEffectiveEntitlements() {
+ if(_entitlementFnForTests){
+ return _entitlementFnForTests();
+ }
if(effectiveEntitlementsCached !== undefined){
return effectiveEntitlementsCached;
}
@@ -358,7 +362,11 @@ define(function (require, exports, module) {
getRawEntitlements,
getNotifications,
getLiveEditEntitlement,
- loginToAccount
+ loginToAccount,
+ simulateEntitlementForTests: (entitlementsFn) => {
+ _entitlementFnForTests = entitlementsFn;
+ EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED);
+ }
};
}
diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less
index ee4f57cb18..7a5bc286c7 100644
--- a/src/styles/brackets_patterns_override.less
+++ b/src/styles/brackets_patterns_override.less
@@ -2522,20 +2522,20 @@ code {
height: 30px;
padding: 5px;
box-sizing: border-box;
- margin-bottom: 8px;
+ margin-bottom: 10px;
}
#folder-suggestions {
max-height: 150px;
overflow-y: auto;
overflow-x: hidden;
- border: 1px solid @bc-btn-border;
- border-radius: @bc-border-radius;
- background-color: @bc-panel-bg-alt;
+ outline: 1px solid #f5f5f5;
+ border-radius: 3px;
+ background-color: #f5f5f5;
.dark & {
- border: 1px solid @dark-bc-btn-border;
- background-color: @dark-bc-panel-bg-alt;
+ background-color: #1E1E1E;
+ outline: 1px solid #1E1E1E;
}
&:empty {
@@ -2550,37 +2550,60 @@ code {
.folder-suggestion-item {
padding: 6px 10px;
+ display: flex;
+ align-items: center;
cursor: pointer;
- font-size: 12px;
- color: @bc-text;
- border-left: 3px solid transparent;
+ font-size: 0.875rem;
+ letter-spacing: 0.4px;
+ word-spacing: 0.75px;
+ color: #555;
+ background-color: #f1f1f1;
+ border-right: 1px solid rgba(0, 0, 0, 0.05);
+ position: relative;
+ user-select: none;
.dark & {
- color: @dark-bc-text;
+ color: #aaa;
+ background-color: #292929;
+ border-right: 1px solid rgba(255, 255, 255, 0.05);
}
&:hover {
- background-color: rgba(0, 0, 0, 0.03);
+ background-color: #e0e0e0;
.dark & {
- background-color: rgba(255, 255, 255, 0.05);
+ background-color: #3b3a3a;
}
}
&.selected {
- background-color: rgba(40, 142, 223, 0.08);
- border-left-color: #288edf;
+ background-color: #fff;
+ color: #333;
.dark & {
- background-color: rgba(40, 142, 223, 0.15);
- border-left-color: #3da3ff;
+ background-color: #1D1F21;
+ color: #dedede;
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 0.15rem;
+ background-color: #0078D7;
+
+ .dark & {
+ background-color: #75BEFF;
+ }
}
&:hover {
- background-color: rgba(40, 142, 223, 0.12);
+ background-color: #fff;
.dark & {
- background-color: rgba(40, 142, 223, 0.2);
+ background-color: #1D1F21;
}
}
}
@@ -2596,7 +2619,7 @@ code {
}
.folder-help-text {
- margin-top: 8px;
+ margin-top: 10px;
margin-bottom: 0;
font-size: 11px;
color: @bc-text-quiet;
diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js
index b9b828dd66..0282b79436 100644
--- a/test/UnitTestSuite.js
+++ b/test/UnitTestSuite.js
@@ -142,6 +142,8 @@ define(function (require, exports, module) {
require("spec/Extn-Git-integ-test");
// Node Tests
require("spec/NodeConnection-test");
+ // pro test suite optional components
+ require("./pro-test-suite");
// todo TEST_MODERN
// require("spec/LanguageTools-test"); LSP tests. disabled for now
// require("spec/Menu-native-integ-test"); evaluate after we have native menus in os installed builds
diff --git a/test/spec/LiveDevelopmentCustomServer-test.js b/test/spec/LiveDevelopmentCustomServer-test.js
index f54b56b0f5..76f494b244 100644
--- a/test/spec/LiveDevelopmentCustomServer-test.js
+++ b/test/spec/LiveDevelopmentCustomServer-test.js
@@ -109,11 +109,7 @@ define(function (require, exports, module) {
WorkspaceManager = null;
}, 30000);
- async function _enableLiveHighlights(enable) {
- PreferencesManager.setViewState("livedevHighlight", enable);
- }
async function endPreviewSession() {
- await _enableLiveHighlights(true);
LiveDevMultiBrowser.close();
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }),
"closing all file");
@@ -136,6 +132,17 @@ define(function (require, exports, module) {
await waitsForLiveDevelopmentFileSwitch();
}
+ async function waitForLivePreviewToContainTitle(title) {
+ await awaitsFor(
+ function isLiveDevelopmentActive() {
+ const currentTitle = testWindow.$("#panel-live-preview-title").attr("title");
+ return currentTitle.indexOf(title) !== -1;
+ },
+ `Liuve prview page title to be ${title}`,
+ 20000
+ );
+ }
+
it("should live preview settings work as expected", async function () {
const testTempDir = await SpecRunnerUtils.getTempTestDirectory(
"/spec/LiveDevelopment-MultiBrowser-test-files", true);
@@ -1011,11 +1018,12 @@ define(function (require, exports, module) {
expect(testWindow.$(".live-preview-status-overlay").is(":visible")).toBeFalse();
// now edit the settings
+ const serverURL = "http://localhost:8000";
testWindow.$(".live-preview-settings").click();
await SpecRunnerUtils.waitForModalDialog();
if(!testWindow.$("#enableCustomServerChk").is(":checked")){
testWindow.$("#enableCustomServerChk").click();
- testWindow.$("#livePreviewServerURL").val("http://localhost:8000");
+ testWindow.$("#livePreviewServerURL").val(serverURL);
}
SpecRunnerUtils.clickDialogButton(Dialogs.DIALOG_BTN_OK);
@@ -1032,7 +1040,7 @@ define(function (require, exports, module) {
await SpecRunnerUtils.loadProjectInTestWindow(testPath);
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
"open simple1.html");
- await waitsForLiveDevelopmentToOpen();
+ await waitForLivePreviewToContainTitle(serverURL);
await awaits(100);// give some time to see if the banner comes up
expect(testWindow.$(".live-preview-settings").is(":visible")).toBeFalse();
diff --git a/test/spec/LiveDevelopmentMultiBrowser-test.js b/test/spec/LiveDevelopmentMultiBrowser-test.js
index ef03e15d32..1a6a0b6948 100644
--- a/test/spec/LiveDevelopmentMultiBrowser-test.js
+++ b/test/spec/LiveDevelopmentMultiBrowser-test.js
@@ -27,6 +27,7 @@ define(function (require, exports, module) {
const SpecRunnerUtils = require("spec/SpecRunnerUtils"),
KeyEvent = require("utils/KeyEvent"),
StringUtils = require("utils/StringUtils"),
+ CONSTANTS = require("LiveDevelopment/LivePreviewConstants"),
Strings = require("strings");
describe("livepreview:MultiBrowser Live Preview", function () {
@@ -113,17 +114,6 @@ define(function (require, exports, module) {
await SpecRunnerUtils.loadProjectInTestWindow(testFolder);
await SpecRunnerUtils.deletePathAsync(testFolder + "/.phcode.json", true);
- // Disable edit mode features for core live preview tests
- // This ensures tests focus on basic live preview functionality without
- // edit mode interference (hover/click handlers)
- if (LiveDevMultiBrowser && LiveDevMultiBrowser.config) {
- LiveDevMultiBrowser.config.isProUser = false;
- // Also update the remote browser configuration
- if (LiveDevMultiBrowser.updateConfig) {
- LiveDevMultiBrowser.updateConfig(JSON.stringify(LiveDevMultiBrowser.config));
- }
- }
-
if (!WorkspaceManager.isPanelVisible('live-preview-panel')) {
await awaitsForDone(CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW));
}
@@ -149,11 +139,11 @@ define(function (require, exports, module) {
WorkspaceManager = null;
}, 30000);
- async function _enableLiveHighlights(enable) {
- PreferencesManager.setViewState("livedevHighlight", enable);
+ async function _setLivePreviewMode(mode) {
+ PreferencesManager.set(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE, mode);
}
async function endPreviewSession() {
- await _enableLiveHighlights(true);
+ await _setLivePreviewMode(CONSTANTS.LIVE_HIGHLIGHT_MODE);
LiveDevMultiBrowser.close();
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }),
"closing all file");
@@ -172,14 +162,6 @@ define(function (require, exports, module) {
}
async function waitsForLiveDevelopmentToOpen() {
- // Ensure edit mode is disabled before opening live preview
- if (LiveDevMultiBrowser && LiveDevMultiBrowser.config) {
- LiveDevMultiBrowser.config.isProUser = false;
- // Update the remote browser configuration to sync the disabled state
- if (LiveDevMultiBrowser.updateConfig) {
- LiveDevMultiBrowser.updateConfig(JSON.stringify(LiveDevMultiBrowser.config));
- }
- }
LiveDevMultiBrowser.open();
await waitsForLiveDevelopmentFileSwitch();
}
@@ -923,7 +905,6 @@ define(function (require, exports, module) {
}, 30000);
it("focus test: should html live previews never take focus from editor", async function () {
- // this test may fail if the test window doesn't have focus
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
"SpecRunnerUtils.openProjectFiles simple1.html");
@@ -937,7 +918,7 @@ define(function (require, exports, module) {
// delegate focus to editor explicitly in case of html files.
expect(testWindow.document.activeElement).toEqual(iFrame);
// for html, it can take focus, but clicking on any non- text elemnt will make it loose focus to editor
- await forRemoteExec(`document.getElementById("testId2").click()`);
+ await forRemoteExec(`document.getElementById("testId").click()`);
await awaits(500);
const activeElement = testWindow.document.activeElement;
const editorHolder = testWindow.document.getElementById("editor-holder");
@@ -1005,8 +986,6 @@ define(function (require, exports, module) {
"SpecRunnerUtils.openProjectFiles simple1.html");
await waitsForLiveDevelopmentToOpen();
- await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ',
- "testId", "Brackets is hello world awesome!");
let iFrame = testWindow.document.getElementById("panel-live-preview-frame");
expect(iFrame.src.endsWith("simple1.html")).toBeTrue();
@@ -1041,8 +1020,6 @@ define(function (require, exports, module) {
"SpecRunnerUtils.openProjectFiles simple1.html");
await waitsForLiveDevelopmentToOpen();
- await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ',
- "testId", "Brackets is hello world awesome!");
let iFrame = testWindow.document.getElementById("panel-live-preview-frame");
expect(iFrame.src.endsWith("simple1.html")).toBeTrue();
@@ -1552,7 +1529,7 @@ define(function (require, exports, module) {
}, 30000);
it("should reverse highlight be disabled if live highlight is disabled", async function () {
- await _enableLiveHighlights(false);
+ await _setLivePreviewMode(CONSTANTS.LIVE_PREVIEW_MODE);
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
"SpecRunnerUtils.openProjectFiles simple1.html");
@@ -1569,7 +1546,7 @@ define(function (require, exports, module) {
await awaits(500);
expect(editor.getCursorPos()).toEql({ line: 0, ch: 0, sticky: null });
- await _enableLiveHighlights(true);
+ await _setLivePreviewMode(CONSTANTS.LIVE_HIGHLIGHT_MODE);
await endPreviewSession();
}, 30000);
@@ -1603,24 +1580,24 @@ define(function (require, exports, module) {
}, 30000);
it("should beautify and undo not corrupt live preview", async function () {
- await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
- "SpecRunnerUtils.openProjectFiles simple1.html");
+ await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple2.html"]),
+ "SpecRunnerUtils.openProjectFiles simple2.html");
await waitsForLiveDevelopmentToOpen();
- await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ',
- "testId", "Brackets is hello world awesome!");
+ await _editFileAndVerifyLivePreview("simple2.html", { line: 11, ch: 45 }, 'hello world ',
+ "simpId", "Brackets is hello world awesome!");
let editor = EditorManager.getActiveEditor();
await BeautificationManager.beautifyEditor(editor);
- await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 73 }, 'yo',
- "testId", "Brackets is hello world awesome!yo");
+ await _editFileAndVerifyLivePreview("simple2.html", { line: 11, ch: 73 }, 'yo',
+ "simpId", "Brackets is hello world awesome!yo");
await awaitsForDone(CommandManager.execute(Commands.EDIT_UNDO), "undo");
await awaitsForDone(CommandManager.execute(Commands.EDIT_UNDO), "undo");
await awaitsForDone(CommandManager.execute(Commands.EDIT_UNDO), "undo");
- await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ',
- "testId", "Brackets is hello world awesome!");
+ await _editFileAndVerifyLivePreview("simple2.html", { line: 11, ch: 45 }, 'hello world ',
+ "simpId", "Brackets is hello world awesome!");
await endPreviewSession();
}, 30000);
@@ -1944,246 +1921,5 @@ define(function (require, exports, module) {
testWindow.$("#pinURLButton").click();
await endPreviewSession();
}, 30000);
-
- describe("Edit Mode Tests", function () {
-
- async function waitsForLiveDevelopmentToOpenWithEditMode(elemHighlights = 'hover') {
- // Enable edit mode before opening live preview
- if (LiveDevMultiBrowser && LiveDevMultiBrowser.config) {
- LiveDevMultiBrowser.config.isProUser = true;
- LiveDevMultiBrowser.config.elemHighlights = elemHighlights;
- // Update the remote browser configuration
- if (LiveDevMultiBrowser.updateConfig) {
- LiveDevMultiBrowser.updateConfig(JSON.stringify(LiveDevMultiBrowser.config));
- }
- }
- LiveDevMultiBrowser.open();
- await waitsForLiveDevelopmentFileSwitch();
- }
-
- async function endEditModePreviewSession() {
- await _enableLiveHighlights(true);
- LiveDevMultiBrowser.close();
- // Disable edit mode after session
- if (LiveDevMultiBrowser && LiveDevMultiBrowser.config) {
- LiveDevMultiBrowser.config.isProUser = false;
- LiveDevMultiBrowser.config.elemHighlights = 'hover';
- }
- await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }),
- "closing all file");
- }
-
- async function waitForInfoBox(shouldBeVisible = true, timeout = 5000) {
- await forRemoteExec(`
- const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot);
- let hasInfoBox = false;
- shadowHosts.forEach(host => {
- if (host.shadowRoot && host.shadowRoot.innerHTML.includes('phoenix-node-info-box')) {
- hasInfoBox = true;
- }
- });
- hasInfoBox;
- `, (result) => {
- return result === shouldBeVisible;
- });
- }
-
- async function waitForMoreOptionsBox(shouldBeVisible = true, timeout = 5000) {
- await forRemoteExec(`
- const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot);
- let hasMoreOptionsBox = false;
- shadowHosts.forEach(host => {
- if (host.shadowRoot && host.shadowRoot.innerHTML.includes('phoenix-more-options-box')) {
- hasMoreOptionsBox = true;
- }
- });
- hasMoreOptionsBox;
- `, (result) => {
- return result === shouldBeVisible;
- });
- }
-
- async function waitForClickedElement(shouldBeVisible = true, timeout = 5000) {
- await forRemoteExec(`
- const highlightedElements = document.getElementsByClassName("__brackets-ld-highlight");
- Array.from(highlightedElements).some(el =>
- el.style.backgroundColor && el.style.backgroundColor.includes('rgba(0, 162, 255')
- );
- `, (result) => {
- return result === shouldBeVisible;
- });
- }
-
- async function waitForNoEditBoxes() {
- // Wait for no shadow DOM boxes and no clicked element highlighting
- await forRemoteExec(`
- const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot);
- shadowHosts.length;
- `, (result) => {
- return result === 0;
- });
-
- await waitForClickedElement(false);
- }
-
- it("should show info box on hover when elemHighlights is 'hover'", async function () {
- await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
- "SpecRunnerUtils.openProjectFiles simple1.html");
-
- await waitsForLiveDevelopmentToOpenWithEditMode('hover');
-
- // Initially no boxes should be visible
- await waitForNoEditBoxes();
-
- // Hover over testId element
- await forRemoteExec(`
- const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true });
- document.getElementById('testId').dispatchEvent(event);
- `);
-
- // Info box should appear on hover
- await waitForInfoBox(true);
- await waitForMoreOptionsBox(false);
-
- // Mouse out should hide the info box
- await forRemoteExec(`
- const event = new MouseEvent('mouseout', { bubbles: true, cancelable: true });
- document.getElementById('testId').dispatchEvent(event);
- `);
-
- await waitForInfoBox(false);
- await waitForNoEditBoxes();
-
- await endEditModePreviewSession();
- }, 30000);
-
- it("should show more options box on click when elemHighlights is 'hover'", async function () {
- await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
- "SpecRunnerUtils.openProjectFiles simple1.html");
-
- await waitsForLiveDevelopmentToOpenWithEditMode('hover');
-
- // Click on testId element
- await forRemoteExec(`document.getElementById('testId').click()`);
-
- // More options box should appear on click
- await waitForMoreOptionsBox(true);
- await waitForClickedElement(true);
-
- // Clicking on a different element should move the box
- await forRemoteExec(`document.getElementById('testId2').click()`);
-
- await waitForMoreOptionsBox(true);
- await waitForClickedElement(true);
-
- await endEditModePreviewSession();
- }, 30000);
-
- it("should show more options box on click when elemHighlights is 'click'", async function () {
- await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
- "SpecRunnerUtils.openProjectFiles simple1.html");
-
- await waitsForLiveDevelopmentToOpenWithEditMode('click');
-
- // Initially no boxes should be visible
- await waitForNoEditBoxes();
-
- // In click mode, hover should not show info box
- await forRemoteExec(`
- const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true });
- document.getElementById('testId').dispatchEvent(event);
- `);
-
- // Should still be no boxes visible
- await waitForInfoBox(false);
- await waitForMoreOptionsBox(false);
-
- // Click should show more options box
- await forRemoteExec(`document.getElementById('testId').click()`);
-
- await waitForMoreOptionsBox(true);
- await waitForClickedElement(true);
-
- await endEditModePreviewSession();
- }, 30000);
-
- it("should handle multiple element interactions in hover mode", async function () {
- await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple2.html"]),
- "SpecRunnerUtils.openProjectFiles simple2.html");
-
- await waitsForLiveDevelopmentToOpenWithEditMode('hover');
-
- // Test hovering over multiple elements
- const elementIds = ['simpId', 'simpId2', 'simpId3'];
-
- for (let elementId of elementIds) {
- // Hover over element
- await forRemoteExec(`
- const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true });
- document.getElementById('${elementId}').dispatchEvent(event);
- `);
-
- // Info box should appear
- await waitForInfoBox(true);
-
- // Mouse out
- await forRemoteExec(`
- const event = new MouseEvent('mouseout', { bubbles: true, cancelable: true });
- document.getElementById('${elementId}').dispatchEvent(event);
- `);
-
- // Box should disappear
- await waitForInfoBox(false);
- }
-
- await endEditModePreviewSession();
- }, 30000);
-
- it("should handle multiple element clicks and box movement", async function () {
- await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple2.html"]),
- "SpecRunnerUtils.openProjectFiles simple2.html");
-
- await waitsForLiveDevelopmentToOpenWithEditMode('hover');
-
- const elementIds = ['simpId', 'simpId2', 'simpId3'];
-
- // Click on first element
- await forRemoteExec(`document.getElementById('${elementIds[0]}').click()`);
-
- await waitForMoreOptionsBox(true);
- await waitForClickedElement(true);
-
- // Click on subsequent elements - box should move
- for (let i = 1; i < elementIds.length; i++) {
- await forRemoteExec(`document.getElementById('${elementIds[i]}').click()`);
-
- await waitForMoreOptionsBox(true);
- await waitForClickedElement(true);
- }
-
- await endEditModePreviewSession();
- }, 30000);
-
- it("should dismiss boxes when clicking outside elements", async function () {
- await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]),
- "SpecRunnerUtils.openProjectFiles simple1.html");
-
- await waitsForLiveDevelopmentToOpenWithEditMode('hover');
-
- // Click on element to show more options box
- await forRemoteExec(`document.getElementById('testId').click()`);
-
- await waitForMoreOptionsBox(true);
-
- // Click on body (outside any specific element)
- await forRemoteExec(`document.body.click()`);
-
- // Boxes should be dismissed
- await waitForMoreOptionsBox(false);
- await waitForClickedElement(false);
-
- await endEditModePreviewSession();
- }, 30000);
- });
});
});
diff --git a/tracking-repos.json b/tracking-repos.json
new file mode 100644
index 0000000000..f82ea51b2e
--- /dev/null
+++ b/tracking-repos.json
@@ -0,0 +1,5 @@
+{
+ "phoenixPro": {
+ "commitID": "5692b8e6d4b0b6f24bb81fbd15a1a9afe85f4c52"
+ }
+}