diff --git a/.github/workflows/desktop-mac-m1-test-pull.yml b/.github/workflows/desktop-mac-m1-test-pull.yml index c3b482d548..4e423df797 100644 --- a/.github/workflows/desktop-mac-m1-test-pull.yml +++ b/.github/workflows/desktop-mac-m1-test-pull.yml @@ -9,7 +9,7 @@ concurrency: jobs: test-desktop-mac-m1: - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 90 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/desktop-mac-test-pull.yml b/.github/workflows/desktop-mac-test-pull.yml index 22e7e2f475..f8cfb99eb6 100644 --- a/.github/workflows/desktop-mac-test-pull.yml +++ b/.github/workflows/desktop-mac-test-pull.yml @@ -9,7 +9,7 @@ concurrency: jobs: test-desktop-mac: - runs-on: macos-13 + runs-on: macos-15-intel timeout-minutes: 90 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/playwright-staging-prod.yml b/.github/workflows/playwright-staging-prod.yml index e8e10a76e5..c5f6252396 100644 --- a/.github/workflows/playwright-staging-prod.yml +++ b/.github/workflows/playwright-staging-prod.yml @@ -178,7 +178,7 @@ jobs: playwrightChromiumMacos: timeout-minutes: 90 - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v3 - name: setup node @@ -434,7 +434,7 @@ jobs: playwrightFirefoxMacos: timeout-minutes: 90 - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v3 - name: setup node diff --git a/.gitignore b/.gitignore index da9a874b66..e56b83ed73 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ Thumbs.db /npm-debug.log /src/cacheManifest.json /src/appConfig.js +/src/extensionsIntegrated/pro-loader.js +/test/pro-test-suite.js # ignore node_modules inside src /src/node_modules diff --git a/docs/API-Reference/command/Commands.md b/docs/API-Reference/command/Commands.md index ebbe6bc829..1e6cf83238 100644 --- a/docs/API-Reference/command/Commands.md +++ b/docs/API-Reference/command/Commands.md @@ -158,12 +158,6 @@ Toggles live preview multi-browser mode ## CMD\_RELOAD\_LIVE\_PREVIEW Reloads live preview -**Kind**: global variable - - -## FILE\_LIVE\_HIGHLIGHT -Toggles live highlight - **Kind**: global variable diff --git a/docs/API-Reference/utils/ExtensionLoader.md b/docs/API-Reference/utils/ExtensionLoader.md index c514f2a01e..dde4a6bca3 100644 --- a/docs/API-Reference/utils/ExtensionLoader.md +++ b/docs/API-Reference/utils/ExtensionLoader.md @@ -151,3 +151,15 @@ Load extensions. | --- | --- | --- | | A | Array.<string> | list containing references to extension source location. A source location may be either (a) a folder name inside src/extensions or (b) an absolute path. | + + +## uninstallExtension(extensionID) ⇒ Promise +Uninstall a deprecated extension + +**Kind**: global function +**Returns**: Promise - A promise that resolves when the extension is uninstalled successfully + +| Param | Type | Description | +| --- | --- | --- | +| extensionID | string | The ID of the extension to uninstall | + diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index 48405c4b1a..279d09b2a1 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -30,12 +30,13 @@ const { src, dest, series } = require('gulp'); const zip = require('gulp-zip'); const Translate = require("./translateStrings"); const copyThirdPartyLibs = require("./thirdparty-lib-copy"); +const optionalBuild = require("./optional-build"); const minify = require('gulp-minify'); const glob = require("glob"); -const sourcemaps = require('gulp-sourcemaps'); const crypto = require("crypto"); const rename = require("gulp-rename"); const execSync = require('child_process').execSync; +const terser = require('terser'); function cleanDist() { return del(['dist', 'dist-test']); @@ -57,6 +58,8 @@ function cleanAll() { // Test artifacts 'dist-test', 'test/spec/test_folders.zip', + 'src/extensionsIntegrated/pro-loader.js', + 'test/pro-test-suite.js', ...RELEASE_BUILD_ARTEFACTS ]); } @@ -71,6 +74,44 @@ function cleanUnwantedFilesInDist() { ]); } +function _cleanPhoenixProGitFolder() { + return new Promise((resolve) => { + const gitFolders = [ + 'dist/extensionsIntegrated/phoenix-pro/.git', + 'dist-test/src/extensionsIntegrated/phoenix-pro/.git' + ]; + + for (const gitFolder of gitFolders) { + if (fs.existsSync(gitFolder)) { + fs.rmSync(gitFolder, { recursive: true, force: true }); + console.log(`Removed git folder: ${gitFolder}`); + } + } + resolve(); + }); +} + +function _deletePhoenixProSourceFolder() { + return new Promise((resolve) => { + const phoenixProFolders = [ + // we only delete the source folder from the release build artifact and not the test artifact. why below? + 'dist/extensionsIntegrated/phoenix-pro' + // 'dist-test/src/extensionsIntegrated/phoenix-pro' // ideally we should delete this too so that the tests + // test exactly the release build artifact, but the spec runner requires on these files during test start + // and i wasnt able to isolate them. so instead wehat we do now is that we have an additional test in prod + // that checks that the phoenix-pro source folder is not loaded in prod and loaded only from the inlines + // brackets-min file. + ]; + + for (const folder of phoenixProFolders) { + if (fs.existsSync(folder)) { + fs.rmSync(folder, { recursive: true, force: true }); + } + } + resolve(); + }); +} + /** * TODO: Release scripts to merge and min src js/css/html resources into dist. * Links that might help: @@ -89,8 +130,7 @@ function makeDistAll() { function makeJSDist() { return src(['src/**/*.js', '!src/**/unittest-files/**/*', "!src/thirdparty/prettier/**/*", - "!src/thirdparty/no-minify/**/*"]) - .pipe(sourcemaps.init()) + "!src/thirdparty/no-minify/**/*", "!src/LiveDevelopment/BrowserScripts/RemoteFunctions.js"]) .pipe(minify({ ext:{ min:'.js' @@ -99,23 +139,31 @@ function makeJSDist() { mangle: false, compress: { unused: false + }, + preserveComments: function (node, comment) { + const text = (comment.value || "").trim(); + + // license headers should not end up in distribution as the license of dist depends on + // internal vs external builds. we strip every comment except with below flag. + // Preserve ONLY comments starting with "DONT_STRIP_MINIFY:" + return text.includes("DONT_STRIP_MINIFY:"); } })) - .pipe(sourcemaps.write('./')) .pipe(dest('dist')); } // we had to do this as prettier is non minifiable function makeJSPrettierDist() { return src(["src/thirdparty/prettier/**/*"]) - .pipe(sourcemaps.init()) .pipe(dest('dist/thirdparty/prettier')); } function makeNonMinifyDist() { - return src(["src/thirdparty/no-minify/**/*"]) - .pipe(sourcemaps.init()) - .pipe(dest('dist/thirdparty/no-minify')); + // we dont minify remote functions as its in live preview context and the prod minify is stripping variables + // used by plugins in live preview. so we dont minify this for now. + return src(["src/thirdparty/no-minify/**/*", + "src/LiveDevelopment/BrowserScripts/RemoteFunctions.js"], {base: 'src'}) + .pipe(dest('dist')); } function makeDistNonJS() { @@ -536,29 +584,81 @@ function containsRegExpExcludingEmpty(str) { } +// Paths that should be minified during production builds +const minifyablePaths = [ + 'src/extensionsIntegrated/phoenix-pro/browser-context' +]; + +function _minifyBrowserContextFile(fileContent) { + const minified = terser.minify(fileContent, { + mangle: true, + compress: { + unused: false + }, + output: { + comments: function(node, comment) { + // license headers should not end up in distribution as the license of dist depends on + // internal vs external builds. we strip every comment except with below flag. + const text = comment.value.trim(); + return text.includes("DONT_STRIP_MINIFY:"); + } + } + }); + + if (minified.error) { + throw new Error(`Failed to minify file: ${minified.error}`); + } + + return minified.code; +} + +function _isMinifyablePath(filePath) { + const normalizedFilePath = path.normalize(filePath); + return minifyablePaths.some(minifyPath => + normalizedFilePath.startsWith(path.normalize(minifyPath)) + ); +} + +function getKey(filePath, isDevBuild) { + return isDevBuild + filePath; +} + const textContentMap = {}; -function inlineTextRequire(file, content, srcDir) { +const excludeSuffixPathsInlining = ["MessageIds.json"]; +function inlineTextRequire(file, content, srcDir, isDevBuild = true) { if(content.includes(`'text!`) || content.includes("`text!")) { throw new Error(`in file ${file} require("text!...") should always use a double quote "text! instead of " or \``); } if(content.includes(`"text!`)) { const requireFragments = extractRequireTextFragments(content); for (const {requirePath, requireStatement} of requireFragments) { - let textContent = textContentMap[requirePath]; + let filePath = srcDir + requirePath; + if(requirePath.startsWith("./")) { + filePath = path.join(path.dirname(file), requirePath); + } + let textContent = textContentMap[getKey(filePath, isDevBuild)]; + if(!textContent){ - let filePath = srcDir + requirePath; - if(requirePath.startsWith("./")) { - filePath = path.join(path.dirname(file), requirePath); - } console.log("reading file at path: ", filePath); - const fileContent = fs.readFileSync(filePath, "utf8"); - textContentMap[requirePath] = fileContent; + let fileContent = fs.readFileSync(filePath, "utf8"); + + // Minify inline if this is a minifyable path and we're in production mode + if (!isDevBuild && _isMinifyablePath(filePath)) { + console.log("Minifying file inline:", filePath); + fileContent = _minifyBrowserContextFile(fileContent); + } + + textContentMap[getKey(filePath, isDevBuild)] = fileContent; textContent = fileContent; } - if(textContent.includes("`")) { - console.log("Not inlining file as it contains a backquote(`) :", requirePath); - } else if(requirePath.endsWith(".js") || requirePath.endsWith(".json")) { - console.log("Not inlining JS/JSON file:", requirePath); + if((requirePath.endsWith(".js") && !requirePath.includes("./")) // js files that are relative paths are ok + || excludeSuffixPathsInlining.some(ext => requirePath.endsWith(ext))) { + console.warn("Not inlining JS/JSON file:", requirePath, filePath); + if(filePath.includes("phoenix-pro")) { + // this is needed as we will delete the extension sources when packaging for release. + // so non inlined content will not be available in the extension. throw early to detect that. + throw new Error(`All Files in phoenix pro extension should be inlineable!: failed for ${filePath}`); + } } else { console.log("Inlining", requireStatement); if((requireStatement.includes(".html") || requireStatement.includes(".js")) @@ -568,7 +668,7 @@ function inlineTextRequire(file, content, srcDir) { throw `Error inlining ${requireStatement} in ${file}: Regex: ${detectedRegEx}`+ "\nRegular expression of the form /*/ is not allowed for minification please use RegEx constructor"; } - content = content.replaceAll(requireStatement, "`"+textContent+"`"); + content = content.replaceAll(requireStatement, `${JSON.stringify(textContent)}`); } } @@ -576,7 +676,7 @@ function inlineTextRequire(file, content, srcDir) { return content; } -function makeBracketsConcatJS() { +function _makeBracketsConcatJSInternal(isDevBuild = true) { return new Promise((resolve)=>{ const srcDir = "src/"; const DO_NOT_CONCATENATE = [ @@ -610,7 +710,7 @@ function makeBracketsConcatJS() { console.log("Merging: ", requirePath); mergeCount ++; content = content.replace("define(", `define("${requirePath}", `); - content = inlineTextRequire(file, content, srcDir); + content = inlineTextRequire(file, content, srcDir, isDevBuild); concatenatedFile = concatenatedFile + "\n" + content; } } @@ -621,14 +721,20 @@ function makeBracketsConcatJS() { }); } +function makeBracketsConcatJS() { + return _makeBracketsConcatJSInternal(true); +} + +function makeBracketsConcatJSWithMinifiedBrowserScripts() { + return _makeBracketsConcatJSInternal(false); +} + function _renameBracketsConcatAsBracketsJSInDist() { return new Promise((resolve)=>{ fs.unlinkSync("dist/brackets.js"); fs.copyFileSync("dist/brackets-min.js", "dist/brackets.js"); - fs.copyFileSync("dist/brackets-min.js.map", "dist/brackets.js.map"); // cleanup minifed files fs.unlinkSync("dist/brackets-min.js"); - fs.unlinkSync("dist/brackets-min.js.map"); resolve(); }); } @@ -711,8 +817,8 @@ function makeExtensionConcatJS(extensionName) { `define("${defineId}", ` ); - // inline text requires - content = inlineTextRequire(file, content, extensionDir); + // inline text requires (extensions use isDevBuild=true, they're minified via makeJSDist) + content = inlineTextRequire(file, content, extensionDir, true); concatenatedFile += '\n' + content; mergeCount++; @@ -748,9 +854,7 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) { const srcExtensionConcatFile = path.join(srcExtensionDir, 'extension-min.js'); const distExtensionDir = path.join('dist/extensions/default', extensionName); const extMinFile = path.join(distExtensionDir, 'main.js'); - const extMinFileMap = path.join(distExtensionDir, 'main.js.map'); const extSrcFile = path.join(distExtensionDir, 'extension-min.js'); - const extSrcFileMap = path.join(distExtensionDir, 'extension-min.js.map'); // Make sure extension-min.js exists in dist. if (!fs.existsSync(extSrcFile)) { @@ -769,17 +873,7 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) { } fs.copyFileSync(extSrcFile, extMinFile); - if (fs.existsSync(extMinFileMap)) { - fs.unlinkSync(extMinFileMap); - } - if (fs.existsSync(extSrcFileMap)) { - fs.copyFileSync(extSrcFileMap, extMinFileMap); - } - fs.unlinkSync(extSrcFile); - if (fs.existsSync(extSrcFileMap)) { - fs.unlinkSync(extSrcFileMap); - } resolve(); } catch (err) { @@ -877,6 +971,32 @@ function makeLoggerConfig() { }); } +function generateProLoaderFiles() { + return new Promise((resolve) => { + // AMD module template for generated files + const AMD_MODULE_TEMPLATE = `define(function (require, exports, module) {});\n`; + + const phoenixProExists = fs.existsSync('src/extensionsIntegrated/phoenix-pro'); + + // Generate test/pro-test-suite.js content + const testSuiteCode = phoenixProExists ? + '\n require("extensionsIntegrated/phoenix-pro/unittests");\n' : ''; + const testSuiteContent = AMD_MODULE_TEMPLATE.replace('', testSuiteCode); + + // Generate src/extensionsIntegrated/pro-loader.js content + const loaderCode = phoenixProExists ? '\n require("./phoenix-pro/main");\n' : ''; + const loaderContent = AMD_MODULE_TEMPLATE.replace('', loaderCode); + + fs.writeFileSync('test/pro-test-suite.js', testSuiteContent); + fs.writeFileSync('src/extensionsIntegrated/pro-loader.js', loaderContent); + + console.log(`Generated pro-loader.js (phoenix-pro ${phoenixProExists ? 'found' : 'not found'})`); + console.log(`Generated pro-test-suite.js (phoenix-pro ${phoenixProExists ? 'found' : 'not found'})`); + + resolve(); + }); +} + function validatePackageVersions() { return new Promise((resolve, reject)=>{ const mainPackageJson = require("../package.json", "utf8"); @@ -942,26 +1062,28 @@ function _patchMinifiedCSSInDistIndex() { const createDistTest = series(copyDistToDistTestFolder, copyTestToDistTestFolder, copyIndexToDistTestFolder); -exports.build = series(copyThirdPartyLibs.copyAll, makeLoggerConfig, zipDefaultProjectFiles, zipSampleProjectFiles, - makeBracketsConcatJS, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late +exports.build = series(optionalBuild.clonePhoenixProRepo, copyThirdPartyLibs.copyAll, makeLoggerConfig, generateProLoaderFiles, zipDefaultProjectFiles, zipSampleProjectFiles, + makeBracketsConcatJS, makeBracketsConcatJSWithMinifiedBrowserScripts, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late createSrcCacheManifest, validatePackageVersions); -exports.buildDebug = series(copyThirdPartyLibs.copyAllDebug, makeLoggerConfig, zipDefaultProjectFiles, - makeBracketsConcatJS, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late +exports.buildDebug = series(optionalBuild.clonePhoenixProRepo, copyThirdPartyLibs.copyAllDebug, makeLoggerConfig, generateProLoaderFiles, zipDefaultProjectFiles, + makeBracketsConcatJS, makeBracketsConcatJSWithMinifiedBrowserScripts, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late zipSampleProjectFiles, createSrcCacheManifest); exports.clean = series(cleanDist); exports.reset = series(cleanAll); exports.releaseDev = series(cleanDist, exports.buildDebug, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc, makeDistAll, cleanUnwantedFilesInDist, releaseDev, _renameConcatExtensionsinDist, - createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc); -exports.releaseStaging = series(cleanDist, exports.build, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc, - makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, cleanUnwantedFilesInDist, - _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, _patchMinifiedCSSInDistIndex, releaseStaging, - createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc); -exports.releaseProd = series(cleanDist, exports.build, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc, - makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, cleanUnwantedFilesInDist, - _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, _patchMinifiedCSSInDistIndex, releaseProd, - createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc); + createDistCacheManifest, createDistTest, _cleanPhoenixProGitFolder, _cleanReleaseBuildArtefactsInSrc); +exports.releaseStaging = series(cleanDist, exports.build, makeBracketsConcatJSWithMinifiedBrowserScripts, + makeConcatExtensions, _compileLessSrc, makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, + cleanUnwantedFilesInDist, _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, + _patchMinifiedCSSInDistIndex, releaseStaging, createDistCacheManifest, createDistTest, + _deletePhoenixProSourceFolder, _cleanReleaseBuildArtefactsInSrc); +exports.releaseProd = series(cleanDist, exports.build, makeBracketsConcatJSWithMinifiedBrowserScripts, + makeConcatExtensions, _compileLessSrc, makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, + cleanUnwantedFilesInDist, _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, + _patchMinifiedCSSInDistIndex, releaseProd, createDistCacheManifest, createDistTest, + _deletePhoenixProSourceFolder, _cleanReleaseBuildArtefactsInSrc); exports.releaseWebCache = series(makeDistWebCache); exports.serve = series(exports.build, serve); exports.zipTestFiles = series(zipTestFiles); diff --git a/gulpfile.js/optional-build.js b/gulpfile.js/optional-build.js new file mode 100644 index 0000000000..83d3ea4d37 --- /dev/null +++ b/gulpfile.js/optional-build.js @@ -0,0 +1,182 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2022 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/* eslint-env node */ + +const fs = require('fs'); +const path = require('path'); +const execSync = require('child_process').execSync; + +/** + * Conditionally clones the phoenix-pro repository if environment variables are set. + * + * Behavior: + * - If env vars not set: Skip clone, build continues (community builds) + * - If env vars set but clone fails: Build FAILS (credentials configured but clone failed) + * - If directory exists with correct commit: Skip clone, build continues + * - If directory exists with wrong commit: Log warning, build continues (respect local changes) + */ +function clonePhoenixProRepo() { + return new Promise((resolve, reject) => { + // this is only expected to be hit in github actions environment. + // in normal builds, we will bail out as soon as we detect that the environmental vars are note present. + + const proRepoUrl = process.env.PRO_REPO_URL; + const proRepoToken = process.env.PRO_REPO_ACCESS_TOKEN; + const targetDir = path.resolve(__dirname, '../src/extensionsIntegrated/phoenix-pro'); + + // Check if both environment variables are set + if (!proRepoUrl || !proRepoToken) { + // this si what will happen in most dev builds. + console.log('Skipping phoenix-pro clone: PRO_REPO_URL or PRO_REPO_ACCESS_TOKEN not set'); + console.log('This is expected for community builds'); + resolve(); + return; + } + + // all code below is only likely to be executed in the ci environment + + // Check if directory already exists + if (fs.existsSync(targetDir)) { + console.log('phoenix-pro directory already exists at:', targetDir); + + // Check if it's a git repository + const gitDir = path.join(targetDir, '.git'); + if (fs.existsSync(gitDir)) { + try { + // Verify current commit + const trackingRepos = require('../tracking-repos.json'); + const expectedCommit = trackingRepos.phoenixPro.commitID; + const currentCommit = execSync('git rev-parse HEAD', { + cwd: targetDir, + encoding: 'utf8' + }).trim(); + + if (currentCommit === expectedCommit) { + console.log(`✓ phoenix-pro is already at the correct commit: ${expectedCommit}`); + resolve(); + return; + } else { + // this code will only reach in ci envs with teh env variables, so ward if the commit + // is not what we expect. + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error(`Error: phoenix-pro is at commit ${currentCommit.substring(0, 8)}`); + console.error(` but tracking-repos.json specifies ${expectedCommit.substring(0, 8)}`); + console.error('Not building incorrect binary.'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + reject(); + return; + } + } catch (error) { + console.error(`Error: Could not verify phoenix-pro commit: ${error.message}`); + console.error('Not building incorrect binary.'); + reject(); + return; + } + } else { + console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.warn('Error: phoenix-pro directory exists but is not a git repository'); + console.error('Not building incorrect binary as it could not be verified.'); + console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + reject(); + return; + } + } + + // Perform the clone operation + try { + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('Cloning phoenix-pro repository...'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // Load target commit from tracking-repos.json + const trackingRepos = require('../tracking-repos.json'); + const commitID = trackingRepos.phoenixPro.commitID; + console.log(`Target commit: ${commitID}`); + + // Construct authenticated URL + const authUrl = proRepoUrl.replace('https://', `https://oauth2:${proRepoToken}@`); + + // Step 1: Shallow clone + console.log('Step 1/3: Cloning repository (shallow clone)...'); + execSync(`git clone --depth 1 "${authUrl}" "${targetDir}"`, { + stdio: ['pipe', 'pipe', 'inherit'] // Hide stdout (may contain token), show stderr + }); + console.log('✓ Clone completed'); + + // Step 2: Fetch specific commit + console.log(`Step 2/3: Fetching specific commit: ${commitID}...`); + try { + execSync(`git fetch --depth 1 origin ${commitID}`, { + cwd: targetDir, + stdio: ['pipe', 'pipe', 'inherit'] + }); + console.log('✓ Fetch completed'); + } catch (fetchError) { + // Commit might already be in shallow clone + console.log(' (Commit may already be present in shallow clone)'); + } + + // Step 3: Checkout specific commit + console.log(`Step 3/3: Checking out commit: ${commitID}...`); + execSync(`git checkout ${commitID}`, { + cwd: targetDir, + stdio: ['pipe', 'pipe', 'inherit'] + }); + console.log('✓ Checkout completed'); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('✓ Successfully cloned and checked out phoenix-pro repository'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + resolve(); + + } catch (error) { + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('✗ ERROR: Failed to clone phoenix-pro repository'); + console.error(`Error: ${error.message}`); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('Build failed because:'); + console.error(' - PRO_REPO_URL and PRO_REPO_ACCESS_TOKEN are set (phoenix-pro expected)'); + console.error(' - Clone operation failed'); + console.error(''); + console.error('Possible causes:'); + console.error(' - Invalid or expired access token'); + console.error(' - Insufficient token permissions (needs "repo" scope)'); + console.error(' - Network connectivity issues'); + console.error(' - Repository URL is incorrect'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // Clean up partial clone if it exists + if (fs.existsSync(targetDir)) { + try { + fs.rmSync(targetDir, { recursive: true, force: true }); + console.log('Cleaned up partial clone directory'); + } catch (cleanupError) { + console.warn(`Could not clean up partial clone: ${cleanupError.message}`); + } + } + + reject(new Error('Failed to clone phoenix-pro repository')); // FAIL BUILD + } + }); +} + +// Export the function +exports.clonePhoenixProRepo = clonePhoenixProRepo; diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js index 72b95696fb..f026291e10 100644 --- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js @@ -182,6 +182,10 @@ } } s.id = msg.params.url; + + if (window._LD && window._LD.redrawEverything) { + window._LD.redrawEverything(); + } }, /** @@ -363,7 +367,7 @@ ProtocolManager.enable(); }); - function _getAllInheritedSelectorsInOrder(element) { + function getAllInheritedSelectorsInOrder(element) { let selectorsFound= new Map(); const selectorsList = []; while (element) { @@ -383,6 +387,7 @@ return selectorsList; } + global.getAllInheritedSelectorsInOrder = getAllInheritedSelectorsInOrder; /** * Sends the message containing tagID which is being clicked @@ -407,7 +412,7 @@ "nodeID": element.id, "nodeClassList": element.classList, "nodeName": element.nodeName, - "allSelectors": _getAllInheritedSelectorsInOrder(element), + "allSelectors": getAllInheritedSelectorsInOrder(element), "contentEditable": element.contentEditable === 'true', "clicked": true, "edit": true @@ -431,7 +436,7 @@ "nodeID": element.id, "nodeClassList": element.classList, "nodeName": element.nodeName, - "allSelectors": _getAllInheritedSelectorsInOrder(element), + "allSelectors": getAllInheritedSelectorsInOrder(element), "contentEditable": element.contentEditable === 'true', "clicked": true }); @@ -440,34 +445,90 @@ } window.document.addEventListener("click", onDocumentClick); window.document.addEventListener("keydown", function (e) { + // Check if user is editing text content - if so, allow normal text cut + // Get the truly active element, even if inside shadow roots + let activeElement = document.activeElement; + + const isEditingText = activeElement && ( + // Check for standard form input elements + ['INPUT', 'TEXTAREA'].includes(activeElement.tagName) || + // Check for contentEditable elements + activeElement.isContentEditable || + // Check for ARIA roles that indicate text input + ['textbox', 'searchbox', 'combobox'].includes(activeElement.getAttribute('role')) || + // Check if element is designed to receive text input + (activeElement.hasAttribute("contenteditable") && activeElement.hasAttribute("data-brackets-id")) + ); + + // Check if a Phoenix tool is active (has data-phcode-internal-* attribute) + const isActiveElementPhoenixTool = activeElement && Array.from(activeElement.attributes || []).some(attr => + attr.name.startsWith('data-phcode-internal-') && attr.value === 'true' + ); + + const isInEditMode = window._LD && window._LD.getMode && window._LD.getMode() === 'edit'; + // for undo. refer to LivePreviewEdit.js file 'handleLivePreviewEditOperation' function - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && + (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) { MessageBroker.send({ livePreviewEditEnabled: true, undoLivePreviewOperation: true }); } - // for redo - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") { + // for redo - supports both Ctrl+Y and Ctrl+Shift+Z (Cmd+Y and Cmd+Shift+Z on Mac) + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && (e.ctrlKey || e.metaKey) && + (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) { MessageBroker.send({ livePreviewEditEnabled: true, redoLivePreviewOperation: true }); } + // Cut: Ctrl+X / Cmd+X - operates on selected element + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "x") { + + // Only handle element cut if not editing text and in edit mode + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleCutElement) { + e.preventDefault(); + window._LD.handleCutElement(); + } + } + + // Copy: Ctrl+C / Cmd+C - operates on selected element + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") { + + // Only handle element copy if not editing text and in edit mode + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleCopyElement) { + e.preventDefault(); + window._LD.handleCopyElement(); + } + } + + // Paste: Ctrl+V / Cmd+V - operates on selected element + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") { + + // Only handle element paste if not editing text and in edit mode + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handlePasteElement) { + e.preventDefault(); + window._LD.handlePasteElement(); + } + } + + if (e.key.toLowerCase() === 'delete' || e.key.toLowerCase() === 'backspace') { + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleDeleteElement) { + e.preventDefault(); + window._LD.handleDeleteElement(); + } + } + // for save if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); // to check if user was in between editing text // in such cases we first finish the editing and then save - const activeElement = document.activeElement; - if (activeElement && - activeElement.hasAttribute("contenteditable") && - activeElement.hasAttribute("data-brackets-id") && - window._LD && - window._LD.finishEditing) { + if (isEditingText && window._LD && window._LD.finishEditing) { window._LD.finishEditing(activeElement); } diff --git a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js index 50d278c061..66a0d27065 100644 --- a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js @@ -313,9 +313,10 @@ } }; + const ABS_REGEX = new RegExp("^(?:[a-z]+:)?\\/\\/", "i"); function getAbsoluteUrl(url) { // Check if the URL is already absolute - if (/^(?:[a-z]+:)?\/\//i.test(url)) { + if (ABS_REGEX.test(url)) { return url; // The URL is already absolute } @@ -439,6 +440,7 @@ let alertQueue = [], confirmCalled = false, promptCalled = false; let addToQueue = true; + window.__PHOENIX_APP_INFO = {isTauri, platform}; if(!isExternalBrowser){ // this is an embedded iframe we always take hold of the alert api for better ux within the live preivew frame. window.__PHOENIX_EMBED_INFO = {isTauri, platform}; diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 0b7beceaed..f2d73370d8 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -1,3372 +1,265 @@ -/* - * GNU AGPL-3.0 License - * - * Copyright (c) 2021 - present core.ai . All rights reserved. - * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved. - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License - * for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. - * - */ - -/*jslint forin: true */ -/*global Node, MessageEvent */ -/*theseus instrument: false */ - -/** - * RemoteFunctions define the functions to be executed in the browser. This - * modules should define a single function that returns an object of all - * exported functions. - */ -function RemoteFunctions(config = {}) { - // this will store the element that was clicked previously (before the new click) - // we need this so that we can remove click styling from the previous element when a new element is clicked - let previouslyClickedElement = null; - - var req, timeout; - var animateHighlight = function (time) { - if(req) { - window.cancelAnimationFrame(req); - window.clearTimeout(timeout); - } - req = window.requestAnimationFrame(redrawHighlights); - - timeout = setTimeout(function () { - window.cancelAnimationFrame(req); - req = null; - }, time * 1000); - }; - - /** - * @type {DOMEditHandler} - */ - var _editHandler; - - var HIGHLIGHT_CLASSNAME = "__brackets-ld-highlight"; - - // auto-scroll variables to auto scroll the live preview when an element is dragged to the top/bottom - let _autoScrollTimer = null; - let _isAutoScrolling = false; // to disable highlights when auto scrolling - const AUTO_SCROLL_SPEED = 12; // pixels per scroll - const AUTO_SCROLL_EDGE_SIZE = 0.05; // 5% of viewport height (either top/bottom) - - // initialized from config, defaults to true if not set - let imageGallerySelected = config.imageGalleryState !== undefined ? config.imageGalleryState : true; - - /** - * this function is responsible to auto scroll the live preview when - * dragging an element to the viewport edges - * @param {number} clientY - curr mouse Y position - */ - function _handleAutoScroll(clientY) { - const viewportHeight = window.innerHeight; - const scrollEdgeSize = viewportHeight * AUTO_SCROLL_EDGE_SIZE; - - // Clear existing timer - if (_autoScrollTimer) { - clearInterval(_autoScrollTimer); - _autoScrollTimer = null; - } - - let scrollDirection = 0; - - // check if near top edge (scroll up) - if (clientY <= scrollEdgeSize) { - scrollDirection = -AUTO_SCROLL_SPEED; - } else if (clientY >= viewportHeight - scrollEdgeSize) { - // check if near bottom edge (scroll down) - scrollDirection = AUTO_SCROLL_SPEED; - } - - // Start scrolling if needed - if (scrollDirection !== 0) { - _isAutoScrolling = true; - _autoScrollTimer = setInterval(() => { - window.scrollBy(0, scrollDirection); - }, 16); // 16 is ~60fps - } - } - - // stop autoscrolling - function _stopAutoScroll() { - if (_autoScrollTimer) { - clearInterval(_autoScrollTimer); - _autoScrollTimer = null; - } - _isAutoScrolling = false; - } - - // determine whether an event should be processed for Live Development - function _validEvent(event) { - if (window.navigator.platform.substr(0, 3) === "Mac") { - // Mac - return event.metaKey; - } - // Windows - return event.ctrlKey; - } - - /** - * check if an element is inspectable. - * inspectable elements are those which doesn't have data-brackets-id, - * this normally happens when content is DOM content is inserted by some scripting language - */ - function isElementInspectable(element, onlyHighlight = false) { - if(!config.isProUser && !onlyHighlight) { - return false; - } - - if(element && // element should exist - element.tagName.toLowerCase() !== "body" && // shouldn't be the body tag - element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag - !element.closest("[data-phcode-internal-c15r5a9]") && // this attribute is used by phoenix internal elements - !_isInsideHeadTag(element)) { // shouldn't be inside the head tag like meta tags and all - return true; - } - return false; - } - - /** - * This is a checker function for editable elements, it makes sure that the element satisfies all the required check - * - When onlyHighlight is false → config.isProUser must be true - * - When onlyHighlight is true → config.isProUser can be true or false (doesn't matter) - * @param {DOMElement} element - * @param {boolean} [onlyHighlight=false] - If true, bypasses the isProUser check - * @returns {boolean} - True if the element is editable else false - */ - function isElementEditable(element, onlyHighlight = false) { - // for an element to be editable it should satisfy all inspectable checks and should also have data-brackets-id - return isElementInspectable(element, onlyHighlight) && - element.hasAttribute("data-brackets-id"); - } - - // helper function to check if an element is inside the HEAD tag - // we need this because we don't wanna trigger the element highlights on head tag and its children, - // except for
${content}
`; - 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 = ` - - - - - `; - }, - - _getDefaultQuery: function() { - // this are the default queries, so when image ribbon gallery is shown, we select a random query and show it - const qualityQueries = [ - 'nature', 'minimal', 'workspace', 'abstract', 'coffee', - 'mountains', 'city', 'flowers', 'ocean', 'sunset', - 'architecture', 'forest', 'travel', 'technology', 'sky', - 'landscape', 'creative', 'design', 'art', 'modern', - 'food', 'patterns', 'colors', 'photography', 'studio', - 'light', 'winter', 'summer', 'vintage', 'geometric', - 'water', 'beach', 'space', 'garden', 'textures', - 'urban', 'portrait', 'music', 'books', 'home', - 'cozy', 'aesthetic', 'autumn', 'spring', 'clouds' - ]; - - const randIndex = Math.floor(Math.random() * qualityQueries.length); - return qualityQueries[randIndex]; - }, - - _fetchImages: function(searchQuery, page = 1, append = false) { - this._currentSearchQuery = searchQuery; - - if (!append && this._loadFromCache(searchQuery)) { // try cache first - return; - } - if (append && this._loadPageFromCache(searchQuery, page)) { // try to load new page from cache - return; - } - // if unable to load from cache, we make the API call - this._fetchFromAPI(searchQuery, page, append); - }, - - _fetchFromAPI: function(searchQuery, page, append) { - // when we fetch from API, we clear the previous query from local storage and then store a fresh copy - if (searchQuery !== _imageGalleryCache.currentQuery) { - this._clearCache(); - } - - const apiUrl = `https://images.phcode.dev/api/images/search?q=${encodeURIComponent(searchQuery)}&per_page=10&page=${page}&safe=true`; - - if (!append) { - this._showLoading(); - } - - fetch(apiUrl) - .then(response => { - if (!response.ok) { - throw new Error(`API request failed: ${response.status}`); - } - return response.json(); - }) - .then(data => { - if (data.results && data.results.length > 0) { - if (append) { - this.allImages = this.allImages.concat(data.results); - this._renderImages(data.results, true); // true means need to append new images at the end - } else { - this.allImages = data.results; - this._renderImages(this.allImages, false); // false means its a new search - } - this.totalPages = data.total_pages || 1; - this.currentPage = page; - this._handleNavButtonsDisplay('visible'); - this._updateSearchInput(searchQuery); - this._updateCache(searchQuery, data, append); - } else if (!append) { - this._showError(config.strings.imageGalleryNoImages); - } - - if (append) { - this._isLoadingMore = false; - this._hideLoadingMore(); - } - }) - .catch(error => { - console.error('Failed to fetch images:', error); - if (!append) { - this._showError(config.strings.imageGalleryLoadError); - } else { - this._isLoadingMore = false; - this._hideLoadingMore(); - } - }); - }, - - _updateCache: function(searchQuery, data, append) { - // Update cache with new data for current query - _imageGalleryCache.currentQuery = searchQuery; - _imageGalleryCache.totalPages = data.total_pages || 1; - _imageGalleryCache.currentPage = this.currentPage; - - if (append) { - const currentImages = _imageGalleryCache.allImages || []; - const newImages = currentImages.concat(data.results); - - if (newImages.length > CACHE_MAX_IMAGES) { - _imageGalleryCache.allImages = newImages.slice(0, CACHE_MAX_IMAGES); - } else { - _imageGalleryCache.allImages = newImages; - } - } else { - // new search replace cache - _imageGalleryCache.allImages = data.results; - } - }, - - _clearCache: function() { - try { - window.localStorage.removeItem('imageGalleryCache'); - } catch (error) { - console.error('Failed to clear image cache:', error); - } - }, - - _updateSearchInput: function(searchQuery) { - // write the current query in the search input - const searchInput = this._shadow.querySelector('.search-wrapper input'); - if (searchInput && searchQuery) { - searchInput.value = searchQuery; - searchInput.placeholder = searchQuery; - } - }, - - _loadFromCache: function(searchQuery) { - const cachedImages = _imageGalleryCache.allImages; - if (searchQuery === _imageGalleryCache.currentQuery && cachedImages && cachedImages.length > 0) { - this.allImages = cachedImages; - this.totalPages = _imageGalleryCache.totalPages; - this.currentPage = _imageGalleryCache.currentPage; - - this._renderImages(this.allImages, false); - this._handleNavButtonsDisplay('visible'); - this._updateSearchInput(searchQuery); - return true; - } - return false; - }, - - _loadPageFromCache: function(searchQuery, page) { - const cachedImages = _imageGalleryCache.allImages; - if (searchQuery === _imageGalleryCache.currentQuery && cachedImages && page <= Math.ceil(cachedImages.length / 10)) { - const startIdx = (page - 1) * 10; - const endIdx = startIdx + 10; - const pageImages = cachedImages.slice(startIdx, endIdx); - - if (pageImages.length > 0) { - this.allImages = this.allImages.concat(pageImages); - this._renderImages(pageImages, true); - this.currentPage = page; - this._handleNavButtonsDisplay('visible'); - this._isLoadingMore = false; - this._hideLoadingMore(); - return true; - } - } - return false; - }, - - _handleNavLeft: function() { - const container = this._shadow.querySelector('.phoenix-image-gallery-strip'); - if (!container) { return; } - - const containerWidth = container.clientWidth; - const imageWidth = 117; // image width + gap - - // calculate how many images are visible - const visibleImages = Math.floor(containerWidth / imageWidth); - - // scroll by (visible images - 2), minimum 1 image, maximum 5 images - const imagesToScroll = Math.max(1, Math.min(5, visibleImages - 2)); - const scrollAmount = imagesToScroll * imageWidth; - - this.scrollPosition = Math.max(0, this.scrollPosition - scrollAmount); - container.scrollTo({ left: this.scrollPosition, behavior: 'smooth' }); - this._handleNavButtonsDisplay('visible'); - }, - - _handleNavRight: function() { - const container = this._shadow.querySelector('.phoenix-image-gallery-strip'); - if (!container) { return; } - - const containerWidth = container.clientWidth; - const totalWidth = container.scrollWidth; - const imageWidth = 117; // image width + gap - - // calculate how many images are visible - const visibleImages = Math.floor(containerWidth / imageWidth); - - // scroll by (visible images - 2), minimum 1 image, maximum 5 images - const imagesToScroll = Math.max(1, Math.min(5, visibleImages - 2)); - const scrollAmount = imagesToScroll * imageWidth; - - // if we're near the end, we need to load more images - const nearEnd = (this.scrollPosition + containerWidth + scrollAmount) >= totalWidth - 100; - if (nearEnd && this.currentPage < this.totalPages && !this._isLoadingMore) { - this._isLoadingMore = true; - this._showLoadingMore(); - this._fetchImages(this._currentSearchQuery, this.currentPage + 1, true); - } - - this.scrollPosition = Math.min(totalWidth - containerWidth, this.scrollPosition + scrollAmount); - container.scrollTo({ left: this.scrollPosition, behavior: 'smooth' }); - this._handleNavButtonsDisplay('visible'); - }, - - _handleNavButtonsDisplay: function(state) { // state can be 'visible' or 'hidden' - const navLeft = this._shadow.querySelector('.phoenix-image-gallery-nav.left'); - const navRight = this._shadow.querySelector('.phoenix-image-gallery-nav.right'); - const container = this._shadow.querySelector('.phoenix-image-gallery-strip'); - - if (!navLeft || !navRight) { return; } - - if (state === 'hidden') { - navLeft.style.setProperty('display', 'none', 'important'); - navRight.style.setProperty('display', 'none', 'important'); - return; - } - - if (state === 'visible') { - if (!container) { return; } - - // show/hide the nav-left button - if (this.scrollPosition <= 0) { - navLeft.style.setProperty('display', 'none', 'important'); - } else { - navLeft.style.setProperty('display', 'flex', 'important'); - } - - // show/hide the nav-right button - const containerWidth = container.clientWidth; - const totalWidth = container.scrollWidth; - const atEnd = (this.scrollPosition + containerWidth) >= totalWidth - 10; - const hasMorePages = this.currentPage < this.totalPages; - - if (atEnd && !hasMorePages) { - navRight.style.setProperty('display', 'none', 'important'); - } else { - navRight.style.setProperty('display', 'flex', 'important'); - } - } - }, - - _showLoading: function() { - const rowElement = this._shadow.querySelector('.phoenix-image-gallery-row'); - if (!rowElement) { return; } - - rowElement.innerHTML = config.strings.imageGalleryLoadingInitial; - rowElement.className = 'phoenix-image-gallery-row phoenix-image-gallery-loading'; - - this._handleNavButtonsDisplay('hidden'); - }, - - _showLoadingMore: function() { - const rowElement = this._shadow.querySelector('.phoenix-image-gallery-row'); - if (!rowElement) { return; } - - // when loading more images we need to show the message at the end of the image ribbon - const loadingIndicator = window.document.createElement('div'); - loadingIndicator.className = 'phoenix-loading-more'; - loadingIndicator.textContent = config.strings.imageGalleryLoadingMore; - rowElement.appendChild(loadingIndicator); - }, - - _hideLoadingMore: function() { - const loadingIndicator = this._shadow.querySelector('.phoenix-loading-more'); - if (loadingIndicator) { - loadingIndicator.remove(); - } - }, - - _attachEventHandlers: function() { - const ribbonContainer = this._shadow.querySelector('.phoenix-image-gallery-container'); - const ribbonStrip = this._shadow.querySelector('.phoenix-image-gallery-strip'); - const searchInput = this._shadow.querySelector('.search-wrapper input'); - const searchButton = this._shadow.querySelector('.search-icon'); - const closeButton = this._shadow.querySelector('.phoenix-image-gallery-close-button'); - const folderSettingsButton = this._shadow.querySelector('.phoenix-image-gallery-download-folder-button'); - const navLeft = this._shadow.querySelector('.phoenix-image-gallery-nav.left'); - const navRight = this._shadow.querySelector('.phoenix-image-gallery-nav.right'); - const selectImageBtn = this._shadow.querySelector('.phoenix-image-gallery-upload-container button'); - const fileInput = this._shadow.querySelector('.phoenix-file-input'); - - if (searchInput && searchButton) { - const performSearch = (e) => { - e.stopPropagation(); - const query = searchInput.value.trim(); - if (query) { - // reset pagination when searching - this.currentPage = 1; - this.allImages = []; - this.scrollPosition = 0; - this._fetchImages(query); - } - }; - - // disable/enable search button as per input container text - const updateSearchButtonState = () => { - searchButton.disabled = searchInput.value.trim().length === 0; - }; - - searchInput.addEventListener('input', updateSearchButtonState); - - searchInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - performSearch(e); - } - }); - - searchInput.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - searchButton.addEventListener('click', performSearch); - } - - if (selectImageBtn && fileInput) { - selectImageBtn.addEventListener('click', (e) => { - e.stopPropagation(); - fileInput.click(); - }); - - fileInput.addEventListener('change', (e) => { - e.stopPropagation(); - const file = e.target.files[0]; - if (file) { - this._handleLocalImageSelection(file); - fileInput.value = ''; - } - }); - } - - if (closeButton) { - closeButton.addEventListener('click', (e) => { - e.stopPropagation(); - this.remove(); - imageGallerySelected = false; - _handleImageGalleryStateChange(); - dismissUIAndCleanupState(); - }); - } - - if (folderSettingsButton) { - folderSettingsButton.addEventListener('click', (e) => { - e.stopPropagation(); - // send message to LivePreviewEdit to show folder selection dialog - const tagId = this.element.getAttribute("data-brackets-id"); - window._Brackets_MessageBroker.send({ - livePreviewEditEnabled: true, - resetImageFolderSelection: true, - element: this.element, - tagId: Number(tagId) - }); - }); - } - - if (navLeft) { - navLeft.addEventListener('click', (e) => { - e.stopPropagation(); - this._handleNavLeft(); - }); - } +// this is a single file sent to browser preview. keep this light. add features as extensions +// Please do not add any license header in this file as it will end up in distribution bin as is. +/** + * RemoteFunctions define the functions to be executed in the browser. This + * modules should define a single function that returns an object of all + * exported functions. + */ +// eslint-disable-next-line no-unused-vars +function RemoteFunctions(config = {}) { + const GLOBALS = { + // given to internal elements like info box, tool box, image gallery and all other phcode internal elements + // to distinguish between phoenix internal vs user created elements + PHCODE_INTERNAL_ATTR: "data-phcode-internal-c15r5a9", + DATA_BRACKETS_ID_ATTR: "data-brackets-id", // data attribute used to track elements for live preview operations + HIGHLIGHT_CLASSNAME: "__brackets-ld-highlight" // CSS class name used for highlighting elements in live preview + }; - if (navRight) { - navRight.addEventListener('click', (e) => { - e.stopPropagation(); - this._handleNavRight(); - }); - } + const SHARED_STATE = { + __description: "Use this to keep shared state for Live Preview Edit instead of window.*" + }; - // Restore original image when mouse leaves the entire ribbon strip - if (ribbonStrip) { - ribbonStrip.addEventListener('mouseleave', () => { - this.element.src = this._originalImageSrc; - }); - } + let _localHighlight; + let _hoverHighlight; + let _clickHighlight; + let _setup = false; + let _hoverLockTimer = null; - // Prevent clicks anywhere inside the ribbon from bubbling up - if (ribbonContainer) { - ribbonContainer.addEventListener('click', (e) => { - e.stopPropagation(); - }); - } - }, + // this will store the element that was clicked previously (before the new click) + // we need this so that we can remove click styling from the previous element when a new element is clicked + let previouslyClickedElement = null; - // append true means load more images (user clicked on nav-right) - // append false means its a new query - _renderImages: function(images, append = false) { - const rowElement = this._shadow.querySelector('.phoenix-image-gallery-row'); - if (!rowElement) { return; } + var req, timeout; + function animateHighlight(time) { + if(req) { + window.cancelAnimationFrame(req); + window.clearTimeout(timeout); + } + req = window.requestAnimationFrame(redrawHighlights); - const container = this._shadow.querySelector('.phoenix-image-gallery-strip'); - const savedScrollPosition = container ? container.scrollLeft : 0; + timeout = setTimeout(function () { + window.cancelAnimationFrame(req); + req = null; + }, time * 1000); + } - // if not appending we clear the phoenix ribbon - if (!append) { - rowElement.innerHTML = ''; - rowElement.className = 'phoenix-image-gallery-row'; - } else { - // when appending we add the new images at the end - const loadingIndicator = this._shadow.querySelector('.phoenix-loading-more'); - if (loadingIndicator) { - loadingIndicator.remove(); - } + // the following fucntions can be in the handler and live preview will call those functions when the below + // events happen + const allowedHandlerFns = [ + "dismiss", // when handler gets this event, it should dismiss all ui it renders in the live preview + "createToolBox", + "createInfoBox", + "createMoreOptionsDropdown", + // render an icon or html when the selected element toolbox appears in edit mode. + "renderToolBoxItem", + "redraw", + "onElementSelected", // an item is selected in live preview + "onElementCleanup", + "onNonEditableElementClick", // called when user clicks on a non-editable element + "handleConfigChange", + // below function gets called to render the dropdown when user clicks on the ... menu in the tool box, + // the handler should retrun html tor ender the dropdown item. + "renderDropdownItems", + // called when an item is selected from the more options dropdown + "handleDropdownClick", + "reRegisterEventHandlers", + "handleClick", // handle click on an icon in the tool box. + // when escape key is presses in the editor, we may need to dismiss the live edit boxes. + "handleEscapePressFromEditor", + // interaction blocks acts as 'kill switch' to block all kinds of click handlers + // this is done so that links or buttons doesn't perform their natural operation in edit mode + "registerInteractionBlocker", // to block + "unregisterInteractionBlocker", // to unblock + "udpateHotCornerState" // to update the hot corner button when state changes + ]; + + const _toolHandlers = new Map(); + function registerToolHandler(handlerName, handler) { + if(_toolHandlers.get(handlerName)) { + console.error(`lp: Tool handler '${handlerName}' already registered. Ignoring new registration`); + return; + } + if (!handler || typeof handler !== "object") { + console.error(`lp: Tool handler '${handlerName}' value is invalid ${JSON.stringify(handler)}.`); + return; + } + handler.handlerName = handlerName; + for (const key of Object.keys(handler)) { + if (key !== "handlerName" && !allowedHandlerFns.includes(key)) { + console.warn(`lp: Tool handler '${handlerName}' has unknown property '${key}'`, + `should be one of ${allowedHandlerFns.join(",")}`); } + } + _toolHandlers.set(handlerName, handler); + } + function getToolHandler(handlerName) { + return _toolHandlers.get(handlerName); + } + function getAllToolHandlers() { + return Array.from(_toolHandlers.values()); + } - // Create thumbnails from API data - images.forEach(image => { - const thumbDiv = window.document.createElement('div'); - thumbDiv.className = 'phoenix-ribbon-thumb'; - - const img = window.document.createElement('img'); - img.src = image.thumb_url || image.url; - img.alt = image.alt_text || ''; - img.loading = 'lazy'; - - // show hovered image along with dimensions - thumbDiv.addEventListener('mouseenter', () => { - this.element.style.width = this._originalImageStyle.width; - this.element.style.height = this._originalImageStyle.height; - - this.element.style.objectFit = this._originalImageStyle.objectFit || 'cover'; - this.element.src = image.url || image.thumb_url; - }); - - // attribution overlay, we show this only in the image ribbon gallery - const attribution = window.document.createElement('div'); - attribution.className = 'phoenix-ribbon-attribution'; - - const photographer = window.document.createElement('a'); - photographer.className = 'photographer'; - photographer.href = image.photographer_url; - photographer.target = '_blank'; - photographer.rel = 'noopener noreferrer'; - photographer.textContent = (image.user && image.user.name) || 'Anonymous'; - photographer.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - const source = window.document.createElement('a'); - source.className = 'source'; - source.href = image.unsplash_url; - source.target = '_blank'; - source.rel = 'noopener noreferrer'; - source.textContent = 'on Unsplash'; - source.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - attribution.appendChild(photographer); - attribution.appendChild(source); - - // download icon - const downloadIcon = window.document.createElement('div'); - downloadIcon.className = 'phoenix-download-icon'; - downloadIcon.title = config.strings.imageGalleryUseImage; - downloadIcon.innerHTML = ICONS.downloadImage; - - // when the image is clicked we download the image - thumbDiv.addEventListener('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - - // prevent multiple downloads of the same image - if (thumbDiv.classList.contains('downloading')) { return; } - - // show download indicator - this._showDownloadIndicator(thumbDiv); - - const filename = this._generateFilename(image); - const extnName = ".jpg"; - - const downloadUrl = image.url || image.thumb_url; - const downloadLocation = image.download_location; - - this._useImage(downloadUrl, filename, extnName, false, thumbDiv, downloadLocation); - }); - - thumbDiv.appendChild(img); - thumbDiv.appendChild(attribution); - thumbDiv.appendChild(downloadIcon); - rowElement.appendChild(thumbDiv); - }); - - if (append && container && savedScrollPosition > 0) { - setTimeout(() => { - container.scrollLeft = savedScrollPosition; - }, 0); - } + /** + * check if an element is inspectable. + * inspectable elements are those which doesn't have GLOBALS.DATA_BRACKETS_ID_ATTR ('data-brackets-id'), + * this normally happens when content is DOM content is inserted by some scripting language + */ + function isElementInspectable(element, onlyHighlight = false) { + if(config.mode !== 'edit' && !onlyHighlight) { + return false; + } - this._handleNavButtonsDisplay('visible'); - }, + if(element && // element should exist + element.tagName.toLowerCase() !== "body" && // shouldn't be the body tag + element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag + // this attribute is used by phoenix internal elements + !element.closest(`[${GLOBALS.PHCODE_INTERNAL_ATTR}]`) && + !_isInsideHeadTag(element)) { // shouldn't be inside the head tag like meta tags and all + return true; + } + return false; + } - _showError: function(message) { - const rowElement = this._shadow.querySelector('.phoenix-image-gallery-row'); - if (!rowElement) { return; } + /** + * This is a checker function for editable elements, it makes sure that the element satisfies all the required check + * - When onlyHighlight is false → config.mode must be 'edit' + * - When onlyHighlight is true → config.mode can be any mode (doesn't matter) + * @param {DOMElement} element + * @param {boolean} [onlyHighlight=false] - If true, bypasses the mode check + * @returns {boolean} - True if the element is editable else false + */ + function isElementEditable(element, onlyHighlight = false) { + // for an element to be editable it should satisfy all inspectable checks and should also have data-brackets-id + return isElementInspectable(element, onlyHighlight) && element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); + } - rowElement.innerHTML = message; - rowElement.className = 'phoenix-image-gallery-row phoenix-ribbon-error'; + /** + * this function calc the screen offset of an element + * + * @param {DOMElement} element + * @returns {{left: number, top: number}} + */ + function screenOffset(element) { + const elemBounds = element.getBoundingClientRect(); + const body = window.document.body; + let offsetTop; + let offsetLeft; - this._handleNavButtonsDisplay('hidden'); - }, + if (window.getComputedStyle(body).position === "static") { + offsetLeft = elemBounds.left + window.pageXOffset; + offsetTop = elemBounds.top + window.pageYOffset; + } else { + const bodyBounds = body.getBoundingClientRect(); + offsetLeft = elemBounds.left - bodyBounds.left; + offsetTop = elemBounds.top - bodyBounds.top; + } + return { left: offsetLeft, top: offsetTop }; + } - // file name with which we need to save the image - _generateFilename: function(image) { - const photographerName = (image.user && image.user.name) || 'Anonymous'; - const searchTerm = this._currentSearchQuery || 'image'; + const LivePreviewView = { + registerToolHandler: registerToolHandler, + getToolHandler: getToolHandler, + getAllToolHandlers: getAllToolHandlers, + isElementEditable: isElementEditable, + isElementInspectable: isElementInspectable, + isElementVisible: isElementVisible, + screenOffset: screenOffset, + selectElement: selectElement, + brieflyDisableHoverListeners: brieflyDisableHoverListeners, + handleElementClick: handleElementClick, + cleanupPreviousElementState: cleanupPreviousElementState + }; - // clean the search term and the photograper name to write in file name - const cleanSearchTerm = searchTerm.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); - const cleanPhotographerName = photographerName.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + /** + * @type {DOMEditHandler} + */ + var _editHandler; - return `${cleanSearchTerm}-by-${cleanPhotographerName}`; - }, + // the below code comment is replaced by added scripts for extensibility + // DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_CONSTANT_SCRIPTS - _useImage: function(imageUrl, filename, extnName, isLocalFile, thumbDiv, downloadLocation) { - const tagId = this.element.getAttribute("data-brackets-id"); - const downloadId = Date.now() + Math.random(); - - const messageData = { - livePreviewEditEnabled: true, - useImage: true, - imageUrl: imageUrl, - filename: filename, - extnName: extnName, - element: this.element, - tagId: Number(tagId), - downloadLocation: downloadLocation, - downloadId: downloadId - }; + // determine whether an event should be processed for Live Development + function _validEvent(event) { + if (window.navigator.platform.substr(0, 3) === "Mac") { + // Mac + return event.metaKey; + } + // Windows + return event.ctrlKey; + } - // if this is a local file we need some more data before sending it to the editor - if (isLocalFile) { - messageData.isLocalFile = true; - // Convert data URL to binary data array for local files - const byteCharacters = atob(imageUrl.split(',')[1]); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - messageData.imageData = byteNumbers; + // helper function to check if an element is inside the HEAD tag + // we need this because we don't wanna trigger the element highlights on head tag and its children, + // except for ${content}`; - window.document.body.appendChild(toast); - - // Auto-dismiss after 3 seconds - _toastTimeout = setTimeout(() => { - if (toast && toast.parentNode) { - toast.remove(); - } - _toastTimeout = null; - }, 3000); - } - - /** - * this function is to dismiss the toast message - * and clear its timeout (if any) - */ - function dismissToastMessage() { - const toastMessage = window.document.getElementById('phoenix-toast-notification'); - if (toastMessage) { - toastMessage.remove(); - } - if (_toastTimeout) { - clearTimeout(_toastTimeout); - } - _toastTimeout = null; - } - /** * Helper function to cleanup previously clicked element highlighting and state */ @@ -4650,6 +1278,16 @@ function RemoteFunctions(config = {}) { if (_hoverHighlight) { _hoverHighlight.clear(); } + if (_clickHighlight) { + _clickHighlight.clear(); + } + + // Notify handlers about cleanup + getAllToolHandlers().forEach(handler => { + if (handler.onElementCleanup) { + handler.onElementCleanup(); + } + }); previouslyClickedElement = null; } @@ -4660,223 +1298,92 @@ function RemoteFunctions(config = {}) { * Called when user presses Esc key, clicks on HTML/Body tags, or other dismissal events */ function dismissUIAndCleanupState() { - dismissAllUIBoxes(); + getAllToolHandlers().forEach(handler => (handler.dismiss && handler.dismiss())); // to dismiss all UI boxes cleanupPreviousElementState(); } - /** - * this is a hard reset function, it resets every live preview edit thing, whether it be UI boxes - * highlighting, any timers or anything - */ - function resetState() { - _stopAutoScroll(); - - if (_hoverHighlight) { - _hoverHighlight.clear(); - _hoverHighlight = null; - } - if (_clickHighlight) { - _clickHighlight.clear(); - _clickHighlight = null; - } - - dismissUIAndCleanupState(); - - const allElements = window.document.querySelectorAll("[data-brackets-id]"); - for (let i = 0; i < allElements.length; i++) { - if (allElements[i]._originalBackgroundColor !== undefined) { - clearElementBackground(allElements[i]); - } - } - - if (config.isProUser) { - _hoverHighlight = new Highlight("#c8f9c5", true); - _clickHighlight = new Highlight("#cfc", true); - } - } - - - /** - * This function is responsible to move the cursor to the end of the text content when we start editing - * @param {DOMElement} element - */ - function moveCursorToEnd(selection, element) { - const range = document.createRange(); - range.selectNodeContents(element); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - - // Function to handle direct editing of elements in the live preview - function startEditing(element) { - if (!isElementEditable(element)) { - return; - } - - // Make the element editable - element.setAttribute("contenteditable", "true"); - element.focus(); - // to compare with the new text content, if same we don't make any changes in the editor area - const oldContent = element.textContent; - - // Move cursor to end if no existing selection - const selection = window.getSelection(); - if (selection.rangeCount === 0 || selection.isCollapsed) { - moveCursorToEnd(selection, element); - } - - dismissUIAndCleanupState(); - - // flag to check if escape is pressed, if pressed we prevent onBlur from handling it as keydown already handles - let isEscapePressed = false; - - function onBlur() { - // Small delay so that keydown can handle things first - setTimeout(() => { - if (isEscapePressed) { - isEscapePressed = false; - finishEditingCleanup(element); - return; - } - - const newContent = element.textContent; - if (oldContent !== newContent) { - finishEditing(element); - } else { // if same content, we just cleanup things - finishEditingCleanup(element); - } - }, 10); - } - - function onKeyDown(event) { - if (event.key === "Escape") { - isEscapePressed = true; - // Cancel editing - event.preventDefault(); - const newContent = element.textContent; - if (oldContent !== newContent) { - finishEditing(element, false); // false means that the edit operation was cancelled - } else { // no content change we can avoid sending details to the editor - finishEditingCleanup(element); - } - } else if (event.key === "Enter" && !event.shiftKey) { - isEscapePressed = false; - // Finish editing on Enter (unless Shift is held) - event.preventDefault(); - finishEditing(element); - } else if ((event.key === " " || event.key === "Spacebar") && element.tagName.toLowerCase() === 'button') { - event.preventDefault(); - document.execCommand("insertText", false, " "); - } - } - - element.addEventListener("blur", onBlur); - element.addEventListener("keydown", onKeyDown); - - // Store the event listeners for later removal - element._editListeners = { - blur: onBlur, - keydown: onKeyDown - }; - } - - function finishEditingCleanup(element) { - if (!isElementEditable(element) || !element.hasAttribute("contenteditable")) { - return; - } - - // Remove contenteditable attribute - element.removeAttribute("contenteditable"); - dismissUIAndCleanupState(); - - // Remove event listeners - if (element._editListeners) { - element.removeEventListener("blur", element._editListeners.blur); - element.removeEventListener("keydown", element._editListeners.keydown); - delete element._editListeners; - } - } - - // Function to finish editing and apply changes - // isEditSuccessful: this is a boolean value, defaults to true. false only when the edit operation is cancelled - function finishEditing(element, isEditSuccessful = true) { - finishEditingCleanup(element); - - const tagId = element.getAttribute("data-brackets-id"); - window._Brackets_MessageBroker.send({ - livePreviewEditEnabled: true, - livePreviewTextEdit: true, - element: element, - newContent: element.outerHTML, - tagId: Number(tagId), - isEditSuccessful: isEditSuccessful - }); - } - // init _editHandler = new DOMEditHandler(window.document); function registerHandlers() { - // clear previous highlighting - if (_hoverHighlight) { - _hoverHighlight.clear(); - _hoverHighlight = null; - } - if (_clickHighlight) { - _clickHighlight.clear(); - _clickHighlight = null; - } - - // Always remove existing listeners first to avoid duplicates - window.document.removeEventListener("mouseover", onElementHover); - window.document.removeEventListener("mouseout", onElementHoverOut); - window.document.removeEventListener("click", onClick); - window.document.removeEventListener("dblclick", onDoubleClick); - window.document.removeEventListener("dragover", onDragOver); - window.document.removeEventListener("drop", onDrop); - window.document.removeEventListener("dragleave", onDragLeave); + hideHighlight(); // clear previous highlighting + disableHoverListeners(); // Always remove existing listeners first to avoid duplicates window.document.removeEventListener("keydown", onKeyDown); + getAllToolHandlers().forEach(handler => { + if (handler.unregisterInteractionBlocker) { + handler.unregisterInteractionBlocker(); + } + }); - if (config.isProUser) { + if (config.mode === 'edit') { // Initialize hover highlight with Chrome-like colors _hoverHighlight = new Highlight("#c8f9c5", true); // Green similar to Chrome's padding color // Initialize click highlight with animation _clickHighlight = new Highlight("#cfc", true); // Light green for click highlight - window.document.addEventListener("mouseover", onElementHover); - window.document.addEventListener("mouseout", onElementHoverOut); - window.document.addEventListener("click", onClick); - window.document.addEventListener("dblclick", onDoubleClick); - window.document.addEventListener("dragover", onDragOver); - window.document.addEventListener("drop", onDrop); - window.document.addEventListener("dragleave", onDragLeave); + // register the event handlers + enableHoverListeners(); window.document.addEventListener("keydown", onKeyDown); + + // this is to block all the interactions of the user created elements + // so that lets say user created link doesn't redirect in edit mode + getAllToolHandlers().forEach(handler => { + if (handler.registerInteractionBlocker) { + handler.registerInteractionBlocker(); + } + }); } else { // Clean up any existing UI when edit features are disabled dismissUIAndCleanupState(); } + getAllToolHandlers().forEach(handler => { + if (handler.reRegisterEventHandlers) { + handler.reRegisterEventHandlers(); + } + }); } - registerHandlers(); + function _escapeKeyPressInEditor() { + enableHoverListeners(); // so that if hover lock is there it will get cleared + dismissUIAndCleanupState(); + getAllToolHandlers().forEach(handler => { + if (handler.handleEscapePressFromEditor) { + handler.handleEscapePressFromEditor(); + } + }); + } - return { - "DOMEditHandler" : DOMEditHandler, - "hideHighlight" : hideHighlight, - "highlight" : highlight, - "highlightRule" : highlightRule, - "redrawHighlights" : redrawHighlights, - "redrawEverything" : redrawEverything, - "applyDOMEdits" : applyDOMEdits, - "updateConfig" : updateConfig, - "startEditing" : startEditing, - "finishEditing" : finishEditing, - "hasVisibleLivePreviewBoxes" : hasVisibleLivePreviewBoxes, - "dismissUIAndCleanupState" : dismissUIAndCleanupState, - "resetState" : resetState, - "enableHoverListeners" : enableHoverListeners, - "registerHandlers" : registerHandlers, - "handleDownloadEvent" : handleDownloadEvent + // we need to refresh the config once the load is completed + // this is important because messageBroker gets ready for use only when load fires + window.addEventListener('load', function() { + window._Brackets_MessageBroker.send({ + requestConfigRefresh: true + }); + }); + + let customReturns = {}; + // only apis that needs to be called from phoenix js layer should be customReturns. APis that are shared within + // the remote function context only should not be in customReturns and should be in + // either SHARED_STATE for state vars, GLOBALS for global vars, or LivePreviewView for shared functions. + customReturns = { // we have to do this else the minifier will strip the customReturns variable + ...customReturns, + "DOMEditHandler": DOMEditHandler, + "hideHighlight": hideHighlight, + "highlight": highlight, + "highlightRule": highlightRule, + "redrawHighlights": redrawHighlights, + "redrawEverything": redrawEverything, + "applyDOMEdits": applyDOMEdits, + "updateConfig": updateConfig, + "dismissUIAndCleanupState": dismissUIAndCleanupState, + "escapeKeyPressInEditor": _escapeKeyPressInEditor, + "getMode": function() { return config.mode; } }; + + // the below code comment is replaced by added scripts for extensibility + // DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_SCRIPTS + + registerHandlers(); + return customReturns; } diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js index 8d8598addf..b84e15290e 100644 --- a/src/LiveDevelopment/LiveDevMultiBrowser.js +++ b/src/LiveDevelopment/LiveDevMultiBrowser.js @@ -87,6 +87,7 @@ define(function (require, exports, module) { LivePreviewTransport = require("LiveDevelopment/MultiBrowserImpl/transports/LivePreviewTransport"), LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"), Metrics = require("utils/Metrics"), + WorkspaceManager = require("view/WorkspaceManager"), PageLoaderWorkerScript = require("text!LiveDevelopment/BrowserScripts/pageLoaderWorker.js"); // Documents @@ -128,6 +129,8 @@ define(function (require, exports, module) { */ var _server; + let _config = {}; + /** * @private * Determine which live document class should be used for a given document @@ -375,6 +378,18 @@ define(function (require, exports, module) { ); } + function _updateVirtualServerScripts() { + if(!_server || !_liveDocument || !_liveDocument.doc){ + return; + } + _server.addVirtualContentAtPath( + `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME}`, + _protocol.getRemoteScriptContents()); + _server.addVirtualContentAtPath( + `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME}`, + PageLoaderWorkerScript); + } + /** * @private * Creates the main live document for a given HTML document and notifies the server it exists. @@ -389,12 +404,7 @@ define(function (require, exports, module) { return; } _server.add(_liveDocument); - _server.addVirtualContentAtPath( - `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME}`, - _protocol.getRemoteScriptContents()); - _server.addVirtualContentAtPath( - `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME}`, - PageLoaderWorkerScript); + _updateVirtualServerScripts(); } @@ -435,7 +445,6 @@ define(function (require, exports, module) { const urlString = `${url.origin}${url.pathname}`; if (_liveDocument && urlString === _resolveUrl(_liveDocument.doc.file.fullPath)) { _setStatus(STATUS_ACTIVE); - resetLPEditState(); } } Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "connect", @@ -650,7 +659,7 @@ define(function (require, exports, module) { * Initialize the LiveDevelopment module. */ function init(config) { - exports.config = config; + _config = config; MainViewManager .on("currentFileChange", _onFileChange); DocumentManager @@ -701,52 +710,46 @@ define(function (require, exports, module) { } /** - * Check if live preview boxes are currently visible + * Update configuration in the remote browser */ - function hasVisibleLivePreviewBoxes() { - if (_protocol) { - return _protocol.evaluate("_LD.hasVisibleLivePreviewBoxes()"); - } - return false; + function updateConfig(config) { + _config = config; + _updateVirtualServerScripts(); + refreshConfig(); } /** - * Dismiss live preview boxes like info box, options box, AI box + * Refresh all live previews with existing configuration in the remote browser */ - function dismissLivePreviewBoxes() { + function refreshConfig() { if (_protocol) { - _protocol.evaluate("_LD.enableHoverListeners()"); // so that if hover lock is there it will get cleared - _protocol.evaluate("_LD.dismissUIAndCleanupState()"); + _protocol.evaluate("_LD.updateConfig(" + JSON.stringify(_config) + ")"); } } - /** - * Register event handlers in the remote browser for live preview functionality - */ - function registerHandlers() { - if (_protocol) { - _protocol.evaluate("_LD.registerHandlers()"); - } - } /** - * Update configuration in the remote browser + * this function handles escape key for live preview to hide boxes if they are visible + * @param {Event} event */ - function updateConfig(configJSON) { - if (_protocol) { - _protocol.evaluate("_LD.updateConfig(" + JSON.stringify(configJSON) + ")"); + function _handleLivePreviewEscapeKey(event) { + const currLiveDoc = getCurrentLiveDoc(); + if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) { + currLiveDoc.protocol.evaluate("_LD.escapeKeyPressInEditor()"); } + // returning false to let the editor also handle the escape key + return false; } + // allow live preview to handle escape key event + // Escape is mainly to hide boxes if they are visible + WorkspaceManager.addEscapeKeyEventHandler("livePreview", _handleLivePreviewEscapeKey); /** - * this function is to completely reset the live preview edit - * its done so that when live preview is opened/popped out, we can re-update the config so that - * there are no stale markers and edit works perfectly + * gets configuration used to set in the remote browser */ - function resetLPEditState() { - if (_protocol) { - _protocol.evaluate("_LD.resetState()"); - } + function getConfig() { + // not using structured clone as it's not fast for small objects + return JSON.parse(JSON.stringify(_config || {})); } /** @@ -815,10 +818,9 @@ define(function (require, exports, module) { exports.showHighlight = showHighlight; exports.hideHighlight = hideHighlight; exports.redrawHighlight = redrawHighlight; - exports.hasVisibleLivePreviewBoxes = hasVisibleLivePreviewBoxes; - exports.dismissLivePreviewBoxes = dismissLivePreviewBoxes; - exports.registerHandlers = registerHandlers; + exports.getConfig = getConfig; exports.updateConfig = updateConfig; + exports.refreshConfig = refreshConfig; exports.init = init; exports.isActive = isActive; exports.setLivePreviewPinned= setLivePreviewPinned; diff --git a/src/LiveDevelopment/LivePreviewConstants.js b/src/LiveDevelopment/LivePreviewConstants.js new file mode 100644 index 0000000000..cc2a521ef2 --- /dev/null +++ b/src/LiveDevelopment/LivePreviewConstants.js @@ -0,0 +1,44 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global less, Phoenix */ + +/** + * main integrates LiveDevelopment into Brackets + * + * This module creates two menu items: + * + * "Go Live": open or close a Live Development session and visualize the status + * "Highlight": toggle source highlighting + */ +define(function main(require, exports, module) { + exports.LIVE_PREVIEW_MODE = "preview"; + exports.LIVE_HIGHLIGHT_MODE = "highlight"; + exports.LIVE_EDIT_MODE = "edit"; + + exports.PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode"; + + exports.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = "livePreviewElementHighlights"; + exports.HIGHLIGHT_HOVER = "hover"; + exports.HIGHLIGHT_CLICK = "click"; + + exports.PREFERENCE_SHOW_RULER_LINES = "livePreviewShowMeasurements"; +}); diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js index 57b63570e3..cb0d7a554a 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js @@ -22,7 +22,8 @@ define(function (require, exports, module) { - var EditorManager = require("editor/EditorManager"), + const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), + EditorManager = require("editor/EditorManager"), EventDispatcher = require("utils/EventDispatcher"), PreferencesManager = require("preferences/PreferencesManager"), _ = require("thirdparty/lodash"); @@ -34,6 +35,16 @@ define(function (require, exports, module) { */ var SYNC_ERROR_CLASS = "live-preview-sync-error"; + function _simpleHash(str) { + let hash = 5381; + for (let i = 0; i < str.length; ) { + // eslint-disable-next-line no-bitwise + hash = (hash * 33) ^ str.charCodeAt(i++); + } + // eslint-disable-next-line no-bitwise + return hash >>> 0; + } + /** * @constructor * Base class for managing the connection between a live editor and the browser. Provides functions @@ -62,16 +73,11 @@ define(function (require, exports, module) { this._onActiveEditorChange = this._onActiveEditorChange.bind(this); this._onCursorActivity = this._onCursorActivity.bind(this); - this._onHighlightPrefChange = this._onHighlightPrefChange.bind(this); - - EditorManager.on(`activeEditorChange.LiveDocument-${this.doc.file.fullPath}`, this._onActiveEditorChange); - PreferencesManager.stateManager.getPreference("livedevHighlight") - .on(`change.LiveDocument-${this.doc.file.fullPath}`, this._onHighlightPrefChange); - - // Redraw highlights when window gets focus. This ensures that the highlights - // will be in sync with any DOM changes that may have occurred. - $(window).focus(this._onHighlightPrefChange); + // we cant use file paths for event registration - paths may have spaces(treated as an event list separator) + this.fileHashForEvents = _simpleHash(this.doc.file.fullPath); + EditorManager.off(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`); + EditorManager.on(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`, this._onActiveEditorChange); if (editor) { // Attach now @@ -85,12 +91,9 @@ define(function (require, exports, module) { * Closes the live document, terminating its connection to the browser. */ LiveDocument.prototype.close = function () { - + EditorManager.off(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`); this._clearErrorDisplay(); this._detachFromEditor(); - EditorManager.off(`activeEditorChange.LiveDocument-${this.doc.file.fullPath}`); - PreferencesManager.stateManager.getPreference("livedevHighlight") - .off(`change.LiveDocument-${this.doc.file.fullPath}`); }; /** @@ -126,18 +129,6 @@ define(function (require, exports, module) { }; }; - /** - * @private - * Handles changes to the "Live Highlight" preference, switching it on/off in the browser as appropriate. - */ - LiveDocument.prototype._onHighlightPrefChange = function () { - if (this.isHighlightEnabled()) { - this.updateHighlight(); - } else { - this.hideHighlight(); - } - }; - /** * @private * Handles when the active editor changes, attaching to the new editor if it's for the current document. @@ -163,6 +154,7 @@ define(function (require, exports, module) { if (this.editor) { this.setInstrumentationEnabled(true, true); + this.editor.off("cursorActivity", this._onCursorActivity); this.editor.on("cursorActivity", this._onCursorActivity); this.updateHighlight(); } @@ -262,7 +254,7 @@ define(function (require, exports, module) { * @return {boolean} */ LiveDocument.prototype.isHighlightEnabled = function () { - return PreferencesManager.getViewState("livedevHighlight"); + return PreferencesManager.get(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE) !== CONSTANTS.LIVE_PREVIEW_MODE; }; /** diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js index 0d660ec2e2..cfa52533f6 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js @@ -42,7 +42,8 @@ define(function (require, exports, module) { const EventDispatcher = require("utils/EventDispatcher"); // Text of the script we'll inject into the browser that handles protocol requests. - const LiveDevProtocolRemote = require("text!LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js"), + const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), + LiveDevProtocolRemote = require("text!LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js"), DocumentObserver = require("text!LiveDevelopment/BrowserScripts/DocumentObserver.js"), LanguageManager = require("language/LanguageManager"), RemoteFunctions = require("text!LiveDevelopment/BrowserScripts/RemoteFunctions.js"), @@ -52,8 +53,7 @@ define(function (require, exports, module) { HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"), StringUtils = require("utils/StringUtils"), FileViewController = require("project/FileViewController"), - MainViewManager = require("view/MainViewManager"), - LivePreviewEdit = require("LiveDevelopment/LivePreviewEdit"); + MainViewManager = require("view/MainViewManager"); const LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = `phoenix_live_preview_scripts_instrumented_${StringUtils.randomString(8)}.js`; const LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = `pageLoaderWorker_${StringUtils.randomString(8)}.js`; @@ -89,6 +89,16 @@ define(function (require, exports, module) { */ var _responseDeferreds = {}; + let _remoteFunctionProvider = null; + + /** + * The callback fn must return a single text string that will be used as remote function script + * @param callbackFn + */ + function setCustomRemoteFunctionProvider(callbackFn) { + _remoteFunctionProvider = callbackFn; + } + /** * Returns an array of the client IDs that are being managed by this live document. * @return {Array.} @@ -149,9 +159,9 @@ define(function (require, exports, module) { } function _tagSelectedInLivePreview(tagId, nodeName, contentEditable, allSelectors) { - const highlightPref = PreferencesManager.getViewState("livedevHighlight"); - if(!highlightPref){ - // live preview highlight and reverse highlight feature is disabled + const livePreviewMode = PreferencesManager.get(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE); + if(livePreviewMode === CONSTANTS.LIVE_PREVIEW_MODE){ + // hilights are enabled only in edit and highlight mode return; } const liveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(), @@ -203,6 +213,11 @@ define(function (require, exports, module) { // for a fraction of a second. so a size of 1000 should be more than enough. }); + let _livePreviewMessageHandler; + function setLivePreviewMessageHandler(handler) { + _livePreviewMessageHandler = handler; + } + /** * @private * Handles a message received from the remote protocol handler via the transport. @@ -219,16 +234,23 @@ define(function (require, exports, module) { * only processed once and not from any reflections. */ function _receive(clientId, msgStr, messageID) { - var msg = JSON.parse(msgStr), - event = msg.method || "event", - deferred; + const msg = JSON.parse(msgStr), + event = msg.method || "event"; + let deferred; if(messageID && processedMessageIDs.has(messageID)){ return; // this message is already processed. } else if (messageID) { processedMessageIDs.set(messageID, true); } - if (msg.livePreviewEditEnabled) { - LivePreviewEdit.handleLivePreviewEditOperation(msg); + if(_livePreviewMessageHandler) { + let preventDefault = _livePreviewMessageHandler(msg); + if(preventDefault){ + return; + } + } + if(msg.requestConfigRefresh){ + LiveDevMultiBrowser.refreshConfig(); + return; } if (msg.id) { @@ -263,6 +285,11 @@ define(function (require, exports, module) { function _send(msg, clients) { var id = _nextMsgId++, result = new $.Deferred(); + if(!_transport){ + console.error("Cannot send message before live preview transport initialised"); + result.reject(); + return result.promise(); + } // broadcast if there are no specific clients clients = clients || getConnectionIds(); @@ -331,7 +358,6 @@ define(function (require, exports, module) { _transport.start(); } - /** * Returns a script that should be injected into the HTML that's launched in the * browser in order to implement remote commands that handle protocol requests. @@ -343,7 +369,12 @@ define(function (require, exports, module) { // Inject DocumentObserver into the browser (tracks related documents) script += DocumentObserver; // Inject remote functions into the browser. - script += "\nwindow._LD=(" + RemoteFunctions + "(" + JSON.stringify(LiveDevMultiBrowser.config) + "))"; + if(_remoteFunctionProvider){ + script += _remoteFunctionProvider(); + } else { + script += "\nwindow._LD=(" + RemoteFunctions + + "(" + JSON.stringify(LiveDevMultiBrowser.getConfig()) + "))"; + } return "\n" + script + "\n"; } @@ -481,6 +512,8 @@ define(function (require, exports, module) { exports.close = close; exports.getConnectionIds = getConnectionIds; exports.closeAllConnections = closeAllConnections; + exports.setLivePreviewMessageHandler = setLivePreviewMessageHandler; + exports.setCustomRemoteFunctionProvider = setCustomRemoteFunctionProvider; exports.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME; exports.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME; exports.EVENT_LIVE_PREVIEW_CLICKED = EVENT_LIVE_PREVIEW_CLICKED; diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js index ad343d5880..fd6f127b98 100644 --- a/src/LiveDevelopment/main.js +++ b/src/LiveDevelopment/main.js @@ -19,7 +19,7 @@ * */ -/*global less, Phoenix */ +/*global less */ /** * main integrates LiveDevelopment into Brackets @@ -32,7 +32,8 @@ define(function main(require, exports, module) { - const Commands = require("command/Commands"), + const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), + Commands = require("command/Commands"), AppInit = require("utils/AppInit"), MultiBrowserLiveDev = require("LiveDevelopment/LiveDevMultiBrowser"), LivePreviewTransport = require("LiveDevelopment/MultiBrowserImpl/transports/LivePreviewTransport"), @@ -43,24 +44,30 @@ define(function main(require, exports, module) { Strings = require("strings"), ExtensionUtils = require("utils/ExtensionUtils"), StringUtils = require("utils/StringUtils"), - EventDispatcher = require("utils/EventDispatcher"), - WorkspaceManager = require("view/WorkspaceManager"), - EditorManager = require("editor/EditorManager"); + EventDispatcher = require("utils/EventDispatcher"); - - const KernalModeTrust = window.KernalModeTrust; + const LIVE_PREVIEW_MODE = CONSTANTS.LIVE_PREVIEW_MODE, + LIVE_HIGHLIGHT_MODE = CONSTANTS.LIVE_HIGHLIGHT_MODE, + LIVE_EDIT_MODE = CONSTANTS.LIVE_EDIT_MODE; // this will later be assigned its correct values once entitlementsManager loads - let isProUser = false; - let isFreeTrialUser = false; + let hasLiveEditCapability = false; - const EVENT_LIVE_HIGHLIGHT_PREF_CHANGED = "liveHighlightPrefChange"; - const PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode"; + const PREFERENCE_LIVE_PREVIEW_MODE = CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE; // state manager key to track image gallery selected state, by default we keep this as selected // if this is true we show the image gallery when an image element is clicked const IMAGE_GALLERY_STATE = "livePreview.imageGallery.state"; + PreferencesManager.definePreference(PREFERENCE_LIVE_PREVIEW_MODE, "string", LIVE_HIGHLIGHT_MODE, { + description: StringUtils.format( + Strings.LIVE_PREVIEW_MODE_PREFERENCE, LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE, LIVE_EDIT_MODE), + values: [LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE, LIVE_EDIT_MODE] + }).on("change", function () { + // when mode changes we update the config mode and notify remoteFunctions so that it can get updated + _previewModeUpdated(); + }); + /** * get the image gallery state from StateManager * @returns {boolean} true (default) @@ -78,83 +85,25 @@ define(function main(require, exports, module) { StateManager.set(IMAGE_GALLERY_STATE, state); // update the config with the new state + const config = MultiBrowserLiveDev.getConfig(); config.imageGalleryState = state; - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - } + MultiBrowserLiveDev.updateConfig(config); } - var params = new UrlParams(); - var config = { - experimental: false, // enable experimental features - debug: true, // enable debug output and helpers - highlight: true, // enable highlighting? - highlightConfig: { // the highlight configuration for the Inspector - borderColor: {r: 255, g: 229, b: 153, a: 0.66}, - contentColor: {r: 111, g: 168, b: 220, a: 0.55}, - marginColor: {r: 246, g: 178, b: 107, a: 0.66}, - paddingColor: {r: 147, g: 196, b: 125, a: 0.66}, - showInfo: true - }, - isProUser: isProUser, - elemHighlights: "hover", // default value, this will get updated when the extension loads - imageGalleryState: _getImageGalleryState(), // image gallery selected state - // this strings are used in RemoteFunctions.js - // we need to pass this through config as remoteFunctions runs in browser context and cannot - // directly reference Strings file - strings: { - selectParent: Strings.LIVE_DEV_MORE_OPTIONS_SELECT_PARENT, - editText: Strings.LIVE_DEV_MORE_OPTIONS_EDIT_TEXT, - duplicate: Strings.LIVE_DEV_MORE_OPTIONS_DUPLICATE, - delete: Strings.LIVE_DEV_MORE_OPTIONS_DELETE, - ai: Strings.LIVE_DEV_MORE_OPTIONS_AI, - imageGallery: Strings.LIVE_DEV_MORE_OPTIONS_IMAGE_GALLERY, - aiPromptPlaceholder: Strings.LIVE_DEV_AI_PROMPT_PLACEHOLDER, - imageGalleryUseImage: Strings.LIVE_DEV_IMAGE_GALLERY_USE_IMAGE, - imageGallerySelectDownloadFolder: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_DOWNLOAD_FOLDER, - imageGallerySearchPlaceholder: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER, - imageGallerySearchButton: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_BUTTON, - imageGalleryLoadingInitial: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_INITIAL, - imageGalleryLoadingMore: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_MORE, - imageGalleryNoImages: Strings.LIVE_DEV_IMAGE_GALLERY_NO_IMAGES, - imageGalleryLoadError: Strings.LIVE_DEV_IMAGE_GALLERY_LOAD_ERROR, - imageGalleryClose: Strings.LIVE_DEV_IMAGE_GALLERY_CLOSE, - imageGallerySelectFromComputer: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER, - imageGallerySelectFromComputerTooltip: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER_TOOLTIP, - toastNotEditable: Strings.LIVE_DEV_TOAST_NOT_EDITABLE - } + let params = new UrlParams(); + const defaultConfig = { + mode: LIVE_HIGHLIGHT_MODE, // will be updated when we fetch entitlements + elemHighlights: CONSTANTS.HIGHLIGHT_HOVER, // default value, this will get updated when the extension loads + showRulerLines: false, // default value, this will get updated when the extension loads + imageGalleryState: _getImageGalleryState() // image gallery selected state }; + // Status labels/styles are ordered: error, not connected, progress1, progress2, connected. var _status, _allStatusStyles = ["warning", "info", "success", "out-of-sync", "sync-error"].join(" "); var _$btnGoLive; // reference to the GoLive button - var prefs = PreferencesManager.getExtensionPrefs("livedev"); - - // "livedev.remoteHighlight" preference - var PREF_REMOTEHIGHLIGHT = "remoteHighlight"; - var remoteHighlightPref = prefs.definePreference(PREF_REMOTEHIGHLIGHT, "object", { - animateStartValue: { - "background-color": "rgba(0, 162, 255, 0.5)", - "opacity": 0 - }, - animateEndValue: { - "background-color": "rgba(0, 162, 255, 0)", - "opacity": 0.6 - }, - "paddingStyling": { - "background-color": "rgba(200, 249, 197, 0.7)" - }, - "marginStyling": { - "background-color": "rgba(249, 204, 157, 0.7)" - }, - "borderColor": "rgba(200, 249, 197, 0.85)", - "showPaddingMargin": true - }, { - description: Strings.DESCRIPTION_LIVE_DEV_HIGHLIGHT_SETTINGS - }); - /** Load Live Development LESS Style */ function _loadStyles() { var lessText = require("text!LiveDevelopment/main.less"); @@ -269,105 +218,31 @@ define(function main(require, exports, module) { // Add checkmark when status is STATUS_ACTIVE; otherwise remove it CommandManager.get(Commands.FILE_LIVE_FILE_PREVIEW) .setChecked(status === MultiBrowserLiveDev.STATUS_ACTIVE); - CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT) - .setEnabled(status === MultiBrowserLiveDev.STATUS_ACTIVE); }); } - function _updateHighlightCheckmark() { - CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).setChecked(config.highlight); - exports.trigger(EVENT_LIVE_HIGHLIGHT_PREF_CHANGED, config.highlight); - } - - function togglePreviewHighlight() { - config.highlight = !config.highlight; - _updateHighlightCheckmark(); - if (config.highlight) { - MultiBrowserLiveDev.showHighlight(); - } else { - MultiBrowserLiveDev.hideHighlight(); - } - PreferencesManager.setViewState("livedevHighlight", config.highlight); - } - - /** Setup window references to useful LiveDevelopment modules */ - function _setupDebugHelpers() { - window.report = function report(params) { window.params = params; console.info(params); }; - } - - /** force reload the live preview currently only with shortcut ctrl-shift-R */ - function _handleReloadLivePreviewCommand() { - if (MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.reload(); - } - } - /** - * this function handles escape key for live preview to hide boxes if they are visible - * @param {Event} event + * Internal api used to update live edit capability status as entitlements changes. calling this will update the UI + * but will not functionally enable live editing capabilities as that are dependent on entitlements framework. + * @param newCapability + * @private */ - function _handleLivePreviewEscapeKey(event) { - // we only handle the escape keypress for live preview when its active - if (MultiBrowserLiveDev.status === MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.dismissLivePreviewBoxes(); - } - // returning false to let the editor also handle the escape key - return false; - } - - // default mode means on first load for pro user we have edit mode - // for free user we have highlight mode - function _getDefaultMode() { - return isProUser ? "edit" : "highlight"; - } - - // to set that mode in the preferences - function _initializeMode() { - if (isFreeTrialUser) { - PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "edit"); - return; - } - - const savedMode = PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode(); - - if (savedMode === "highlight" && isProUser) { - PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "edit"); - } else if (savedMode === "edit" && !isProUser) { - PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight"); - } - } - - // this is called everytime there is a change in entitlements - async function _updateProUserStatus() { - if (!KernalModeTrust) { - return; - } - - try { - const entitlement = await KernalModeTrust.EntitlementsManager.getLiveEditEntitlement(); - - isProUser = entitlement.activated; - isFreeTrialUser = await KernalModeTrust.EntitlementsManager.isInProTrial(); - - config.isProUser = isProUser; - exports.isProUser = isProUser; - exports.isFreeTrialUser = isFreeTrialUser; - - _initializeMode(); - - if (MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - MultiBrowserLiveDev.registerHandlers(); + function _liveEditCapabilityChanged(newCapability) { + if(newCapability !== hasLiveEditCapability){ + hasLiveEditCapability = newCapability; + if(!hasLiveEditCapability && getCurrentMode() === LIVE_EDIT_MODE){ + // downgraded, so we need to disable live edit mode + setMode(LIVE_HIGHLIGHT_MODE); + } else if(hasLiveEditCapability) { + // this means that the user has switched to pro-account and we need to enable live edit mode + // as user may have just logged in with a pro-capable account/upgraded to pro. + setMode(LIVE_EDIT_MODE); } - } catch (error) { - console.error("Error updating pro user status:", error); - isProUser = false; - isFreeTrialUser = false; } } function setMode(mode) { - if (mode === "edit" && !exports.isProUser) { + if (mode === LIVE_EDIT_MODE && !hasLiveEditCapability) { return false; } PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, mode); @@ -375,31 +250,21 @@ define(function main(require, exports, module) { } function getCurrentMode() { - return PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode(); + return PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE); + } + + function isInPreviewMode() { + return getCurrentMode() === LIVE_PREVIEW_MODE; } /** Initialize LiveDevelopment */ AppInit.appReady(function () { params.parse(); - config.remoteHighlight = prefs.get(PREF_REMOTEHIGHLIGHT); - - // init experimental multi-browser implementation - // it can be enable by setting 'livedev.multibrowser' preference to true. - // It has to be initiated at this point in case of dynamically switching - // by changing the preference value. + const config = MultiBrowserLiveDev.getConfig() || defaultConfig; + config.mode = getCurrentMode(); MultiBrowserLiveDev.init(config); _loadStyles(); - _updateHighlightCheckmark(); - - // init pro user status and listen for changes - if (KernalModeTrust) { - _updateProUserStatus(); - KernalModeTrust.EntitlementsManager.on( - KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, - _updateProUserStatus - ); - } // update styles for UI status _status = [ @@ -416,18 +281,6 @@ define(function main(require, exports, module) { _setupGoLiveButton(); _setupGoLiveMenu(); - if (config.debug) { - _setupDebugHelpers(); - } - - remoteHighlightPref - .on("change", function () { - config.remoteHighlight = prefs.get(PREF_REMOTEHIGHLIGHT); - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - } - }); - MultiBrowserLiveDev.on(MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL, function (event, previewDetails) { exports.trigger(exports.EVENT_OPEN_PREVIEW_URL, previewDetails); }); @@ -440,82 +293,62 @@ define(function main(require, exports, module) { MultiBrowserLiveDev.on(MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_RELOAD, function (_event, clientDetails) { exports.trigger(exports.EVENT_LIVE_PREVIEW_RELOAD, clientDetails); }); - - // allow live preview to handle escape key event - // Escape is mainly to hide boxes if they are visible - WorkspaceManager.addEscapeKeyEventHandler("livePreview", _handleLivePreviewEscapeKey); - }); - - // init prefs - PreferencesManager.stateManager.definePreference("livedevHighlight", "boolean", true) - .on("change", function () { - config.highlight = PreferencesManager.getViewState("livedevHighlight"); - _updateHighlightCheckmark(); - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - } - }); - - PreferencesManager.definePreference(PREFERENCE_LIVE_PREVIEW_MODE, "string", _getDefaultMode(), { - description: StringUtils.format(Strings.LIVE_PREVIEW_MODE_PREFERENCE, "'preview'", "'highlight'", "'edit'"), - values: ["preview", "highlight", "edit"] }); - config.highlight = PreferencesManager.getViewState("livedevHighlight"); - - function setLivePreviewEditFeaturesActive(enabled) { - isProUser = enabled; - config.isProUser = enabled; - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - MultiBrowserLiveDev.registerHandlers(); + function _previewModeUpdated() { + const currentMode = getCurrentMode(); + if (currentMode === LIVE_EDIT_MODE && !hasLiveEditCapability) { + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE); + // we will get another update event for this immediately, so just return. + return; } + const config = MultiBrowserLiveDev.getConfig(); + config.mode = currentMode; + MultiBrowserLiveDev.updateConfig(config); } // this function is responsible to update element highlight config // called from live preview extension when preference changes function updateElementHighlightConfig() { - const prefValue = PreferencesManager.get("livePreviewElementHighlights"); - config.elemHighlights = prefValue || "hover"; - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - MultiBrowserLiveDev.registerHandlers(); - } + const prefValue = PreferencesManager.get(CONSTANTS.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT); + const config = MultiBrowserLiveDev.getConfig(); + config.elemHighlights = prefValue || CONSTANTS.HIGHLIGHT_HOVER; + MultiBrowserLiveDev.updateConfig(config); } - // init commands - CommandManager.register(Strings.CMD_LIVE_HIGHLIGHT, Commands.FILE_LIVE_HIGHLIGHT, togglePreviewHighlight); - CommandManager.register(Strings.CMD_RELOAD_LIVE_PREVIEW, Commands.CMD_RELOAD_LIVE_PREVIEW, _handleReloadLivePreviewCommand); - - CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).setEnabled(false); + function updateRulerLinesConfig() { + const prefValue = PreferencesManager.get(CONSTANTS.PREFERENCE_SHOW_RULER_LINES); + const config = MultiBrowserLiveDev.getConfig(); + config.showRulerLines = prefValue || false; + MultiBrowserLiveDev.updateConfig(config); + } EventDispatcher.makeEventDispatcher(exports); - exports.isProUser = isProUser; - exports.isFreeTrialUser = isFreeTrialUser; + // private api + exports._liveEditCapabilityChanged = _liveEditCapabilityChanged; // public events exports.EVENT_OPEN_PREVIEW_URL = MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL; exports.EVENT_CONNECTION_CLOSE = MultiBrowserLiveDev.EVENT_CONNECTION_CLOSE; exports.EVENT_LIVE_PREVIEW_CLICKED = MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_CLICKED; exports.EVENT_LIVE_PREVIEW_RELOAD = MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_RELOAD; - exports.EVENT_LIVE_HIGHLIGHT_PREF_CHANGED = EVENT_LIVE_HIGHLIGHT_PREF_CHANGED; // Export public functions + exports.CONSTANTS = CONSTANTS; exports.openLivePreview = openLivePreview; exports.closeLivePreview = closeLivePreview; exports.isInactive = isInactive; exports.isActive = isActive; exports.setLivePreviewPinned = setLivePreviewPinned; exports.setLivePreviewTransportBridge = setLivePreviewTransportBridge; - exports.togglePreviewHighlight = togglePreviewHighlight; - exports.setLivePreviewEditFeaturesActive = setLivePreviewEditFeaturesActive; exports.setImageGalleryState = setImageGalleryState; exports.updateElementHighlightConfig = updateElementHighlightConfig; + exports.updateRulerLinesConfig = updateRulerLinesConfig; exports.getConnectionIds = MultiBrowserLiveDev.getConnectionIds; exports.getLivePreviewDetails = MultiBrowserLiveDev.getLivePreviewDetails; exports.hideHighlight = MultiBrowserLiveDev.hideHighlight; - exports.dismissLivePreviewBoxes = MultiBrowserLiveDev.dismissLivePreviewBoxes; exports.setMode = setMode; exports.getCurrentMode = getCurrentMode; + exports.isInPreviewMode = isInPreviewMode; }); diff --git a/src/command/Commands.js b/src/command/Commands.js index dd9d5b0e64..188d1dfeff 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -103,9 +103,6 @@ define(function (require, exports, module) { /** Reloads live preview */ exports.CMD_RELOAD_LIVE_PREVIEW = "file.reloadLivePreview"; // LiveDevelopment/main.js _handleReloadLivePreviewCommand() - /** Toggles live highlight */ - exports.FILE_LIVE_HIGHLIGHT = "file.previewHighlight"; // LiveDevelopment/main.js _handlePreviewHighlightCommand() - /** Opens project settings */ exports.FILE_PROJECT_SETTINGS = "file.projectSettings"; // ProjectManager.js _projectSettings() diff --git a/src/editor/EditorHelper/ChangeHelper.js b/src/editor/EditorHelper/ChangeHelper.js index 9b20eb2dc6..3f9d50ba4d 100644 --- a/src/editor/EditorHelper/ChangeHelper.js +++ b/src/editor/EditorHelper/ChangeHelper.js @@ -25,6 +25,11 @@ define(function (require, exports, module) { + let _cutInterceptor = null; + let _copyInterceptor = null; + let _pasteInterceptor = null; + let _keyEventInterceptor = null; + const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"), Menus = require("command/Menus"); @@ -170,6 +175,10 @@ define(function (require, exports, module) { // Redispatch these CodeMirror key events as Editor events function _onKeyEvent(instance, event) { + if(_keyEventInterceptor && _keyEventInterceptor(self, self._codeMirror, event)){ + // the interceptor processed it, so don't pass it along to CodeMirror' + return; + } self.trigger("keyEvent", self, event); // deprecated self.trigger(event.type, self, event); return event.defaultPrevented; // false tells CodeMirror we didn't eat the event @@ -242,6 +251,29 @@ define(function (require, exports, module) { elt.style.textIndent = "-" + off + "px"; elt.style.paddingLeft = off + "px"; }); + self._codeMirror.on("cut", function(cm, e) { + // Let interceptor decide what to do with the event (including preventDefault) + if (_cutInterceptor) { + return _cutInterceptor(self, cm, e); + } + // Otherwise allow normal cut behavior + }); + + self._codeMirror.on("copy", function(cm, e) { + // Let interceptor decide what to do with the event (including preventDefault) + if (_copyInterceptor) { + return _copyInterceptor(self, cm, e); + } + // Otherwise allow normal copy behavior + }); + + self._codeMirror.on("paste", function(cm, e) { + // Let interceptor decide what to do with the event (including preventDefault) + if (_pasteInterceptor) { + return _pasteInterceptor(self, cm, e); + } + // Otherwise allow normal paste behavior + }); } /** @@ -282,5 +314,45 @@ define(function (require, exports, module) { Editor.prototype._dontDismissPopupOnScroll = _dontDismissPopupOnScroll; } + /** + * Sets the cut interceptor function in codemirror + * @param {Function} interceptor - Function(editor, cm, event) that returns true to + preventDefault + */ + function setCutInterceptor(interceptor) { + _cutInterceptor = interceptor; + } + + /** + * Sets the copy interceptor function in codemirror + * @param {Function} interceptor - Function(editor, cm, event) that returns true to + preventDefault + */ + function setCopyInterceptor(interceptor) { + _copyInterceptor = interceptor; + } + + /** + * Sets the paste interceptor function in codemirror + * @param {Function} interceptor - Function(editor, cm, event) that returns true to + preventDefault + */ + function setPasteInterceptor(interceptor) { + _pasteInterceptor = interceptor; + } + + /** + * Sets the key down/up/press interceptor function in codemirror + * @param {Function} interceptor - Function(editor, cm, event) that returns true to + preventDefault + */ + function setKeyEventInterceptor(interceptor) { + _keyEventInterceptor = interceptor; + } + exports.addHelpers =addHelpers; + exports.setCutInterceptor = setCutInterceptor; + exports.setCopyInterceptor = setCopyInterceptor; + exports.setPasteInterceptor = setPasteInterceptor; + exports.setKeyEventInterceptor = setKeyEventInterceptor; }); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js b/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js index 65a65cad0c..e656669fa6 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js @@ -34,8 +34,6 @@ define(function (require, exports, module) { Mustache = require("thirdparty/mustache/mustache"), FileSystem = require("filesystem/FileSystem"), EventDispatcher = require("utils/EventDispatcher"), - CommandManager = require("command/CommandManager"), - Commands = require("command/Commands"), StringUtils = require("utils/StringUtils"), EventManager = require("utils/EventManager"), LivePreviewSettings = require("./LivePreviewSettings"), @@ -730,11 +728,8 @@ define(function (require, exports, module) { }); }); - function _isLiveHighlightEnabled() { - return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked(); - } exports.on(EVENT_EMBEDDED_IFRAME_ESCAPE_PRESS, function () { - if(!_isLiveHighlightEnabled()){ + if(LiveDevelopment.isInPreviewMode()){ return; } utils.focusActiveEditorIfFocusInLivePreview(); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js b/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js index 6a4e385797..77fb640a0c 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js @@ -37,8 +37,6 @@ define(function (require, exports, module) { LivePreviewSettings = require("./LivePreviewSettings"), ProjectManager = require("project/ProjectManager"), EventManager = require("utils/EventManager"), - CommandManager = require("command/CommandManager"), - Commands = require("command/Commands"), Strings = require("strings"), utils = require('./utils'), NativeApp = require("utils/NativeApp"), @@ -777,11 +775,8 @@ define(function (require, exports, module) { } }); - function _isLiveHighlightEnabled() { - return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked(); - } exports.on(EVENT_EMBEDDED_IFRAME_ESCAPE_PRESS, function () { - if(!_isLiveHighlightEnabled()){ + if(LiveDevelopment.isInPreviewMode()){ return; } utils.focusActiveEditorIfFocusInLivePreview(); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index 547851b0ea..bd6187d630 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -36,7 +36,7 @@ */ /*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ -/*global path, jsPromise*/ +/*global path*/ //jshint-ignore:no-start define(function (require, exports, module) { @@ -57,6 +57,7 @@ define(function (require, exports, module) { Strings = require("strings"), Mustache = require("thirdparty/mustache/mustache"), Metrics = require("utils/Metrics"), + CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), LiveDevelopment = require("LiveDevelopment/main"), LiveDevServerManager = require("LiveDevelopment/LiveDevServerManager"), MultiBrowserLiveDev = require("LiveDevelopment/LiveDevMultiBrowser"), @@ -75,6 +76,11 @@ define(function (require, exports, module) { ProDialogs = require("services/pro-dialogs"), utils = require('./utils'); + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + throw new Error("KernalModeTrust is not defined. Cannot boot without trust ring"); + } + const StateManager = PreferencesManager.stateManager; const STATE_CUSTOM_SERVER_BANNER_ACK = "customServerBannerDone"; let customServerModalBar; @@ -93,9 +99,16 @@ define(function (require, exports, module) { const PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode"; // live preview element highlights preference (whether on hover or click) - const PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = "livePreviewElementHighlights"; - PreferencesManager.definePreference(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, "string", "hover", { - description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE + const PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = CONSTANTS.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT; + PreferencesManager.definePreference(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, "string", CONSTANTS.HIGHLIGHT_HOVER, { + description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE, + values: [CONSTANTS.HIGHLIGHT_HOVER, CONSTANTS.HIGHLIGHT_CLICK] + }); + + // live preview ruler lines preference (show/hide ruler lines on element selection) + const PREFERENCE_SHOW_RULER_LINES = CONSTANTS.PREFERENCE_SHOW_RULER_LINES; + PreferencesManager.definePreference(PREFERENCE_SHOW_RULER_LINES, "boolean", false, { + description: Strings.LIVE_DEV_SETTINGS_SHOW_RULER_LINES_PREFERENCE }); const LIVE_PREVIEW_PANEL_ID = "live-preview-panel"; @@ -150,6 +163,18 @@ define(function (require, exports, module) { let connectingOverlayTimer = null; // this is needed as we show the connecting overlay after 3s let connectingOverlayTimeDuration = 3000; + let isProEditUser = false; + // this is called everytime there is a change in entitlements + async function _entitlementsChanged() { + try { + const entitlement = await KernalModeTrust.EntitlementsManager.getLiveEditEntitlement(); + isProEditUser = entitlement.activated; + } catch (error) { + console.error("Error updating pro user status:", error); + isProEditUser = false; + } + } + StaticServer.on(EVENT_EMBEDDED_IFRAME_WHO_AM_I, function () { if($iframe && $iframe[0]) { const iframeDom = $iframe[0]; @@ -189,6 +214,12 @@ define(function (require, exports, module) { // for connecting status, we delay showing the overlay by 3 seconds if(status === MultiBrowserLiveDev.STATUS_CONNECTING) { connectingOverlayTimer = setTimeout(() => { + // before creating the overlays we need to do a recheck for custom server + // cause project prefs sometimes takes time to reload which causes overlays to appear for custom servers + if(LivePreviewSettings.isUsingCustomServer()){ + connectingOverlayTimer = null; + return; + } _createAndShowOverlay(textMessage, status); connectingOverlayTimer = null; }, connectingOverlayTimeDuration); @@ -274,44 +305,6 @@ define(function (require, exports, module) { } } - // this function is to check if the live highlight feature is enabled or not - function _isLiveHighlightEnabled() { - return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked(); - } - - /** - * Live Preview 'Preview Mode'. in this mode no live preview highlight or any such features are active - * Just the plain website - */ - function _LPPreviewMode() { - LiveDevelopment.setLivePreviewEditFeaturesActive(false); - if(_isLiveHighlightEnabled()) { - LiveDevelopment.togglePreviewHighlight(); - } - } - - /** - * Live Preview 'Highlight Mode'. in this mode only the live preview matching with the source code is active - * Meaning that if user clicks on some element that element's source code will be highlighted and vice versa - */ - function _LPHighlightMode() { - LiveDevelopment.setLivePreviewEditFeaturesActive(false); - if(!_isLiveHighlightEnabled()) { - LiveDevelopment.togglePreviewHighlight(); - } - } - - /** - * Live Preview 'Edit Mode'. this is the most interactive mode, in here the highlight features are available - * along with that we also show element's highlighted boxes and such - */ - function _LPEditMode() { - LiveDevelopment.setLivePreviewEditFeaturesActive(true); - if(!_isLiveHighlightEnabled()) { - LiveDevelopment.togglePreviewHighlight(); - } - } - /** * update the mode button text in the live preview toolbar UI based on the current mode * @param {String} mode - The current mode ("preview", "highlight", or "edit") @@ -331,22 +324,18 @@ define(function (require, exports, module) { function _initializeMode() { const currentMode = LiveDevelopment.getCurrentMode(); - if (currentMode === "highlight") { - _LPHighlightMode(); - $previewBtn.removeClass('selected'); - } else if (currentMode === "edit") { - _LPEditMode(); - $previewBtn.removeClass('selected'); - } else { - _LPPreviewMode(); + // when in preview mode, we need to give the play button a selected state + if (currentMode === LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE) { $previewBtn.addClass('selected'); + } else { + $previewBtn.removeClass('selected'); } _updateModeButton(currentMode); } function _showModeSelectionDropdown(event) { - const isEditFeaturesActive = LiveDevelopment.isProUser; + const isEditFeaturesActive = isProEditUser; const items = [ Strings.LIVE_PREVIEW_MODE_PREVIEW, Strings.LIVE_PREVIEW_MODE_HIGHLIGHT, @@ -357,6 +346,7 @@ define(function (require, exports, module) { if (isEditFeaturesActive) { items.push("---"); items.push(Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON); + items.push(Strings.LIVE_PREVIEW_SHOW_RULER_LINES); } const currentMode = LiveDevelopment.getCurrentMode(); @@ -364,22 +354,33 @@ define(function (require, exports, module) { $dropdown = new DropdownButton.DropdownButton("", items, function(item, index) { if (item === Strings.LIVE_PREVIEW_MODE_PREVIEW) { // using empty spaces to keep content aligned - return currentMode === "preview" ? `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; + return currentMode === LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE ? + `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; } else if (item === Strings.LIVE_PREVIEW_MODE_HIGHLIGHT) { - return currentMode === "highlight" ? `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; + return currentMode === LiveDevelopment.CONSTANTS.LIVE_HIGHLIGHT_MODE ? + `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; } else if (item === Strings.LIVE_PREVIEW_MODE_EDIT) { - const checkmark = currentMode === "edit" ? "✓ " : `${'\u00A0'.repeat(4)}`; - const crownIcon = !isEditFeaturesActive ? ' Pro' : ''; + const checkmark = currentMode === LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE ? + "✓ " : `${'\u00A0'.repeat(4)}`; + const crownIcon = !isEditFeaturesActive ? + ' Pro' : ''; return { html: `${checkmark}${item}${crownIcon}`, enabled: true }; } else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) { - const isHoverMode = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT) !== "click"; + const isHoverMode = + PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT) === CONSTANTS.HIGHLIGHT_HOVER; if(isHoverMode) { return `✓ ${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`; } return `${'\u00A0'.repeat(4)}${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`; + } else if (item === Strings.LIVE_PREVIEW_SHOW_RULER_LINES) { + const isEnabled = PreferencesManager.get(PREFERENCE_SHOW_RULER_LINES); + if(isEnabled) { + return `✓ ${Strings.LIVE_PREVIEW_SHOW_RULER_LINES}`; + } + return `${'\u00A0'.repeat(4)}${Strings.LIVE_PREVIEW_SHOW_RULER_LINES}`; } return item; }); @@ -405,11 +406,11 @@ define(function (require, exports, module) { // handle the option selection $dropdown.on("select", function (e, item, index) { if (index === 0) { - LiveDevelopment.setMode("preview"); + LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE); } else if (index === 1) { - LiveDevelopment.setMode("highlight"); + LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_HIGHLIGHT_MODE); } else if (index === 2) { - if (!LiveDevelopment.setMode("edit")) { + if (!LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE)) { ProDialogs.showProUpsellDialog(ProDialogs.UPSELL_TYPE_LIVE_EDIT); } } else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) { @@ -419,14 +420,20 @@ define(function (require, exports, module) { } // Toggle between hover and click const currMode = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT); - const newMode = currMode !== "click" ? "click" : "hover"; + const newMode = (currMode !== CONSTANTS.HIGHLIGHT_CLICK) ? + CONSTANTS.HIGHLIGHT_CLICK : CONSTANTS.HIGHLIGHT_HOVER; PreferencesManager.set(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, newMode); return; // Don't dismiss highlights for this option + } else if (item === Strings.LIVE_PREVIEW_SHOW_RULER_LINES) { + // Don't allow ruler lines toggle if edit features are not active + if (!isEditFeaturesActive) { + return; + } + // Toggle ruler lines on/off + const currentValue = PreferencesManager.get(PREFERENCE_SHOW_RULER_LINES); + PreferencesManager.set(PREFERENCE_SHOW_RULER_LINES, !currentValue); + return; // Don't dismiss highlights for this option } - - // need to dismiss the previous highlighting and stuff - LiveDevelopment.hideHighlight(); - LiveDevelopment.dismissLivePreviewBoxes(); }); // Remove the button after the dropdown is hidden @@ -669,10 +676,14 @@ define(function (require, exports, module) { function _handlePreviewBtnClick() { if($previewBtn.hasClass('selected')) { $previewBtn.removeClass('selected'); - const isEditFeaturesActive = LiveDevelopment.isProUser; + const isEditFeaturesActive = isProEditUser; if(modeThatWasSelected) { - if(modeThatWasSelected === 'edit' && !isEditFeaturesActive) { - // we just set the preference as preference has change handlers that will update the config + // If the last selected mode was preview itself, default to the best mode for user's entitlement + if(modeThatWasSelected === 'preview') { + const defaultMode = isEditFeaturesActive ? 'edit' : 'highlight'; + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, defaultMode); + } else if(modeThatWasSelected === 'edit' && !isEditFeaturesActive) { + // Non-pro users can't be in edit mode - switch to highlight PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight"); } else { PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, modeThatWasSelected); @@ -1191,10 +1202,15 @@ define(function (require, exports, module) { }); CommandManager.register(Strings.CMD_LIVE_FILE_PREVIEW_SETTINGS, Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, _showSettingsDialog); + CommandManager.register(Strings.CMD_RELOAD_LIVE_PREVIEW, Commands.CMD_RELOAD_LIVE_PREVIEW, function () { + _loadPreview(true, true); + }); let fileMenu = Menus.getMenu(Menus.AppMenuBar.FILE_MENU); fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW, "", Menus.AFTER, Commands.FILE_EXTENSION_MANAGER); - fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, "", + fileMenu.addMenuItem(Commands.CMD_RELOAD_LIVE_PREVIEW, "", Menus.AFTER, Commands.FILE_LIVE_FILE_PREVIEW); + fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, "", + Menus.AFTER, Commands.CMD_RELOAD_LIVE_PREVIEW); fileMenu.addMenuDivider(Menus.BEFORE, Commands.FILE_LIVE_FILE_PREVIEW); _registerHandlers(); @@ -1205,13 +1221,17 @@ define(function (require, exports, module) { _initializeMode(); }); - // Handle element highlight preference changes from this extension + // Handle element highlight & ruler lines preference changes PreferencesManager.on("change", PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, function() { LiveDevelopment.updateElementHighlightConfig(); }); + PreferencesManager.on("change", PREFERENCE_SHOW_RULER_LINES, function() { + LiveDevelopment.updateRulerLinesConfig(); + }); - // Initialize element highlight config on startup + // Initialize element highlight and ruler lines config on startup LiveDevelopment.updateElementHighlightConfig(); + LiveDevelopment.updateRulerLinesConfig(); LiveDevelopment.openLivePreview(); LiveDevelopment.on(LiveDevelopment.EVENT_OPEN_PREVIEW_URL, _openLivePreviewURL); @@ -1291,6 +1311,13 @@ define(function (require, exports, module) { } }, 1000); _projectOpened(); + if(!Phoenix.isSpecRunnerWindow){ + _entitlementsChanged(); + KernalModeTrust.EntitlementsManager.on( + KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, + _entitlementsChanged + ); + } }); // private API to be used inside phoenix codebase only diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js index 523db814bb..a93c82ef1d 100644 --- a/src/extensionsIntegrated/loader.js +++ b/src/extensionsIntegrated/loader.js @@ -46,4 +46,5 @@ define(function (require, exports, module) { require("./TabBar/main"); require("./CustomSnippets/main"); require("./CollapseFolders/main"); + require("./pro-loader"); }); diff --git a/src/LiveDevelopment/LivePreviewEdit.js b/src/extensionsIntegrated/phoenix-pro/LivePreviewEdit.js similarity index 67% rename from src/LiveDevelopment/LivePreviewEdit.js rename to src/extensionsIntegrated/phoenix-pro/LivePreviewEdit.js index 851e84d094..e15547be60 100644 --- a/src/LiveDevelopment/LivePreviewEdit.js +++ b/src/extensionsIntegrated/phoenix-pro/LivePreviewEdit.js @@ -1,22 +1,6 @@ /* - * GNU AGPL-3.0 License - * - * Copyright (c) 2021 - present core.ai . All rights reserved. - * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved. - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License - * for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. - * + * Copyright (c) 2021 - present core.ai + * SPDX-License-Identifier: LicenseRef-Proprietary */ /* @@ -28,8 +12,10 @@ define(function (require, exports, module) { const HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"); const LiveDevMultiBrowser = require("LiveDevelopment/LiveDevMultiBrowser"); const LiveDevelopment = require("LiveDevelopment/main"); + const Constants = require("LiveDevelopment/LivePreviewConstants"); const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"); const ProjectManager = require("project/ProjectManager"); + const PreferencesManager = require("preferences/PreferencesManager"); const CommandManager = require("command/CommandManager"); const Commands = require("command/Commands"); const FileSystem = require("filesystem/FileSystem"); @@ -40,13 +26,25 @@ define(function (require, exports, module) { const ProDialogs = require("services/pro-dialogs"); const Mustache = require("thirdparty/mustache/mustache"); const Strings = require("strings"); - const ImageFolderDialogTemplate = require("text!htmlContent/image-folder-dialog.html"); + const ImageFolderDialogTemplate = require("text!./html/image-folder-dialog.html"); + const LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"); + const ProUtils = require("./pro-utils"); + const EditorManager = require("editor/EditorManager"); + const ChangeHelper = require("editor/EditorHelper/ChangeHelper"); + const AppInit = require("utils/AppInit"); + const Editor = require("editor/Editor").Editor; + const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"); // state manager key, to save the download location of the image const IMAGE_DOWNLOAD_FOLDER_KEY = "imageGallery.downloadFolder"; const IMAGE_DOWNLOAD_PERSIST_FOLDER_KEY = "imageGallery.persistFolder"; + // state manager key for tracking if copy/cut toast has been shown + const COPY_CUT_TOAST_SHOWN_KEY = "livePreviewEdit.copyToastShown"; + const DOWNLOAD_EVENTS = { + DIALOG_OPENED: 'dialogOpened', + DIALOG_CLOSED: 'dialogClosed', STARTED: 'downloadStarted', COMPLETED: 'downloadCompleted', CANCELLED: 'downloadCancelled', @@ -72,7 +70,7 @@ define(function (require, exports, module) { * we only care about text changes or things like newlines,
, or formatting like , , etc. * * Here's the basic idea: - * - Parse both old and new HTML strings into DOM trees + * - Parse both old and new HTML strings into document fragments using