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..ba009110e6 100644
--- a/gulpfile.js/index.js
+++ b/gulpfile.js/index.js
@@ -30,12 +30,14 @@ 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 validateBuild = require("./validate-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,11 +59,13 @@ function cleanAll() {
// Test artifacts
'dist-test',
'test/spec/test_folders.zip',
+ 'src/extensionsIntegrated/pro-loader.js',
+ 'test/pro-test-suite.js',
...RELEASE_BUILD_ARTEFACTS
]);
}
-function cleanUnwantedFilesInDist() {
+function cleanUnwantedFilesInDistDev() {
return del([
'dist/nls/*/expertTranslations.json',
'dist/nls/*/lastTranslated.json',
@@ -71,6 +75,55 @@ function cleanUnwantedFilesInDist() {
]);
}
+function cleanUnwantedFilesInDistProd() {
+ return del([
+ 'dist/nls/*/expertTranslations.json',
+ 'dist/nls/*/lastTranslated.json',
+ 'dist/nls/*/*.js.map',
+ 'dist/extensions/default/*/unittests.js.map',
+ 'dist/**/*no_dist.*',
+ 'dist/thirdparty/no-minify/language-worker.js.map'
+ ]);
+}
+
+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 +142,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 +151,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 +596,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 +680,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 +688,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 +722,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 +733,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 +829,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 +866,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 +885,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 +983,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");
@@ -926,6 +1058,7 @@ function validatePackageVersions() {
});
}
+
function _patchMinifiedCSSInDistIndex() {
return new Promise((resolve)=>{
let content = fs.readFileSync("dist/index.html", "utf8");
@@ -942,26 +1075,29 @@ 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, optionalBuild.generateProBuildInfo, 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, optionalBuild.generateProBuildInfo, 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);
+ makeDistAll, cleanUnwantedFilesInDistDev, releaseDev, _renameConcatExtensionsinDist,
+ createDistCacheManifest, createDistTest,
+ _cleanPhoenixProGitFolder, _cleanReleaseBuildArtefactsInSrc, validateBuild.validateDistSizeRestrictions);
+exports.releaseStaging = series(cleanDist, exports.build, makeBracketsConcatJSWithMinifiedBrowserScripts,
+ makeConcatExtensions, _compileLessSrc, makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist,
+ cleanUnwantedFilesInDistProd, _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist,
+ _patchMinifiedCSSInDistIndex, releaseStaging, createDistCacheManifest, createDistTest,
+ _deletePhoenixProSourceFolder, _cleanReleaseBuildArtefactsInSrc, validateBuild.validateDistSizeRestrictions);
+exports.releaseProd = series(cleanDist, exports.build, makeBracketsConcatJSWithMinifiedBrowserScripts,
+ makeConcatExtensions, _compileLessSrc, makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist,
+ cleanUnwantedFilesInDistProd, _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist,
+ _patchMinifiedCSSInDistIndex, releaseProd, createDistCacheManifest, createDistTest,
+ _deletePhoenixProSourceFolder, _cleanReleaseBuildArtefactsInSrc, validateBuild.validateDistSizeRestrictions);
exports.releaseWebCache = series(makeDistWebCache);
exports.serve = series(exports.build, serve);
exports.zipTestFiles = series(zipTestFiles);
@@ -972,3 +1108,4 @@ exports.default = series(exports.build);
exports.patchVersionBump = series(patchVersionBump);
exports.minorVersionBump = series(minorVersionBump);
exports.majorVersionBump = series(majorVersionBump);
+exports.validateDistSizeRestrictions = series(validateBuild.validateDistSizeRestrictions);
diff --git a/gulpfile.js/optional-build.js b/gulpfile.js/optional-build.js
new file mode 100644
index 0000000000..c839fc5a99
--- /dev/null
+++ b/gulpfile.js/optional-build.js
@@ -0,0 +1,235 @@
+/*
+ * 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 repository URL is set
+ if (!proRepoUrl) {
+ // this si what will happen in most dev builds.
+ console.log('Skipping phoenix-pro clone: PRO_REPO_URL not set');
+ console.log('This is expected for community builds');
+ resolve();
+ return;
+ }
+
+ if (!proRepoToken) {
+ console.warn('PRO_REPO_ACCESS_TOKEN not set, will attempt clone without authentication');
+ }
+
+ // 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 if token is available
+ const authUrl = proRepoToken
+ ? proRepoUrl.replace('https://', `https://oauth2:${proRepoToken}@`)
+ : proRepoUrl;
+
+ // 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 is 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
+ }
+ });
+}
+
+/**
+ * Generates a JSON file with phoenix-pro build information including the commit ID.
+ * Only generates if the phoenix-pro folder exists.
+ * If the phoenix-pro directory is not a git repository, the commit ID will be "unknown".
+ */
+function generateProBuildInfo() {
+ return new Promise((resolve) => {
+ const phoenixProPath = path.resolve(__dirname, '../src/extensionsIntegrated/phoenix-pro');
+
+ // Only generate if phoenix-pro folder exists
+ if (!fs.existsSync(phoenixProPath)) {
+ console.log('Phoenix Pro folder not found, skipping buildInfo.json generation');
+ resolve();
+ return;
+ }
+
+ const gitPath = path.join(phoenixProPath, '.git');
+ let commitID = "unknown";
+
+ if (fs.existsSync(gitPath)) {
+ try {
+ commitID = execSync('git rev-parse --short HEAD', {
+ cwd: phoenixProPath,
+ encoding: 'utf8'
+ }).trim();
+ console.log(`Phoenix Pro commit ID: ${commitID}`);
+ } catch (error) {
+ console.warn('Could not get phoenix-pro commit ID:', error.message);
+ commitID = "unknown";
+ }
+ } else {
+ console.log('Phoenix Pro is not a git repository, using "unknown" for commit ID');
+ }
+
+ const buildInfo = {
+ phoenixProCommitID: commitID
+ };
+
+ const buildInfoPath = path.join(phoenixProPath, 'proBuildInfo.json');
+ fs.writeFileSync(buildInfoPath, JSON.stringify(buildInfo, null, 2));
+
+ console.log('Generated phoenix-pro/proBuildInfo.json');
+ resolve();
+ });
+}
+
+// Export the functions
+exports.clonePhoenixProRepo = clonePhoenixProRepo;
+exports.generateProBuildInfo = generateProBuildInfo;
diff --git a/gulpfile.js/validate-build.js b/gulpfile.js/validate-build.js
new file mode 100644
index 0000000000..cf31f5dff5
--- /dev/null
+++ b/gulpfile.js/validate-build.js
@@ -0,0 +1,188 @@
+/*
+ * 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 glob = require('glob');
+
+// Size limits for development builds (in MB)
+const DEV_MAX_FILE_SIZE_MB = 6;
+const DEV_MAX_TOTAL_SIZE_MB = 90;
+// Custom size limits for known large files (size in MB) For development builds
+const LARGE_FILE_LIST_DEV = {
+ 'dist/thirdparty/no-minify/language-worker.js.map': 10,
+ 'dist/brackets-min.js': 15
+};
+
+// Size limits for production/staging builds (in MB)
+const PROD_MAX_FILE_SIZE_MB = 2;
+const PROD_MAX_TOTAL_SIZE_MB = 70;
+// Custom size limits for known large files (size in MB) For staging/production builds
+const LARGE_FILE_LIST_PROD = {
+ 'dist/brackets.js': 9, // this is the full minified file itself renamed in prod
+ 'dist/phoenix/virtualfs.js.map': 3
+};
+
+function _listFilesInDir(dir) {
+ return new Promise((resolve, reject)=>{
+ glob(dir + '/**/*', {
+ nodir: true
+ }, (err, res)=>{
+ if(err){
+ reject(err);
+ return;
+ }
+ resolve(res);
+ });
+ });
+}
+
+function _scanDistFiles(environment, largeFileList, maxFileSizeMB, maxTotalSizeMB) {
+ return new Promise((resolve, reject) => {
+ const maxTotalSizeBytes = maxTotalSizeMB * 1024 * 1024;
+
+ _listFilesInDir('dist').then((files) => {
+ const oversizedFiles = [];
+ let totalSize = 0;
+
+ for (let file of files) {
+ const stats = fs.statSync(file);
+ totalSize += stats.size;
+
+ // Check if file has a custom size limit
+ const customLimitMB = largeFileList[file];
+ const fileLimitMB = customLimitMB !== undefined ? customLimitMB : maxFileSizeMB;
+ const fileLimitBytes = fileLimitMB * 1024 * 1024;
+
+ if (stats.size > fileLimitBytes) {
+ oversizedFiles.push({
+ path: file,
+ sizeMB: (stats.size / (1024 * 1024)).toFixed(2),
+ limitMB: fileLimitMB,
+ isCustomLimit: customLimitMB !== undefined
+ });
+ }
+ }
+
+ const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2);
+
+ resolve({
+ oversizedFiles,
+ totalSizeMB,
+ totalLimitMB: maxTotalSizeMB,
+ hasTotalSizeExceeded: totalSize > maxTotalSizeBytes
+ });
+ }).catch(reject);
+ });
+}
+
+function _displayValidationResults(scanResults, environment) {
+ const {
+ oversizedFiles,
+ totalSizeMB,
+ totalLimitMB,
+ hasTotalSizeExceeded
+ } = scanResults;
+
+ if (oversizedFiles.length || hasTotalSizeExceeded) {
+ console.error(`\n========================================`);
+ console.error(`SIZE VALIDATION FAILED (${environment})`);
+ console.error(`========================================`);
+
+ if (hasTotalSizeExceeded) {
+ console.error(`\nTotal dist folder size: ${totalSizeMB} MB ` +
+ `(exceeds ${totalLimitMB} MB limit for ${environment})`);
+ }
+
+ if (oversizedFiles.length) {
+ // Sort by size in descending order
+ oversizedFiles.sort((a, b) => b.sizeBytes - a.sizeBytes);
+ console.error(`\nFound ${oversizedFiles.length} file(s) exceeding size limits for ${environment}:\n`);
+
+ for (let file of oversizedFiles) {
+ const limitInfo = file.isCustomLimit ?
+ ` [custom limit: ${file.limitMB} MB]` : ` [limit: ${file.limitMB} MB]`;
+ console.error(` ${file.path} (${file.sizeMB} MB)${limitInfo}`);
+ }
+ }
+
+ console.error(`\n========================================\n`);
+
+ const errors = [];
+ if (hasTotalSizeExceeded) {
+ errors.push(`Total dist size ${totalSizeMB} MB exceeds ${totalLimitMB} MB limit`);
+ }
+ if (oversizedFiles.length) {
+ errors.push(`${oversizedFiles.length} file(s) exceed size limit`);
+ }
+
+ return {
+ passed: false,
+ errorMessage: `Build validation failed for ${environment}: ${errors.join('; ')}`
+ };
+ }
+
+ console.log(`Size validation passed for ${environment}: Total dist size is ${totalSizeMB} MB ` +
+ `(under ${totalLimitMB} MB), all files under required limits.`);
+ return {
+ passed: true
+ };
+}
+
+function validateDistSizeRestrictions() {
+ return new Promise((resolve, reject) => {
+ // Read config to determine environment
+ let config;
+ try {
+ config = JSON.parse(fs.readFileSync('dist/config.json', 'utf8'));
+ } catch (err) {
+ reject(`Failed to read dist/config.json for size validation: ${err.message}`);
+ return;
+ }
+
+ const environment = config.config?.environment || 'production';
+ const isDev = environment === 'dev';
+
+ // Set limits based on environment
+ const MAX_FILE_SIZE_MB = isDev ? DEV_MAX_FILE_SIZE_MB : PROD_MAX_FILE_SIZE_MB;
+ const MAX_TOTAL_SIZE_MB = isDev ? DEV_MAX_TOTAL_SIZE_MB : PROD_MAX_TOTAL_SIZE_MB;
+ const LARGE_FILE_LIST = isDev ? LARGE_FILE_LIST_DEV : LARGE_FILE_LIST_PROD;
+
+ console.log(`Validating dist size for ${environment} environment
+ (File limit: ${MAX_FILE_SIZE_MB} MB, Total limit: ${MAX_TOTAL_SIZE_MB} MB)`);
+
+ _scanDistFiles(environment, LARGE_FILE_LIST, MAX_FILE_SIZE_MB, MAX_TOTAL_SIZE_MB)
+ .then((scanResults) => {
+ const result = _displayValidationResults(scanResults, environment);
+
+ if (result.passed) {
+ resolve();
+ } else {
+ reject(result.errorMessage);
+ }
+ })
+ .catch(reject);
+ });
+}
+
+module.exports = {
+ validateDistSizeRestrictions
+};
diff --git a/package-lock.json b/package-lock.json
index 06055874e4..27808ef6cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1146,9 +1146,9 @@
}
},
"node_modules/acorn": {
- "version": "8.12.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
- "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
@@ -13806,9 +13806,9 @@
}
},
"acorn": {
- "version": "8.12.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
- "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true
},
"acorn-jsx": {
diff --git a/package.json b/package.json
index c1fd82627e..8a346b5b87 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"release:dev": "gulp releaseDev",
"release:staging": "gulp releaseStaging",
"release:prod": "gulp releaseProd",
+ "validate:dist-size": "gulp validateDistSizeRestrictions",
"_releaseWebCache": "gulp releaseWebCache",
"_patchVersionBump": "gulp patchVersionBump",
"_minorVersionBump": "gulp minorVersionBump",
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..f170f507f0 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,17 @@ function RemoteFunctions(config = {}) {
if (_hoverHighlight) {
_hoverHighlight.clear();
}
+ if (_clickHighlight) {
+ _clickHighlight.clear();
+ _clickHighlight = null;
+ }
+
+ // Notify handlers about cleanup
+ getAllToolHandlers().forEach(handler => {
+ if (handler.onElementCleanup) {
+ handler.onElementCleanup();
+ }
+ });
previouslyClickedElement = null;
}
@@ -4660,223 +1299,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/LivePreviewEdit.js b/src/LiveDevelopment/LivePreviewEdit.js
deleted file mode 100644
index 851e84d094..0000000000
--- a/src/LiveDevelopment/LivePreviewEdit.js
+++ /dev/null
@@ -1,1378 +0,0 @@
-/*
- * 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.
- *
- */
-
-/*
- * This file handles all the editor side source code handling after user performed some live preview edit operation
- * when any operation is performed in the browser context (handled inside remoteFunctions.js) it sends a message through
- * MessageBroker, now this file then makes the change in the source code
- */
-define(function (require, exports, module) {
- const HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation");
- const LiveDevMultiBrowser = require("LiveDevelopment/LiveDevMultiBrowser");
- const LiveDevelopment = require("LiveDevelopment/main");
- const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror");
- const ProjectManager = require("project/ProjectManager");
- const CommandManager = require("command/CommandManager");
- const Commands = require("command/Commands");
- const FileSystem = require("filesystem/FileSystem");
- const PathUtils = require("thirdparty/path-utils/path-utils");
- const StringMatch = require("utils/StringMatch");
- const Dialogs = require("widgets/Dialogs");
- const StateManager = require("preferences/StateManager");
- 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");
-
- // 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";
-
- const DOWNLOAD_EVENTS = {
- STARTED: 'downloadStarted',
- COMPLETED: 'downloadCompleted',
- CANCELLED: 'downloadCancelled',
- ERROR: 'downloadError'
- };
-
- const KernalModeTrust = window.KernalModeTrust;
- if(!KernalModeTrust){
- // integrated extensions will have access to kernal mode, but not external extensions
- throw new Error("LivePreviewEdit.js should have access to KernalModeTrust. Cannot boot without trust ring");
- }
-
- /**
- * This function syncs text content changes between the original source code
- * and the live preview DOM after a text edit in the browser
- *
- * @private
- * @param {String} oldContent - the original source code from the editor
- * @param {String} newContent - the outerHTML after editing in live preview
- * @returns {String} - the updated content that should replace the original editor code
- *
- * NOTE: We don’t touch tag names or attributes —
- * 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
- * - Then walk both DOMs side by side and sync changes
- *
- * What we handle:
- * - if both are text nodes → update the text if changed
- * - if both are elements with same tag → go deeper and sync their children
- * - if one is text and one is an element → replace (like when user adds/removes
or adds bold/italic)
- * - if a node got added or removed → do that in the old DOM
- *
- * We don’t recreate or touch existing elements unless absolutely needed,
- * so all original user-written attributes and tag structure stay exactly the same.
- *
- * 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");
-
- const oldRoot = oldDoc.body;
- const newRoot = newDoc.body;
-
- // this function is to remove the phoenix internal attributes from leaking into the user's source code
- function cleanClonedElement(clonedElement) {
- if (clonedElement.nodeType === Node.ELEMENT_NODE) {
- // this are phoenix's internal attributes
- const attrs = ["data-brackets-id", "data-ld-highlight"];
-
- // remove from the cloned element
- attrs.forEach(attr => clonedElement.removeAttribute(attr));
-
- // also remove from its childrens
- clonedElement.querySelectorAll(attrs.map(a => `[${a}]`).join(","))
- .forEach(el => attrs.forEach(attr => el.removeAttribute(attr)));
- }
- return clonedElement;
- }
-
- function syncText(oldNode, newNode) {
- if (!oldNode || !newNode) {
- return;
- }
-
- // when both are text nodes, we just need to replace the old text with the new one
- if (oldNode.nodeType === Node.TEXT_NODE && newNode.nodeType === Node.TEXT_NODE) {
- if (oldNode.nodeValue !== newNode.nodeValue) {
- oldNode.nodeValue = newNode.nodeValue;
- }
- return;
- }
-
- // when both are elements
- if (oldNode.nodeType === Node.ELEMENT_NODE && newNode.nodeType === Node.ELEMENT_NODE) {
- const oldChildren = Array.from(oldNode.childNodes);
- const newChildren = Array.from(newNode.childNodes);
-
- const maxLen = Math.max(oldChildren.length, newChildren.length);
-
- for (let i = 0; i < maxLen; i++) {
- const oldChild = oldChildren[i];
- const newChild = newChildren[i];
-
- if (!oldChild && newChild) {
- // if new child added → clone and insert
- const cloned = newChild.cloneNode(true);
- oldNode.appendChild(cleanClonedElement(cloned));
- } else if (oldChild && !newChild) {
- // if child removed → delete
- oldNode.removeChild(oldChild);
- } else if (
- oldChild.nodeType === newChild.nodeType &&
- oldChild.nodeType === Node.ELEMENT_NODE &&
- oldChild.tagName === newChild.tagName
- ) {
- // same element tag → sync recursively
- syncText(oldChild, newChild);
- } else if (
- oldChild.nodeType === Node.TEXT_NODE &&
- newChild.nodeType === Node.TEXT_NODE
- ) {
- if (oldChild.nodeValue !== newChild.nodeValue) {
- oldChild.nodeValue = newChild.nodeValue;
- }
- } else {
- // different node types or tags → replace
- const cloned = newChild.cloneNode(true);
- oldNode.replaceChild(cleanClonedElement(cloned), oldChild);
- }
- }
- }
- }
-
- const oldEls = Array.from(oldRoot.children);
- const newEls = Array.from(newRoot.children);
-
- for (let i = 0; i < Math.min(oldEls.length, newEls.length); i++) {
- syncText(oldEls[i], newEls[i]);
- }
-
- return oldRoot.innerHTML;
- }
-
- /**
- * helper function to get editor and validate basic requirements
- * @param {Number} tagId - the data-brackets-id of the element
- */
- function _getEditorAndValidate(tagId) {
- const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
- if (!currLiveDoc || !currLiveDoc.editor) {
- return null;
- }
- // for undo/redo operations, tagId might not be needed, so we only check it if provided
- if (tagId !== undefined && !tagId) {
- return null;
- }
- return currLiveDoc.editor;
- }
-
- /**
- * helper function to get element range from tagId
- *
- * @param {Object} editor - the editor instance
- * @param {Number} tagId - the data-brackets-id of the element
- * @returns {Object|null} - object with startPos and endPos, or null if not found
- */
- function _getElementRange(editor, tagId) {
- // get the start range from the getPositionFromTagId function
- // and we get the end range from the findMatchingTag function
- // NOTE: we cannot get the end range from getPositionFromTagId
- // because on non-beautified code getPositionFromTagId may not provide correct end position
- const startRange = HTMLInstrumentation.getPositionFromTagId(editor, tagId);
- if(!startRange) {
- return null;
- }
-
- const endRange = CodeMirror.findMatchingTag(editor._codeMirror, startRange.from);
- if (!endRange) {
- return null;
- }
-
- const startPos = startRange.from;
- // for empty tags endRange.close might not exist, for ex: img tag
- const endPos = endRange.close ? endRange.close.to : endRange.open.to;
-
- return { startPos, endPos };
- }
-
- /**
- * this function handles the text edit in the source code when user updates the text in the live preview
- *
- * @param {Object} message - the message object
- * - livePreviewEditEnabled: true
- * - livePreviewTextEdit: true
- * - element: element
- * - newContent: element.outerHTML (the edited content from live preview)
- * - tagId: Number (data-brackets-id of the edited element)
- * - isEditSuccessful: boolean (false when user pressed Escape to cancel, otherwise true always)
- */
- function _editTextInSource(message) {
- const editor = _getEditorAndValidate(message.tagId);
- if (!editor) {
- return;
- }
-
- const range = _getElementRange(editor, message.tagId);
- if (!range) {
- return;
- }
-
- const { startPos, endPos } = range;
-
- const text = editor.getTextBetween(startPos, endPos);
-
- // if the edit was cancelled (mainly by pressing Escape key)
- // we just replace the same text with itself
- // this is a quick trick because as the code is changed for that element in the file,
- // the live preview for that element gets refreshed and the changes are discarded in the live preview
- if(!message.isEditSuccessful) {
- editor.document.batchOperation(function () {
- editor.replaceRange(text, startPos, endPos);
- setTimeout(() => {
- editor.undo(); // undo the replaceRange so dirty icon won't appear and no net change in undo history
- }, 0);
- });
- } else {
-
- // if the edit operation was successful, we call a helper function that
- // is responsible to provide the actual content that needs to be written in the editor
- //
- // text: the actual current source code in the editor
- // message.newContent: the new content in the live preview after the edit operation
- const finalText = _syncTextContentChanges(text, message.newContent);
- editor.replaceRange(finalText, startPos, endPos);
- }
- }
-
- /**
- * This function is responsible to duplicate an element from the source code
- * @param {Number} tagId - the data-brackets-id of the DOM element
- */
- function _duplicateElementInSourceByTagId(tagId) {
- // this is to get the currently live document that is being served in the live preview
- const editor = _getEditorAndValidate(tagId);
- if (!editor) {
- return;
- }
-
- const range = _getElementRange(editor, tagId);
- if (!range) {
- return;
- }
-
- const { startPos, endPos } = range;
-
- // this is the actual source code for the element that we need to duplicate
- const text = editor.getTextBetween(startPos, endPos);
- // this is the indentation on the line
- const indent = editor.getTextBetween({ line: startPos.line, ch: 0 }, startPos);
-
- editor.document.batchOperation(function () {
- // make sure there is only indentation and no text before it
- if (indent.trim() === "") {
- // this is the position where we need to insert
- // we're giving the char as 0 because since we insert a new line using '\n'
- // that's why writing any char value will not work, as the line is emptys
- // and codemirror doesn't allow to insert at a column (ch) greater than the length of the line
- // So, the logic is to just append the indent before the text at this insertPos
- const insertPos = {
- line: startPos.line + (endPos.line - startPos.line + 1),
- ch: 0
- };
-
- editor.replaceRange("\n", endPos);
- editor.replaceRange(indent + text, insertPos);
- } else {
- // if there is some text, we just add the duplicated text right next to it
- editor.replaceRange(text, startPos);
- }
- });
- }
-
- /**
- * This function is responsible to delete an element from the source code
- * @param {Number} tagId - the data-brackets-id of the DOM element
- */
- function _deleteElementInSourceByTagId(tagId) {
- // this is to get the currently live document that is being served in the live preview
- const editor = _getEditorAndValidate(tagId);
- if (!editor) {
- return;
- }
-
- const range = _getElementRange(editor, tagId);
- if (!range) {
- return;
- }
-
- const { startPos, endPos } = range;
-
- editor.document.batchOperation(function () {
- editor.replaceRange("", startPos, endPos);
-
- // since we remove content from the source, we want to clear the extra 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);
- }
- });
- }
-
- /**
- * this function is to clean up the empty lines after an element is removed
- * @param {Object} editor - the editor instance
- * @param {Object} range - the range where element was removed
- */
- function _cleanupAfterRemoval(editor, range) {
- const lineToCheck = range.from.line;
-
- // check if the line where element was removed is now empty
- if (lineToCheck < editor.lineCount()) {
- const currentLineText = editor.getLine(lineToCheck);
- if (currentLineText && currentLineText.trim() === "") {
- // remove the empty line
- const lineStart = { line: lineToCheck, ch: 0 };
- const lineEnd = { line: lineToCheck + 1, ch: 0 };
- editor.replaceRange("", lineStart, lineEnd);
- }
- }
-
- // also we need to check the previous line if it became empty
- if (lineToCheck > 0) {
- const prevLineText = editor.getLine(lineToCheck - 1);
- if (prevLineText && prevLineText.trim() === "") {
- const lineStart = { line: lineToCheck - 1, ch: 0 };
- const lineEnd = { line: lineToCheck, ch: 0 };
- editor.replaceRange("", lineStart, lineEnd);
- }
- }
- }
-
- /**
- * this function is to make sure that we insert elements with proper indentation
- *
- * @param {Object} editor - the editor instance
- * @param {Object} insertPos - position where to insert
- * @param {Boolean} insertAfterMode - whether to insert after the position
- * @param {String} targetIndent - the indentation to use
- * @param {String} sourceText - the text to insert
- */
- function _insertElementWithIndentation(editor, insertPos, insertAfterMode, targetIndent, sourceText) {
- if (insertAfterMode) {
- // Insert after the target element
- editor.replaceRange("\n" + targetIndent + sourceText, insertPos);
- } else {
- // Insert before the target element
- const insertLine = insertPos.line;
- const lineStart = { line: insertLine, ch: 0 };
-
- // Get current line content to preserve any existing indentation structure
- const currentLine = editor.getLine(insertLine);
-
- if (currentLine && currentLine.trim() === "") {
- // the line is empty, replace it entirely
- editor.replaceRange(targetIndent + sourceText, lineStart, { line: insertLine, ch: currentLine.length });
- } else {
- // the line has content, insert before it
- editor.replaceRange(targetIndent + sourceText + "\n", lineStart);
- }
- }
- }
-
- /**
- * This function is to make sure that the target element doesn't lie completely within the source element
- * because if that is the case then it means that the drag-drop was not performed correctly
- *
- * @param {Object} source - start/end pos of the source element
- * @param {Object} target - start/end pos of the target element
- * @returns {Boolean} true if target is fully inside source, false otherwise
- */
- function _targetInsideSource(source, target) {
- if (
- (source.from.line < target.from.line ||
- (source.from.line === target.from.line && source.from.ch <= target.from.ch)) &&
- (source.to.line > target.to.line ||
- (source.to.line === target.to.line && source.to.ch >= target.to.ch))
- ) {
- return true;
- }
-
- return false;
- }
-
- /**
- * This function is responsible for moving an element from one position to another in the source code
- * it is called when there is drag-drop in the live preview
- * @param {Number} sourceId - the data-brackets-id of the element being moved
- * @param {Number} targetId - the data-brackets-id of the target element where to move
- * @param {Boolean} insertAfter - whether to insert the source element after the target element
- * @param {Boolean} insertInside - whether to insert the source element as a child of the target element
- */
- function _moveElementInSource(sourceId, targetId, insertAfter, insertInside = false) {
- // this is to get the currently live document that is being served in the live preview
- const editor = _getEditorAndValidate(sourceId);
- if (!editor || !targetId) {
- return;
- }
-
- const sourceRange = _getElementRange(editor, sourceId);
- if (!sourceRange) {
- return;
- }
-
- const targetRange = _getElementRange(editor, targetId);
- if (!targetRange) {
- return;
- }
-
- // convert to the format expected by the rest of the function
- const sourceRangeObj = {
- from: sourceRange.startPos,
- to: sourceRange.endPos
- };
-
- const targetRangeObj = {
- from: targetRange.startPos,
- to: targetRange.endPos
- };
-
- // make sure that the target is not within the source
- // this would otherwise remove both source and target, breaking the document
- if (_targetInsideSource(sourceRangeObj, targetRangeObj)) {
- return;
- }
-
- const sourceText = editor.getTextBetween(sourceRangeObj.from, sourceRangeObj.to);
- let targetIndent = editor.getTextBetween({ line: targetRangeObj.from.line, ch: 0 }, targetRangeObj.from);
- if(targetIndent && targetIndent.trim() !== "") { // because indentation should hold no text
- let indentLength = targetIndent.search(/\S/);
- if (indentLength === -1) {
- indentLength = targetIndent.length;
- }
- targetIndent = ' '.repeat(indentLength);
- }
-
- // Check if source is before target to determine order of operations
- // check if the source is before target or after the target
- // we need this because
- // If source is before target → we need to insert first, then remove
- // If target is before source → remove first, then insert
- const sourceBeforeTarget =
- sourceRangeObj.from.line < targetRangeObj.from.line ||
- (sourceRangeObj.from.line === targetRangeObj.from.line && sourceRangeObj.from.ch < targetRangeObj.from.ch);
-
- // creating a batch operation so that undo in live preview works fine
- editor.document.batchOperation(function () {
- if (sourceBeforeTarget) {
- // this handles the case when source is before target: insert first, then remove
- if (insertInside) {
- const matchingTagInfo = CodeMirror.findMatchingTag(editor._codeMirror, targetRangeObj.from);
- if (matchingTagInfo && matchingTagInfo.open) {
- const insertPos = {
- line: matchingTagInfo.open.to.line,
- ch: matchingTagInfo.open.to.ch
- };
-
- const indentInfo = editor._detectIndent();
- const childIndent = targetIndent + indentInfo.indent;
- _insertElementWithIndentation(editor, insertPos, true, childIndent, sourceText);
- }
- } else if (insertAfter) {
- const insertPos = {
- line: targetRangeObj.to.line,
- ch: targetRangeObj.to.ch
- };
- _insertElementWithIndentation(editor, insertPos, true, targetIndent, sourceText);
- } else {
- // insert before target
- _insertElementWithIndentation(editor, targetRangeObj.from, false, targetIndent, sourceText);
- }
-
- // Now remove the source element (NOTE: the positions have shifted)
- const updatedSourceRange = _getElementRange(editor, sourceId);
- if (updatedSourceRange) {
- const updatedSourceRangeObj = {
- from: updatedSourceRange.startPos,
- to: updatedSourceRange.endPos
- };
- editor.replaceRange("", updatedSourceRangeObj.from, updatedSourceRangeObj.to);
- _cleanupAfterRemoval(editor, updatedSourceRangeObj);
- }
- } else {
- // This handles the case when target is before source: remove first, then insert
- // Store source range before removal
- const originalSourceRange = { ...sourceRangeObj };
-
- // Remove the source element first
- editor.replaceRange("", sourceRangeObj.from, sourceRangeObj.to);
- _cleanupAfterRemoval(editor, originalSourceRange);
-
- // Recalculate target range after source removal as the positions have shifted
- const updatedTargetRange = _getElementRange(editor, targetId);
- if (!updatedTargetRange) {
- return;
- }
-
- const updatedTargetRangeObj = {
- from: updatedTargetRange.startPos,
- to: updatedTargetRange.endPos
- };
-
- if (insertInside) {
- const matchingTagInfo = CodeMirror.findMatchingTag(editor._codeMirror, updatedTargetRangeObj.from);
- if (matchingTagInfo && matchingTagInfo.open) {
- const insertPos = {
- line: matchingTagInfo.open.to.line,
- ch: matchingTagInfo.open.to.ch
- };
-
- const indentInfo = editor._detectIndent();
- const childIndent = targetIndent + indentInfo.indent;
- _insertElementWithIndentation(editor, insertPos, true, childIndent, sourceText);
- }
- } else if (insertAfter) {
- const insertPos = {
- line: updatedTargetRangeObj.to.line,
- ch: updatedTargetRangeObj.to.ch
- };
- _insertElementWithIndentation(editor, insertPos, true, targetIndent, sourceText);
- } else {
- // Insert before target
- _insertElementWithIndentation(editor, updatedTargetRangeObj.from, false, targetIndent, sourceText);
- }
- }
- });
- }
-
- /**
- * This function is to handle the undo redo operation in the live preview
- * @param {String} undoOrRedo - "undo" when to undo, and "redo" for redo
- */
- function handleUndoRedoOperation(undoOrRedo) {
- const editor = _getEditorAndValidate(); // no tagId needed for undo/redo
- if (!editor) {
- return;
- }
-
- if (undoOrRedo === "undo") {
- editor.undo();
- } else if (undoOrRedo === "redo") {
- editor.redo();
- }
- }
-
- function _getRequiredDataForAI(message) {
- // this is to get the currently live document that is being served in the live preview
- const editor = _getEditorAndValidate(message.tagId);
- if (!editor) {
- return;
- }
-
- const range = _getElementRange(editor, message.tagId);
- if (!range) {
- return;
- }
-
- const { startPos, endPos } = range;
- // this is the actual source code for the element that we need to duplicate
- const text = editor.getTextBetween(startPos, endPos);
- const fileName = editor.document.file.name;
- const filePath = editor.document.file.fullPath;
-
- const AIData = {
- editor: editor, // the editor instance that is being served in the live preview
- fileName: fileName,
- filePath: filePath, // the complete absolute path
- tagId: message.tagId, // the data-brackets-id of the element which was selected for AI edit
- range: {startPos, endPos}, // the start and end position text in the source code for that element
- text: text, // the actual source code in between the start and the end pos
- prompt: message.prompt, // the prompt that user typed
- model: message.selectedModel // the selected model (fast, slow or moderate)
- };
-
- return AIData;
- }
-
- async function _editWithAI(message) {
- const AIData = _getRequiredDataForAI(message);
- const aiEntitlement = await KernalModeTrust.EntitlementsManager.getAIEntitlement();
- if (!aiEntitlement.activated) {
- // Ai is not activated for user(not logged in/no ai plan/disabled by system admin)
- // the showAIUpsellDialog will show an appropriate message for each case.
- ProDialogs.showAIUpsellDialog(aiEntitlement);
- return;
- }
- // todo @abose ai wire in
- console.log(AIData);
- }
-
- /**
- * this is a helper function to make sure that when saving a new image, there's no existing file with the same name
- * @param {String} basePath - this is the base path where the image will be saved
- * @param {String} filename - the name of the image file
- * @param {String} extnName - the name of the image extension. (defaults to "jpg")
- * @returns {String} - the new file name
- */
- function getUniqueFilename(basePath, filename, extnName) {
- let counter = 0;
- let uniqueFilename = filename + extnName;
-
- function checkAndIncrement() {
- const filePath = basePath + uniqueFilename;
- const file = FileSystem.getFileForPath(filePath);
-
- return new Promise((resolve) => {
- file.exists((err, exists) => {
- if (exists) {
- counter++;
- uniqueFilename = `${filename}-${counter}${extnName}`;
- checkAndIncrement().then(resolve);
- } else {
- resolve(uniqueFilename);
- }
- });
- });
- }
-
- return checkAndIncrement();
- }
-
- /**
- * This function updates the src attribute of an image element in the source code
- * @param {Number} tagId - the data-brackets-id of the image element
- * @param {String} newSrcValue - the new src value to set
- */
- function _updateImageSrcAttribute(tagId, newSrcValue) {
- 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 src attribute
- const parser = new DOMParser();
- const doc = parser.parseFromString(elementText, "text/html");
- const imgElement = doc.querySelector('img');
-
- if (imgElement) {
- imgElement.setAttribute('src', newSrcValue);
- const updatedElementText = imgElement.outerHTML;
-
- editor.document.batchOperation(function () {
- editor.replaceRange(updatedElementText, startPos, endPos);
- });
- }
- }
-
- function _sendDownloadStatusToBrowser(eventType, data) {
- const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
- if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) {
- const dataJson = JSON.stringify(data || {});
- const evalString = `_LD.handleDownloadEvent('${eventType}', ${dataJson})`;
- currLiveDoc.protocol.evaluate(evalString);
- }
- }
-
- function _handleDownloadError(error, downloadId) {
- console.error('something went wrong while download the image. error:', error);
- if (downloadId) {
- _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.ERROR, { downloadId: downloadId });
- }
- }
-
- function _trackDownload(downloadLocation) {
- if (!downloadLocation) {
- return;
- }
- fetch(`https://images.phcode.dev/api/images/download?download_location=${encodeURIComponent(downloadLocation)}`)
- .catch(error => {
- console.error('download tracking failed:', error);
- });
- }
-
- /**
- * Helper function to update image src attribute and dismiss ribbon gallery
- *
- * @param {Number} tagId - the data-brackets-id of the image element
- * @param {String} targetPath - the full path where the image was saved
- * @param {String} filename - the filename of the saved image
- */
- function _updateImageAndDismissRibbon(tagId, targetPath, filename) {
- const editor = _getEditorAndValidate(tagId);
- if (editor) {
- const htmlFilePath = editor.document.file.fullPath;
- const relativePath = PathUtils.makePathRelative(targetPath, htmlFilePath);
- _updateImageSrcAttribute(tagId, relativePath);
- } else {
- _updateImageSrcAttribute(tagId, filename);
- }
-
- // 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()");
- }
- }
-
- /**
- * helper function to handle 'upload from computer'
- * @param {Object} message - the message object
- * @param {String} filename - the file name with which we need to save the image
- * @param {Directory} projectRoot - the project root in which the image is to be saved
- */
- function _handleUseThisImageLocalFiles(message, filename, projectRoot) {
- const { tagId, imageData, downloadLocation, downloadId } = message;
-
- const uint8Array = new Uint8Array(imageData);
- const targetPath = projectRoot.fullPath + filename;
-
- window.fs.writeFile(targetPath, window.Filer.Buffer.from(uint8Array),
- { encoding: window.fs.BYTE_ARRAY_ENCODING }, (err) => {
- if (err) {
- _handleDownloadError(err, downloadId);
- } else {
- _trackDownload(downloadLocation);
- _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.COMPLETED, { downloadId });
- _updateImageAndDismissRibbon(tagId, targetPath, filename);
- }
- });
- }
-
- /**
- * helper function to handle 'use this image' button click on remote images
- * @param {Object} message - the message object
- * @param {String} filename - the file name with which we need to save the image
- * @param {Directory} projectRoot - the project root in which the image is to be saved
- */
- function _handleUseThisImageRemote(message, filename, projectRoot) {
- const { imageUrl, tagId, downloadLocation, downloadId } = message;
-
- fetch(imageUrl)
- .then(response => {
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- return response.arrayBuffer();
- })
- .then(arrayBuffer => {
- const uint8Array = new Uint8Array(arrayBuffer);
- const targetPath = projectRoot.fullPath + filename;
-
- window.fs.writeFile(targetPath, window.Filer.Buffer.from(uint8Array),
- { encoding: window.fs.BYTE_ARRAY_ENCODING }, (err) => {
- if (err) {
- _handleDownloadError(err, downloadId);
- } else {
- _trackDownload(downloadLocation);
- _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.COMPLETED, { downloadId });
- _updateImageAndDismissRibbon(tagId, targetPath, filename);
- }
- });
- })
- .catch(error => {
- _handleDownloadError(error, downloadId);
- });
- }
-
- /**
- * Downloads image to the specified folder
- * @private
- * @param {Object} message - The message containing image download info
- * @param {string} folderPath - Relative path to the folder
- */
- function _downloadToFolder(message, folderPath) {
- const projectRoot = ProjectManager.getProjectRoot();
- if (!projectRoot) {
- console.error('No project root found');
- return;
- }
-
- if (message.downloadId) {
- _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.STARTED, { downloadId: message.downloadId });
- }
-
- const filename = message.filename;
- const extnName = message.extnName || "jpg";
-
- // the folder path should always end with /
- if (!folderPath.endsWith('/')) {
- folderPath += '/';
- }
-
- const targetPath = projectRoot.fullPath + folderPath;
- const targetDir = FileSystem.getDirectoryForPath(targetPath);
-
- // the directory name that user wrote, first check if it exists or not
- // if it doesn't exist we create it and then download the image inside it
- targetDir.exists((err, exists) => {
- if (err) {
- _handleDownloadError(err, message.downloadId);
- return;
- }
-
- if (!exists) {
- targetDir.create((err) => {
- if (err) {
- _handleDownloadError(err, message.downloadId);
- return;
- }
- _downloadImageToDirectory(message, filename, extnName, targetDir);
- });
- } else {
- _downloadImageToDirectory(message, filename, extnName, targetDir);
- }
- });
- }
-
- /**
- * This function is to determine whether we need to exclude a folder from the suggestions list
- * so we exclude all the folders that start with . 'dot' as this are generally irrelevant dirs
- * secondly, we also exclude large dirs like node modules as they might freeze the UI if we scan them
- * @param {String} folderName - the folder name to check if we need to exclude it or not
- * @returns {Boolean} - true if we should exclude otherwise false
- */
- function _isExcludedFolder(folderName) {
- if (folderName.startsWith('.')) { return true; }
-
- const UNNECESSARY_FOLDERS = ['node_modules', 'bower_components'];
- if (UNNECESSARY_FOLDERS.includes(folderName)) { return true; }
-
- return false;
- }
-
- /**
- * this function scans all the root directories
- * root directories means those directories that are directly inside the project folder
- * we need this to show when the query is empty
- *
- * @param {Directory} directory - project root directory
- * @param {Array} folderList - array to store discovered root folder paths
- * @return {Promise} Resolves when root scan is complete
- */
- function _scanRootDirectoriesOnly(directory, folderList) {
- return new Promise((resolve) => {
- directory.getContents((err, contents) => {
- if (err) {
- resolve();
- return;
- }
-
- const directories = contents.filter(entry => entry.isDirectory);
-
- directories.forEach(dir => {
- if (_isExcludedFolder(dir.name)) { return; }
- // add root folder name with trailing slash
- folderList.push(dir.name + '/');
- });
- resolve();
- });
- });
- }
-
- /**
- * this function scans all the directories recursively
- * and then add the relative paths of the directories to the folderList array
- *
- * @param {Directory} directory - The parent directory to scan
- * @param {string} relativePath - The relative path from project root
- * @param {Array} folderList - Array to store all discovered folder paths
- * @return {Promise} Resolves when scanning is complete
- */
- function _scanDirectories(directory, relativePath, folderList) {
- return new Promise((resolve) => {
- directory.getContents((err, contents) => {
- if (err) {
- resolve();
- return;
- }
-
- const directories = contents.filter(entry => entry.isDirectory);
- const scanPromises = [];
-
- directories.forEach(dir => {
- if (_isExcludedFolder(dir.name)) { return; }
-
- const dirRelativePath = relativePath ? `${relativePath}${dir.name}/` : `${dir.name}/`;
- folderList.push(dirRelativePath);
-
- // also check subdirectories for this dir
- scanPromises.push(_scanDirectories(dir, dirRelativePath, folderList));
- });
-
- Promise.all(scanPromises).then(() => resolve());
- });
- });
- }
-
- /**
- * this function is responsible to get the subdirectories inside a directory
- * we need this because we need to show the drilled down folders...
- * @param {String} parentPath - Parent folder path (e.g., "images/")
- * @param {Array} folderList - Complete list of all folder paths
- * @return {Array} Array of direct subfolders only
- */
- function _getSubfolders(parentPath, folderList) {
- return folderList.filter(folder => {
- if (!folder.startsWith(parentPath)) { return false; }
-
- const relativePath = folder.substring(parentPath.length);
- const pathWithoutTrailingSlash = relativePath.replace(/\/$/, '');
- return !pathWithoutTrailingSlash.includes('/');
- });
- }
-
- /**
- * Renders folder suggestions as a dropdown in the UI with fuzzy match highlighting
- *
- * @param {Array} matches - Array of folder paths (strings) or fuzzy match objects with stringRanges
- * @param {JQuery} $suggestions - jQuery element for the suggestions container
- * @param {JQuery} $input - jQuery element for the input field
- */
- function _renderFolderSuggestions(matches, $suggestions, $input) {
- if (matches.length === 0) {
- $suggestions.empty();
- return;
- }
-
- let html = '';
- matches.forEach((match, index) => {
- let displayHTML = '';
- let folderPath = '';
-
- // Check if match is a string or an object
- if (typeof match === 'string') {
- // Simple string (from empty query showing folders)
- displayHTML = match;
- folderPath = match;
- } else if (match && match.stringRanges) {
- // fuzzy match, highlight matched chars
- match.stringRanges.forEach(range => {
- if (range.matched) {
- displayHTML += `${range.text}`;
- } else {
- displayHTML += range.text;
- }
- });
- folderPath = match.label || '';
- }
-
- // first item should be selected by default
- const selectedClass = index === 0 ? ' selected' : '';
- html += `- ${displayHTML}
`;
- });
- html += '
';
-
- $suggestions.html(html);
- $suggestions.scrollTop(0); // always need to scroll to top when query changes
-
- // when a suggestion is clicked we add the folder path in the input box
- $suggestions.find('.folder-suggestion-item').on('click', function() {
- const folderPath = $(this).data('path');
- $input.val(folderPath).trigger('input');
- });
- }
-
- /**
- * This function is responsible to update the folder suggestion everytime a new char is inserted in the input field
- *
- * @param {string} query - The search query from the input field
- * @param {Array} folderList - List of all available folder paths
- * @param {Array} rootFolders - list of root-level folder paths
- * @param {StringMatch.StringMatcher} stringMatcher - StringMatcher instance for fuzzy matching
- * @param {JQuery} $suggestions - jQuery element for the suggestions container
- * @param {JQuery} $input - jQuery element for the input field
- */
- function _updateFolderSuggestions(query, folderList, rootFolders, stringMatcher, $suggestions, $input) {
- if (!query || query.trim() === '') {
- // when input is empty we show the root folders
- _renderFolderSuggestions(rootFolders.slice(0, 15), $suggestions, $input);
- return;
- }
-
- // if the query ends with a /
- // we then show the drilled down list of dirs inside that parent directory
- if (query.endsWith('/')) {
- const subfolders = _getSubfolders(query, folderList);
- const formattedSubfolders = subfolders.map(folder => {
- return stringMatcher.match(folder, query) || { label: folder, stringRanges: [{ text: folder, matched: false }] };
- });
-
- _renderFolderSuggestions(formattedSubfolders.slice(0, 15), $suggestions, $input);
- return;
- }
-
- if (!stringMatcher) { return; }
-
- // filter folders using fuzzy matching
- const matches = folderList
- .map(folder => {
- const result = stringMatcher.match(folder, query);
- if (result) {
- // get the last folder name (e.g., "assets/images/" -> "images")
- const folderPath = result.label || folder;
- const segments = folderPath.split('/').filter(s => s.length > 0);
- const lastSegment = segments[segments.length - 1] || '';
- result.folderName = lastSegment.toLowerCase();
-
- // we need to boost the score significantly if the last folder segment starts with the query
- // This ensures folders like "images/" rank higher than "testing/maps/google/" when typing "image"
- // note: here testing/maps/google has all the chars of 'image'
- if (lastSegment.toLowerCase().startsWith(query.toLowerCase())) {
- // Use a large positive boost (matchGoodness is negative, so we subtract a large negative number)
- result.matchGoodness -= 10000;
- }
- // Also boost (but less) if the last segment contains the query as a substring
- else if (lastSegment.toLowerCase().includes(query.toLowerCase())) {
- result.matchGoodness -= 1000;
- }
- }
- return result;
- })
- .filter(result => result !== null && result !== undefined);
-
- // Sort by matchGoodness first (prefix matches will have best scores),
- // then alphabetically by folder name, then by full path
- StringMatch.multiFieldSort(matches, { matchGoodness: 0, folderName: 1, label: 2 });
-
- const topMatches = matches.slice(0, 15);
- _renderFolderSuggestions(topMatches, $suggestions, $input);
- }
-
- /**
- * register the input box handlers (folder selection dialog)
- * also registers the 'arrow up/down and enter' key handler for folder selection and move the selected folder,
- * in the list of suggestions
- *
- * @param {JQuery} $input - the input box element
- * @param {JQuery} $suggestions - the suggestions list element
- * @param {JQuery} $dlg - the dialog box element
- */
- function _registerFolderDialogInputHandlers($input, $suggestions, $dlg) {
- // keyboard navigation handler for arrow keys
- $input.on('keydown', function(e) {
- const isArrowDown = e.keyCode === 40;
- const isArrowUp = e.keyCode === 38;
- // we only want to handle the arrow up arrow down keys
- if (!isArrowDown && !isArrowUp) { return; }
-
- e.preventDefault();
- const $items = $suggestions.find('.folder-suggestion-item');
- if ($items.length === 0) { return; }
-
- const $selected = $items.filter('.selected');
-
- // determine which item to select next
- let $nextItem;
- if ($selected.length === 0) {
- // no selection - select first or last based on direction
- $nextItem = isArrowDown ? $items.first() : $items.last();
- } else {
- // move selection
- const currentIndex = $items.index($selected);
- $selected.removeClass('selected');
- const nextIndex = isArrowDown
- ? (currentIndex + 1) % $items.length
- : (currentIndex - 1 + $items.length) % $items.length;
- $nextItem = $items.eq(nextIndex);
- }
-
- // apply selection and scroll the selected item into view (if not in view)
- $nextItem.addClass('selected');
- if ($nextItem.length > 0) {
- $nextItem[0].scrollIntoView({ block: "nearest", behavior: "auto" });
- }
- });
-
- // for enter key, we're using keyup handler because keydown was interfering with dialog's default behaviour
- // when enter key is pressed, we check if there are any selected folders in the suggestions
- // if yes, we type the folder path in the input box,
- // if no, we click the ok button of the dialog
- $input.on('keyup', function(e) {
- if (e.keyCode === 13) { // enter key
- const $items = $suggestions.find('.folder-suggestion-item');
- const $selected = $items.filter('.selected');
-
- // if there's a selected suggestion, use it
- if ($selected.length > 0) {
- const folderPath = $selected.data('path');
- $input.val(folderPath).trigger('input');
- } else {
- // no suggestions, trigger OK button click
- $dlg.find('[data-button-id="ok"]').click();
- }
- }
- });
- }
-
- /**
- * this shows the folder selection dialog for choosing where to download images
- * @param {Object} message - the message object (optional, only needed when downloading image)
- * @private
- */
- function _showFolderSelectionDialog(message) {
- const projectRoot = ProjectManager.getProjectRoot();
- if (!projectRoot) { return; }
-
- // show the dialog with a text box to select a folder
- // dialog html is written in 'image-folder-dialog.html'
- const templateVars = {
- Strings: Strings
- };
- const dialog = Dialogs.showModalDialogUsingTemplate(Mustache.render(ImageFolderDialogTemplate, templateVars), false);
- const $dlg = dialog.getElement();
- const $input = $dlg.find("#folder-path-input");
- const $suggestions = $dlg.find("#folder-suggestions");
- const $rememberCheckbox = $dlg.find("#remember-folder-checkbox");
-
- let folderList = [];
- let rootFolders = [];
- let stringMatcher = null;
-
- const persistFolder = StateManager.get(IMAGE_DOWNLOAD_PERSIST_FOLDER_KEY, StateManager.PROJECT_CONTEXT);
- 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);
- });
-
- _scanDirectories(projectRoot, '', folderList);
-
- // input event handler
- $input.on('input', function() {
- _updateFolderSuggestions($input.val(), folderList, rootFolders, stringMatcher, $suggestions, $input);
- });
- _registerFolderDialogInputHandlers($input, $suggestions, $dlg);
- // focus the input box
- setTimeout(function() {
- $input.focus();
- }, 100);
-
- // handle dialog button clicks
- // so the logic is either its an ok button click or cancel button click, so if its ok click
- // then we download image in that folder and close the dialog, in close btn click we directly close the dialog
- $dlg.one("buttonClick", function(e, buttonId) {
- if (buttonId === Dialogs.DIALOG_BTN_OK) {
- const folderPath = $input.val().trim();
-
- // if the checkbox is checked, we save the folder preference for this project
- const isChecked = $rememberCheckbox.is(':checked');
- StateManager.set(IMAGE_DOWNLOAD_PERSIST_FOLDER_KEY, isChecked, StateManager.PROJECT_CONTEXT);
- if (isChecked) {
- StateManager.set(IMAGE_DOWNLOAD_FOLDER_KEY, folderPath, StateManager.PROJECT_CONTEXT);
- } else {
- StateManager.set(IMAGE_DOWNLOAD_FOLDER_KEY, undefined, StateManager.PROJECT_CONTEXT);
- }
-
- // if message is provided, download the image
- if (message) {
- _downloadToFolder(message, folderPath);
- }
- } else {
- if (message && message.downloadId) {
- _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.CANCELLED, { downloadId: message.downloadId });
- }
- }
- dialog.close();
- });
- }
-
- /**
- * This function is called when 'use this image' button is clicked in the image ribbon gallery
- * or user loads an image file from the computer
- * this is responsible to download the image in the appropriate place
- * and also change the src attribute of the element (by calling appropriate helper functions)
- *
- * @param {Object} message - the message object which stores all the required data for this operation
- */
- function _handleUseThisImage(message) {
- const projectRoot = ProjectManager.getProjectRoot();
- if (!projectRoot) { return; }
-
- // check if user has already saved a folder preference for this project
- const savedFolder = StateManager.get(IMAGE_DOWNLOAD_FOLDER_KEY, StateManager.PROJECT_CONTEXT);
- // we specifically check for nullish type vals because empty string is possible as it means project root
- if (savedFolder !== null && savedFolder !== undefined) {
- _downloadToFolder(message, savedFolder);
- } else {
- // show the folder selection dialog
- _showFolderSelectionDialog(message);
- }
- }
-
- /**
- * Helper function to download image to the specified directory
- *
- * @param {Object} message - Message containing image download info
- * @param {string} filename - Name of the image file
- * @param {string} extnName - File extension (e.g., "jpg")
- * @param {Directory} targetDir - Target directory to save the image
- */
- function _downloadImageToDirectory(message, filename, extnName, targetDir) {
- getUniqueFilename(targetDir.fullPath, filename, extnName).then((uniqueFilename) => {
- // check if the image is loaded from computer or from remote
- if (message.isLocalFile && message.imageData) {
- _handleUseThisImageLocalFiles(message, uniqueFilename, targetDir);
- } else {
- _handleUseThisImageRemote(message, uniqueFilename, targetDir);
- }
- }).catch(error => {
- _handleDownloadError(error, message.downloadId);
- });
- }
-
- /**
- * 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
- */
- function _handleLivePreviewSave() {
- // this saves the active file
- CommandManager.execute(Commands.FILE_SAVE);
-
- // we also save the previewed file, (active file might be same as previewed or different)
- const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc();
- if (currLiveDoc && currLiveDoc.editor) {
- const previewedDoc = currLiveDoc.editor.document;
- CommandManager.execute(Commands.FILE_SAVE, { doc: previewedDoc });
- }
- }
-
- /**
- * This function is responsible to toggle the live preview Preview mode (play icon)
- * this is done when user presses F8 key in the live preview
- */
- function _handlePreviewModeToggle() {
- const $previewBtn = $("#previewModeLivePreviewButton");
- if ($previewBtn.length > 0) {
- $previewBtn.trigger("click");
- }
- }
-
- /**
- * This is the main function that is exported.
- * it will be called by LiveDevProtocol when it receives a message from RemoteFunctions.js
- * or LiveDevProtocolRemote.js (for undo) using MessageBroker
- * Refer to: `handleOptionClick` function in the RemoteFunctions.js and `_receive` function in LiveDevProtocol.js
- *
- * @param {Object} message - this is the object that is passed by RemoteFunctions.js using MessageBroker
- * this object will be in the format
- * {
- livePreviewEditEnabled: true,
- tagId: tagId,
- delete || duplicate || livePreviewTextEdit || AISend: true
- undoLivePreviewOperation: true (this property is available only for undo operation)
-
- prompt: prompt (only for AI)
-
- sourceId: sourceId, (these are for move (drag & drop))
- targetId: targetId,
- insertAfter: boolean, (whether to insert after the target element)
- move: true
- }
- * these are the main properties that are passed through the message
- */
- function handleLivePreviewEditOperation(message) {
- // handle save current document in live preview (ctrl/cmd + s)
- if (message.saveCurrentDocument) {
- _handleLivePreviewSave();
- return;
- }
-
- // toggle live preview mode using F8 key
- if (message.toggleLivePreviewMode) {
- _handlePreviewModeToggle();
- return;
- }
-
- // handle reset image folder selection
- if (message.resetImageFolderSelection) {
- _handleResetImageFolderSelection();
- return;
- }
-
- // handle image gallery state change message
- if (message.type === "imageGalleryStateChange") {
- LiveDevelopment.setImageGalleryState(message.selected);
- return;
- }
-
- // handle move(drag & drop)
- if (message.move && message.sourceId && message.targetId) {
- _moveElementInSource(message.sourceId, message.targetId, message.insertAfter, message.insertInside);
- return;
- }
-
- // use this image
- if (message.useImage && message.imageUrl && message.filename) {
- _handleUseThisImage(message);
- return;
- }
-
- if (!message.element || !message.tagId) {
- // check for undo
- if (message.undoLivePreviewOperation || message.redoLivePreviewOperation) {
- message.undoLivePreviewOperation ? handleUndoRedoOperation("undo") : handleUndoRedoOperation("redo");
- }
- return;
- }
-
- // just call the required functions
- if (message.delete) {
- _deleteElementInSourceByTagId(message.tagId);
- } else if (message.duplicate) {
- _duplicateElementInSourceByTagId(message.tagId);
- } else if (message.livePreviewTextEdit) {
- _editTextInSource(message);
- } else if (message.AISend) {
- _editWithAI(message);
- }
- }
-
- exports.handleLivePreviewEditOperation = handleLivePreviewEditOperation;
-});
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/documents/LiveHTMLDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js
index 8d5b06138d..16730b2d7d 100644
--- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js
+++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js
@@ -33,8 +33,8 @@ define(function (require, exports, module) {
PerfUtils = require("utils/PerfUtils"),
_ = require("thirdparty/lodash"),
LiveDocument = require("LiveDevelopment/MultiBrowserImpl/documents/LiveDocument"),
- HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation");
-
+ HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"),
+ CSSUtils = require("language/CSSUtils");
/**
* @constructor
@@ -156,7 +156,28 @@ define(function (require, exports, module) {
return;
}
var editor = this.editor,
- ids = [];
+ mode = editor.getModeForSelection(),
+ ids = [],
+ selectors = [];
+
+ // check if the cursor is in a stylesheet context (internal styles)
+ if (mode === "css" || mode === "text/x-scss" || mode === "text/x-less") {
+ // find the css selector
+ _.each(this.editor.getSelections(), function (sel) {
+ let selector = CSSUtils.findSelectorAtDocumentPos(editor, (sel.reversed ? sel.end : sel.start));
+ if (selector) {
+ selectors.push(selector);
+ }
+ });
+
+ if (selectors.length) {
+ // to highlight the elements that match the css selectors
+ this.highlightRule(selectors.join(","));
+ return;
+ }
+ }
+
+ // its not found in css context, then it must be a inline style or a normal html element
_.each(this.editor.getSelections(), function (sel) {
var tagID = HTMLInstrumentation._getTagIDAtDocumentPos(
editor,
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..706c4111f7 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,31 @@ 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;
+ let isPaidUser = 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 +86,26 @@ 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
+ 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
- // 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
- }
+ isPaidUser: false // will be updated when we fetch entitlements
};
+
// 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 +220,40 @@ 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");
+ 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);
+ }
}
}
- // 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();
- }
- } catch (error) {
- console.error("Error updating pro user status:", error);
- isProUser = false;
- isFreeTrialUser = false;
+ function _isPaidUserChanged(newStatus) {
+ if(newStatus !== isPaidUser){
+ isPaidUser = newStatus;
+ const config = MultiBrowserLiveDev.getConfig();
+ config.isPaidUser = isPaidUser;
+ MultiBrowserLiveDev.updateConfig(config);
}
}
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 +261,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 = Object.assign({}, defaultConfig, MultiBrowserLiveDev.getConfig());
+ 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 +292,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 +304,63 @@ 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;
+ exports._isPaidUserChanged = _isPaidUserChanged;
// 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/help/HelpCommandHandlers.js b/src/help/HelpCommandHandlers.js
index 3286f8abb5..41b5012593 100644
--- a/src/help/HelpCommandHandlers.js
+++ b/src/help/HelpCommandHandlers.js
@@ -72,6 +72,11 @@ define(function (require, exports, module) {
Strings: Strings
};
+ // If we have phoenix pro optional features built in
+ if (Phoenix.pro.commitID) {
+ templateVars.PRO_BUILD_COMMIT = window.Phoenix.pro.commitID;
+ }
+
Dialogs.showModalDialogUsingTemplate(Mustache.render(AboutDialogTemplate, templateVars));
// Get containers
diff --git a/src/htmlContent/about-dialog.html b/src/htmlContent/about-dialog.html
index cac9afe93c..81f495c252 100644
--- a/src/htmlContent/about-dialog.html
+++ b/src/htmlContent/about-dialog.html
@@ -8,6 +8,9 @@ {{Strings.ABOUT}}
{{APP_NAME_ABOUT_BOX}}
-
- {{Strings.LIVE_DEV_IMAGE_FOLDER_DIALOG_TITLE}}
-
-
- {{Strings.LIVE_DEV_IMAGE_FOLDER_DIALOG_DESCRIPTION}}
-
-
-
-
-
-
- {{Strings.LIVE_DEV_IMAGE_FOLDER_DIALOG_HELP}}
-
-
-
-
-
-
-
-
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js
index a930263878..b29f1e554b 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",
@@ -693,6 +706,7 @@ define({
"CLOSE": "Close",
"ABOUT_TEXT_LINE1": "Release {VERSION_MAJOR}.{VERSION_MINOR} {BUILD_TYPE} {VERSION}",
"ABOUT_TEXT_BUILD_TIMESTAMP": "build timestamp: ",
+ "ABOUT_TEXT_PRO_BUILD": "Phoenix Pro Build: ",
"ABOUT_RELEASE_CREDITS": "Release Credits:",
"ABOUT_TEXT_LINE3": "Third Party Libraries that we use - Licences and Attributions . ",
"ABOUT_TEXT_LINE4": "Documentation and source at https://github.com/phcode-dev/phoenix/",
@@ -1132,7 +1146,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..bbcc4bb864
--- /dev/null
+++ b/tracking-repos.json
@@ -0,0 +1,5 @@
+{
+ "phoenixPro": {
+ "commitID": "d009de2b52cadd8fcb0256262635ccb453f6a631"
+ }
+}