diff --git a/.github/workflows/create-deploy.yml b/.github/workflows/create-deploy.yml index e38ec9cc2f..5ab1c8968f 100644 --- a/.github/workflows/create-deploy.yml +++ b/.github/workflows/create-deploy.yml @@ -17,6 +17,9 @@ jobs: run: | npm ci - name: Verifying release artifact build + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run release:prod - name: Deploy Notification To authoring production repository create.phcode.dev diff --git a/.github/workflows/desktop-linux-prod-test-pull.yml b/.github/workflows/desktop-linux-prod-test-pull.yml index e7eac3cc3f..b2933728f9 100644 --- a/.github/workflows/desktop-linux-prod-test-pull.yml +++ b/.github/workflows/desktop-linux-prod-test-pull.yml @@ -1,5 +1,4 @@ name: 'Desktop-Linux prod release full test suite run on pull request' -# we only do this on linux as a sanity. detailed tests will be run on pulls on staging/prod branch on: pull_request: branches: [ main ] @@ -31,6 +30,9 @@ jobs: sudo apt-get install xvfb - name: build phoenix dist-test + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm ci npm run build @@ -39,6 +41,8 @@ jobs: - name: Download phoenix desktop and build test runner env: GH_TOKEN: ${{ github.token }} + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | cd .. git clone https://github.com/phcode-dev/phoenix-desktop.git diff --git a/.github/workflows/desktop-linux-test-pull.yml b/.github/workflows/desktop-linux-test-pull.yml index ec09291a92..b6f9aa0b27 100644 --- a/.github/workflows/desktop-linux-test-pull.yml +++ b/.github/workflows/desktop-linux-test-pull.yml @@ -30,6 +30,9 @@ jobs: sudo apt-get install xvfb - name: build phoenix dist-test + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm ci npm run build @@ -38,6 +41,8 @@ jobs: - name: Download phoenix desktop and build test runner env: GH_TOKEN: ${{ github.token }} + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | cd .. git clone https://github.com/phcode-dev/phoenix-desktop.git diff --git a/.github/workflows/desktop-mac-m1-prod-test-pull.yml b/.github/workflows/desktop-mac-m1-prod-test-pull.yml new file mode 100644 index 0000000000..54a6f7bd88 --- /dev/null +++ b/.github/workflows/desktop-mac-m1-prod-test-pull.yml @@ -0,0 +1,111 @@ +name: 'Desktop-mac M1 prod release full test suite run on pull request' +on: + pull_request: + branches: [ main ] + +concurrency: + group: pr-desktop-mac-m1-prod-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test-desktop-mac-m1-prod: + runs-on: macos-15 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v3 + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.85.1 + + - name: build phoenix dist-test + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} + run: | + npm ci + npm run build + npm run release:prod + + - name: Download phoenix desktop and build test runner + env: + GH_TOKEN: ${{ github.token }} + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} + run: | + cd .. + git clone https://github.com/phcode-dev/phoenix-desktop.git + cd phoenix-desktop + npm ci + npm run releaseDistTestDebug + + - name: Run tauri unit tests + uses: nick-fields/retry@v2 + id: macM1RunUnit + continue-on-error: true + with: + timeout_minutes: 12 + max_attempts: 3 + command: ../phoenix-desktop/src-tauri/target/debug/phoenix-test --run-tests=unit -q + + - name: Run tauri integration tests + uses: nick-fields/retry@v2 + id: macM1RunIntegration + continue-on-error: true + with: + timeout_minutes: 12 + max_attempts: 3 + command: ../phoenix-desktop/src-tauri/target/debug/phoenix-test --run-tests=integration -q + + - name: Run tauri mainview tests + uses: nick-fields/retry@v2 + id: macM1RunMainview + continue-on-error: true + with: + timeout_minutes: 12 + max_attempts: 3 + command: ../phoenix-desktop/src-tauri/target/debug/phoenix-test --run-tests=mainview -q + + - name: Run tauri livepreview tests + uses: nick-fields/retry@v2 + id: macM1RunLivepreview + continue-on-error: true + with: + timeout_minutes: 12 + max_attempts: 3 + command: ../phoenix-desktop/src-tauri/target/debug/phoenix-test --run-tests=livepreview -q + + - name: Run tauri LegacyInteg tests + uses: nick-fields/retry@v2 + id: macM1RunLegacyInteg + continue-on-error: true + with: + timeout_minutes: 20 + max_attempts: 3 + command: ../phoenix-desktop/src-tauri/target/debug/phoenix-test --run-tests=LegacyInteg -q + + - name: Fail on test runs failed in Mac M1 (prod) + if: steps.macM1RunUnit.outcome == 'failure' || steps.macM1RunIntegration.outcome == 'failure' || steps.macM1RunMainview.outcome == 'failure' || steps.macM1RunLivepreview.outcome == 'failure' || steps.macM1RunLegacyInteg.outcome == 'failure' + run: | + echo "Mac M1 (prod) tests failed, marking step as failed" + echo "Failed tests:" + if [ "${{ steps.macM1RunUnit.outcome }}" == "failure" ]; then + echo "- Run tauri unit tests" + fi + if [ "${{ steps.macM1RunIntegration.outcome }}" == "failure" ]; then + echo "- Run tauri integration tests" + fi + if [ "${{ steps.macM1RunMainview.outcome }}" == "failure" ]; then + echo "- Run tauri mainview tests" + fi + if [ "${{ steps.macM1RunLivepreview.outcome }}" == "failure" ]; then + echo "- Run tauri livepreview tests" + fi + if [ "${{ steps.macM1RunLegacyInteg.outcome }}" == "failure" ]; then + echo "- Run tauri LegacyInteg tests" + fi + exit 1 diff --git a/.github/workflows/desktop-mac-m1-test-pull.yml b/.github/workflows/desktop-mac-m1-test-pull.yml index c3b482d548..e51a98380c 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 @@ -23,6 +23,9 @@ jobs: toolchain: 1.85.1 - name: build phoenix dist-test + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm ci npm run build @@ -31,6 +34,8 @@ jobs: - name: Download phoenix desktop and build test runner env: GH_TOKEN: ${{ github.token }} + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | cd .. git clone https://github.com/phcode-dev/phoenix-desktop.git diff --git a/.github/workflows/desktop-mac-test-pull.yml b/.github/workflows/desktop-mac-test-pull.yml index 22e7e2f475..98429cfeba 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 @@ -23,6 +23,9 @@ jobs: toolchain: 1.85.1 - name: build phoenix dist-test + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm ci npm run build @@ -31,6 +34,8 @@ jobs: - name: Download phoenix desktop and build test runner env: GH_TOKEN: ${{ github.token }} + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | cd .. git clone https://github.com/phcode-dev/phoenix-desktop.git diff --git a/.github/workflows/desktop-windows-test-pull.yml b/.github/workflows/desktop-windows-test-pull.yml index 8bfeee86b1..85c4c99c3c 100644 --- a/.github/workflows/desktop-windows-test-pull.yml +++ b/.github/workflows/desktop-windows-test-pull.yml @@ -23,6 +23,9 @@ jobs: toolchain: 1.85.1 - name: build phoenix dist-test + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm ci npm run build @@ -31,6 +34,8 @@ jobs: - name: Download phoenix desktop and build test runner env: GH_TOKEN: ${{ github.token }} + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | cd .. git clone https://github.com/phcode-dev/phoenix-desktop.git diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index 39d30266b7..5b9fa02350 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -13,6 +13,9 @@ jobs: run: | npm ci - name: Verifying release artifact build + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run release:dev - name: Deploy Notification To dev.phcode.dev repository diff --git a/.github/workflows/playwright-chromium-linux.yml b/.github/workflows/playwright-chromium-linux.yml index 3d2bb5419f..8c0e126342 100644 --- a/.github/workflows/playwright-chromium-linux.yml +++ b/.github/workflows/playwright-chromium-linux.yml @@ -20,6 +20,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: npm run build - name: Install Playwright Browsers run: npx playwright install --with-deps diff --git a/.github/workflows/playwright-chromium-macos.yml b/.github/workflows/playwright-chromium-macos.yml index 26b3854fc2..6cb1af1607 100644 --- a/.github/workflows/playwright-chromium-macos.yml +++ b/.github/workflows/playwright-chromium-macos.yml @@ -20,6 +20,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: npm run build - name: Install Playwright Browsers run: npx playwright install --with-deps diff --git a/.github/workflows/playwright-chromium-windows-prod.yml b/.github/workflows/playwright-chromium-windows-prod.yml index 0909eb6d60..8a1087b938 100644 --- a/.github/workflows/playwright-chromium-windows-prod.yml +++ b/.github/workflows/playwright-chromium-windows-prod.yml @@ -21,6 +21,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run build npm run release:prod diff --git a/.github/workflows/playwright-chromium-windows.yml b/.github/workflows/playwright-chromium-windows.yml index c302710da6..13cfec9396 100644 --- a/.github/workflows/playwright-chromium-windows.yml +++ b/.github/workflows/playwright-chromium-windows.yml @@ -20,6 +20,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: npm run build - name: Install Playwright Browsers run: npx playwright install --with-deps diff --git a/.github/workflows/playwright-firefox-linux.yml b/.github/workflows/playwright-firefox-linux.yml index f61e07ca6c..02e8e62465 100644 --- a/.github/workflows/playwright-firefox-linux.yml +++ b/.github/workflows/playwright-firefox-linux.yml @@ -20,6 +20,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: npm run build - name: Install Playwright Browsers run: npx playwright install --with-deps diff --git a/.github/workflows/playwright-firefox-windows.yml b/.github/workflows/playwright-firefox-windows.yml index 69b75852a3..e225a123b4 100644 --- a/.github/workflows/playwright-firefox-windows.yml +++ b/.github/workflows/playwright-firefox-windows.yml @@ -20,6 +20,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: npm run build - name: Install Playwright Browsers run: npx playwright install --with-deps diff --git a/.github/workflows/playwright-staging-prod.yml b/.github/workflows/playwright-staging-prod.yml index e8e10a76e5..dd95030491 100644 --- a/.github/workflows/playwright-staging-prod.yml +++ b/.github/workflows/playwright-staging-prod.yml @@ -17,6 +17,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run build npm run release:prod @@ -102,6 +105,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run build npm run release:prod @@ -178,7 +184,7 @@ jobs: playwrightChromiumMacos: timeout-minutes: 90 - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v3 - name: setup node @@ -188,6 +194,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run build npm run release:prod @@ -273,6 +282,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run build npm run release:prod @@ -358,6 +370,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run build npm run release:prod @@ -434,7 +449,7 @@ jobs: playwrightFirefoxMacos: timeout-minutes: 90 - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v3 - name: setup node @@ -444,6 +459,9 @@ jobs: - name: Install dependencies run: npm ci - name: Build phoenix + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run build npm run release:prod diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 0c6ac530d2..1c371e5057 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -17,6 +17,9 @@ jobs: run: | npm ci - name: Verifying release artifact build + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run release:prod - name: Deploy Notification To production repository phcode.dev diff --git a/.github/workflows/pull_request_verify.yml b/.github/workflows/pull_request_verify.yml index d295ee8ae9..c2068ddee1 100644 --- a/.github/workflows/pull_request_verify.yml +++ b/.github/workflows/pull_request_verify.yml @@ -20,5 +20,8 @@ jobs: run: | npm run lint - name: building Phoenix with npm run build + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run build diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 3ecd2f7c29..58b72072a6 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -17,6 +17,9 @@ jobs: run: | npm ci - name: Verifying release artifact build + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run release:staging - name: Deploy Notification To staging.phcode.dev repository diff --git a/.github/workflows/tauri-deploy.yml b/.github/workflows/tauri-deploy.yml index a0bdfc7be7..0db082ddfb 100644 --- a/.github/workflows/tauri-deploy.yml +++ b/.github/workflows/tauri-deploy.yml @@ -22,6 +22,9 @@ jobs: run: | npm ci - name: Verifying release artifact build + env: + PRO_REPO_URL: ${{ secrets.PRO_REPO_URL }} + PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run release:prod - name: Deploy Notification To phoenix-desktop repo diff --git a/.gitignore b/.gitignore index da9a874b66..da9257abfa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ Thumbs.db /npm-debug.log /src/cacheManifest.json /src/appConfig.js +/src/extensionsIntegrated/pro-loader.js +/test/pro-test-suite.js +/src/extensionsIntegrated/phoenix-pro # 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..ba009110e6 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -30,12 +30,14 @@ const { src, dest, series } = require('gulp'); const zip = require('gulp-zip'); const Translate = require("./translateStrings"); const copyThirdPartyLibs = require("./thirdparty-lib-copy"); +const optionalBuild = require("./optional-build"); +const validateBuild = require("./validate-build"); const minify = require('gulp-minify'); const glob = require("glob"); -const sourcemaps = require('gulp-sourcemaps'); const crypto = require("crypto"); const rename = require("gulp-rename"); const execSync = require('child_process').execSync; +const terser = require('terser'); function cleanDist() { return del(['dist', 'dist-test']); @@ -57,11 +59,13 @@ function cleanAll() { // Test artifacts 'dist-test', 'test/spec/test_folders.zip', + 'src/extensionsIntegrated/pro-loader.js', + 'test/pro-test-suite.js', ...RELEASE_BUILD_ARTEFACTS ]); } -function cleanUnwantedFilesInDist() { +function cleanUnwantedFilesInDistDev() { return del([ 'dist/nls/*/expertTranslations.json', 'dist/nls/*/lastTranslated.json', @@ -71,6 +75,55 @@ function cleanUnwantedFilesInDist() { ]); } +function cleanUnwantedFilesInDistProd() { + return del([ + 'dist/nls/*/expertTranslations.json', + 'dist/nls/*/lastTranslated.json', + 'dist/nls/*/*.js.map', + 'dist/extensions/default/*/unittests.js.map', + 'dist/**/*no_dist.*', + 'dist/thirdparty/no-minify/language-worker.js.map' + ]); +} + +function _cleanPhoenixProGitFolder() { + return new Promise((resolve) => { + const gitFolders = [ + 'dist/extensionsIntegrated/phoenix-pro/.git', + 'dist-test/src/extensionsIntegrated/phoenix-pro/.git' + ]; + + for (const gitFolder of gitFolders) { + if (fs.existsSync(gitFolder)) { + fs.rmSync(gitFolder, { recursive: true, force: true }); + console.log(`Removed git folder: ${gitFolder}`); + } + } + resolve(); + }); +} + +function _deletePhoenixProSourceFolder() { + return new Promise((resolve) => { + const phoenixProFolders = [ + // we only delete the source folder from the release build artifact and not the test artifact. why below? + 'dist/extensionsIntegrated/phoenix-pro' + // 'dist-test/src/extensionsIntegrated/phoenix-pro' // ideally we should delete this too so that the tests + // test exactly the release build artifact, but the spec runner requires on these files during test start + // and i wasnt able to isolate them. so instead wehat we do now is that we have an additional test in prod + // that checks that the phoenix-pro source folder is not loaded in prod and loaded only from the inlines + // brackets-min file. + ]; + + for (const folder of phoenixProFolders) { + if (fs.existsSync(folder)) { + fs.rmSync(folder, { recursive: true, force: true }); + } + } + resolve(); + }); +} + /** * TODO: Release scripts to merge and min src js/css/html resources into dist. * Links that might help: @@ -89,8 +142,7 @@ function makeDistAll() { function makeJSDist() { return src(['src/**/*.js', '!src/**/unittest-files/**/*', "!src/thirdparty/prettier/**/*", - "!src/thirdparty/no-minify/**/*"]) - .pipe(sourcemaps.init()) + "!src/thirdparty/no-minify/**/*", "!src/LiveDevelopment/BrowserScripts/RemoteFunctions.js"]) .pipe(minify({ ext:{ min:'.js' @@ -99,23 +151,31 @@ function makeJSDist() { mangle: false, compress: { unused: false + }, + preserveComments: function (node, comment) { + const text = (comment.value || "").trim(); + + // license headers should not end up in distribution as the license of dist depends on + // internal vs external builds. we strip every comment except with below flag. + // Preserve ONLY comments starting with "DONT_STRIP_MINIFY:" + return text.includes("DONT_STRIP_MINIFY:"); } })) - .pipe(sourcemaps.write('./')) .pipe(dest('dist')); } // we had to do this as prettier is non minifiable function makeJSPrettierDist() { return src(["src/thirdparty/prettier/**/*"]) - .pipe(sourcemaps.init()) .pipe(dest('dist/thirdparty/prettier')); } function makeNonMinifyDist() { - return src(["src/thirdparty/no-minify/**/*"]) - .pipe(sourcemaps.init()) - .pipe(dest('dist/thirdparty/no-minify')); + // we dont minify remote functions as its in live preview context and the prod minify is stripping variables + // used by plugins in live preview. so we dont minify this for now. + return src(["src/thirdparty/no-minify/**/*", + "src/LiveDevelopment/BrowserScripts/RemoteFunctions.js"], {base: 'src'}) + .pipe(dest('dist')); } function makeDistNonJS() { @@ -536,29 +596,81 @@ function containsRegExpExcludingEmpty(str) { } +// Paths that should be minified during production builds +const minifyablePaths = [ + 'src/extensionsIntegrated/phoenix-pro/browser-context' +]; + +function _minifyBrowserContextFile(fileContent) { + const minified = terser.minify(fileContent, { + mangle: true, + compress: { + unused: false + }, + output: { + comments: function(node, comment) { + // license headers should not end up in distribution as the license of dist depends on + // internal vs external builds. we strip every comment except with below flag. + const text = comment.value.trim(); + return text.includes("DONT_STRIP_MINIFY:"); + } + } + }); + + if (minified.error) { + throw new Error(`Failed to minify file: ${minified.error}`); + } + + return minified.code; +} + +function _isMinifyablePath(filePath) { + const normalizedFilePath = path.normalize(filePath); + return minifyablePaths.some(minifyPath => + normalizedFilePath.startsWith(path.normalize(minifyPath)) + ); +} + +function getKey(filePath, isDevBuild) { + return isDevBuild + filePath; +} + const textContentMap = {}; -function inlineTextRequire(file, content, srcDir) { +const excludeSuffixPathsInlining = ["MessageIds.json"]; +function inlineTextRequire(file, content, srcDir, isDevBuild = true) { if(content.includes(`'text!`) || content.includes("`text!")) { throw new Error(`in file ${file} require("text!...") should always use a double quote "text! instead of " or \``); } if(content.includes(`"text!`)) { const requireFragments = extractRequireTextFragments(content); for (const {requirePath, requireStatement} of requireFragments) { - let textContent = textContentMap[requirePath]; + let filePath = srcDir + requirePath; + if(requirePath.startsWith("./")) { + filePath = path.join(path.dirname(file), requirePath); + } + let textContent = textContentMap[getKey(filePath, isDevBuild)]; + if(!textContent){ - let filePath = srcDir + requirePath; - if(requirePath.startsWith("./")) { - filePath = path.join(path.dirname(file), requirePath); - } console.log("reading file at path: ", filePath); - const fileContent = fs.readFileSync(filePath, "utf8"); - textContentMap[requirePath] = fileContent; + let fileContent = fs.readFileSync(filePath, "utf8"); + + // Minify inline if this is a minifyable path and we're in production mode + if (!isDevBuild && _isMinifyablePath(filePath)) { + console.log("Minifying file inline:", filePath); + fileContent = _minifyBrowserContextFile(fileContent); + } + + textContentMap[getKey(filePath, isDevBuild)] = fileContent; textContent = fileContent; } - if(textContent.includes("`")) { - console.log("Not inlining file as it contains a backquote(`) :", requirePath); - } else if(requirePath.endsWith(".js") || requirePath.endsWith(".json")) { - console.log("Not inlining JS/JSON file:", requirePath); + if((requirePath.endsWith(".js") && !requirePath.includes("./")) // js files that are relative paths are ok + || excludeSuffixPathsInlining.some(ext => requirePath.endsWith(ext))) { + console.warn("Not inlining JS/JSON file:", requirePath, filePath); + if(filePath.includes("phoenix-pro")) { + // this is needed as we will delete the extension sources when packaging for release. + // so non inlined content will not be available in the extension. throw early to detect that. + throw new Error(`All Files in phoenix pro extension should be inlineable!: failed for ${filePath}`); + } } else { console.log("Inlining", requireStatement); if((requireStatement.includes(".html") || requireStatement.includes(".js")) @@ -568,7 +680,7 @@ function inlineTextRequire(file, content, srcDir) { throw `Error inlining ${requireStatement} in ${file}: Regex: ${detectedRegEx}`+ "\nRegular expression of the form /*/ is not allowed for minification please use RegEx constructor"; } - content = content.replaceAll(requireStatement, "`"+textContent+"`"); + content = content.replaceAll(requireStatement, `${JSON.stringify(textContent)}`); } } @@ -576,7 +688,7 @@ function inlineTextRequire(file, content, srcDir) { return content; } -function makeBracketsConcatJS() { +function _makeBracketsConcatJSInternal(isDevBuild = true) { return new Promise((resolve)=>{ const srcDir = "src/"; const DO_NOT_CONCATENATE = [ @@ -610,7 +722,7 @@ function makeBracketsConcatJS() { console.log("Merging: ", requirePath); mergeCount ++; content = content.replace("define(", `define("${requirePath}", `); - content = inlineTextRequire(file, content, srcDir); + content = inlineTextRequire(file, content, srcDir, isDevBuild); concatenatedFile = concatenatedFile + "\n" + content; } } @@ -621,14 +733,20 @@ function makeBracketsConcatJS() { }); } +function makeBracketsConcatJS() { + return _makeBracketsConcatJSInternal(true); +} + +function makeBracketsConcatJSWithMinifiedBrowserScripts() { + return _makeBracketsConcatJSInternal(false); +} + function _renameBracketsConcatAsBracketsJSInDist() { return new Promise((resolve)=>{ fs.unlinkSync("dist/brackets.js"); fs.copyFileSync("dist/brackets-min.js", "dist/brackets.js"); - fs.copyFileSync("dist/brackets-min.js.map", "dist/brackets.js.map"); // cleanup minifed files fs.unlinkSync("dist/brackets-min.js"); - fs.unlinkSync("dist/brackets-min.js.map"); resolve(); }); } @@ -711,8 +829,8 @@ function makeExtensionConcatJS(extensionName) { `define("${defineId}", ` ); - // inline text requires - content = inlineTextRequire(file, content, extensionDir); + // inline text requires (extensions use isDevBuild=true, they're minified via makeJSDist) + content = inlineTextRequire(file, content, extensionDir, true); concatenatedFile += '\n' + content; mergeCount++; @@ -748,9 +866,7 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) { const srcExtensionConcatFile = path.join(srcExtensionDir, 'extension-min.js'); const distExtensionDir = path.join('dist/extensions/default', extensionName); const extMinFile = path.join(distExtensionDir, 'main.js'); - const extMinFileMap = path.join(distExtensionDir, 'main.js.map'); const extSrcFile = path.join(distExtensionDir, 'extension-min.js'); - const extSrcFileMap = path.join(distExtensionDir, 'extension-min.js.map'); // Make sure extension-min.js exists in dist. if (!fs.existsSync(extSrcFile)) { @@ -769,17 +885,7 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) { } fs.copyFileSync(extSrcFile, extMinFile); - if (fs.existsSync(extMinFileMap)) { - fs.unlinkSync(extMinFileMap); - } - if (fs.existsSync(extSrcFileMap)) { - fs.copyFileSync(extSrcFileMap, extMinFileMap); - } - fs.unlinkSync(extSrcFile); - if (fs.existsSync(extSrcFileMap)) { - fs.unlinkSync(extSrcFileMap); - } resolve(); } catch (err) { @@ -877,6 +983,32 @@ function makeLoggerConfig() { }); } +function generateProLoaderFiles() { + return new Promise((resolve) => { + // AMD module template for generated files + const AMD_MODULE_TEMPLATE = `define(function (require, exports, module) {});\n`; + + const phoenixProExists = fs.existsSync('src/extensionsIntegrated/phoenix-pro'); + + // Generate test/pro-test-suite.js content + const testSuiteCode = phoenixProExists ? + '\n require("extensionsIntegrated/phoenix-pro/unittests");\n' : ''; + const testSuiteContent = AMD_MODULE_TEMPLATE.replace('', testSuiteCode); + + // Generate src/extensionsIntegrated/pro-loader.js content + const loaderCode = phoenixProExists ? '\n require("./phoenix-pro/main");\n' : ''; + const loaderContent = AMD_MODULE_TEMPLATE.replace('', loaderCode); + + fs.writeFileSync('test/pro-test-suite.js', testSuiteContent); + fs.writeFileSync('src/extensionsIntegrated/pro-loader.js', loaderContent); + + console.log(`Generated pro-loader.js (phoenix-pro ${phoenixProExists ? 'found' : 'not found'})`); + console.log(`Generated pro-test-suite.js (phoenix-pro ${phoenixProExists ? 'found' : 'not found'})`); + + resolve(); + }); +} + function validatePackageVersions() { return new Promise((resolve, reject)=>{ const mainPackageJson = require("../package.json", "utf8"); @@ -926,6 +1058,7 @@ function validatePackageVersions() { }); } + function _patchMinifiedCSSInDistIndex() { return new Promise((resolve)=>{ let content = fs.readFileSync("dist/index.html", "utf8"); @@ -942,26 +1075,29 @@ function _patchMinifiedCSSInDistIndex() { const createDistTest = series(copyDistToDistTestFolder, copyTestToDistTestFolder, copyIndexToDistTestFolder); -exports.build = series(copyThirdPartyLibs.copyAll, makeLoggerConfig, zipDefaultProjectFiles, zipSampleProjectFiles, - makeBracketsConcatJS, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late +exports.build = series(optionalBuild.clonePhoenixProRepo, optionalBuild.generateProBuildInfo, copyThirdPartyLibs.copyAll, makeLoggerConfig, generateProLoaderFiles, zipDefaultProjectFiles, zipSampleProjectFiles, + makeBracketsConcatJS, makeBracketsConcatJSWithMinifiedBrowserScripts, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late createSrcCacheManifest, validatePackageVersions); -exports.buildDebug = series(copyThirdPartyLibs.copyAllDebug, makeLoggerConfig, zipDefaultProjectFiles, - makeBracketsConcatJS, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late +exports.buildDebug = series(optionalBuild.clonePhoenixProRepo, optionalBuild.generateProBuildInfo, copyThirdPartyLibs.copyAllDebug, makeLoggerConfig, generateProLoaderFiles, zipDefaultProjectFiles, + makeBracketsConcatJS, makeBracketsConcatJSWithMinifiedBrowserScripts, _compileLessSrc, _cleanReleaseBuildArtefactsInSrc, // these are here only as sanity check so as to catch release build minify fails not too late zipSampleProjectFiles, createSrcCacheManifest); exports.clean = series(cleanDist); exports.reset = series(cleanAll); exports.releaseDev = series(cleanDist, exports.buildDebug, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc, - makeDistAll, cleanUnwantedFilesInDist, releaseDev, _renameConcatExtensionsinDist, - createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc); -exports.releaseStaging = series(cleanDist, exports.build, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc, - makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, cleanUnwantedFilesInDist, - _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, _patchMinifiedCSSInDistIndex, releaseStaging, - createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc); -exports.releaseProd = series(cleanDist, exports.build, makeBracketsConcatJS, makeConcatExtensions, _compileLessSrc, - makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, cleanUnwantedFilesInDist, - _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, _patchMinifiedCSSInDistIndex, releaseProd, - createDistCacheManifest, createDistTest, _cleanReleaseBuildArtefactsInSrc); + makeDistAll, cleanUnwantedFilesInDistDev, releaseDev, _renameConcatExtensionsinDist, + createDistCacheManifest, createDistTest, + _cleanPhoenixProGitFolder, _cleanReleaseBuildArtefactsInSrc, validateBuild.validateDistSizeRestrictions); +exports.releaseStaging = series(cleanDist, exports.build, makeBracketsConcatJSWithMinifiedBrowserScripts, + makeConcatExtensions, _compileLessSrc, makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, + cleanUnwantedFilesInDistProd, _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, + _patchMinifiedCSSInDistIndex, releaseStaging, createDistCacheManifest, createDistTest, + _deletePhoenixProSourceFolder, _cleanReleaseBuildArtefactsInSrc, validateBuild.validateDistSizeRestrictions); +exports.releaseProd = series(cleanDist, exports.build, makeBracketsConcatJSWithMinifiedBrowserScripts, + makeConcatExtensions, _compileLessSrc, makeDistNonJS, makeJSDist, makeJSPrettierDist, makeNonMinifyDist, + cleanUnwantedFilesInDistProd, _renameBracketsConcatAsBracketsJSInDist, _renameConcatExtensionsinDist, + _patchMinifiedCSSInDistIndex, releaseProd, createDistCacheManifest, createDistTest, + _deletePhoenixProSourceFolder, _cleanReleaseBuildArtefactsInSrc, validateBuild.validateDistSizeRestrictions); exports.releaseWebCache = series(makeDistWebCache); exports.serve = series(exports.build, serve); exports.zipTestFiles = series(zipTestFiles); @@ -972,3 +1108,4 @@ exports.default = series(exports.build); exports.patchVersionBump = series(patchVersionBump); exports.minorVersionBump = series(minorVersionBump); exports.majorVersionBump = series(majorVersionBump); +exports.validateDistSizeRestrictions = series(validateBuild.validateDistSizeRestrictions); diff --git a/gulpfile.js/optional-build.js b/gulpfile.js/optional-build.js new file mode 100644 index 0000000000..c839fc5a99 --- /dev/null +++ b/gulpfile.js/optional-build.js @@ -0,0 +1,235 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2022 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/* eslint-env node */ + +const fs = require('fs'); +const path = require('path'); +const execSync = require('child_process').execSync; + +/** + * Conditionally clones the phoenix-pro repository if environment variables are set. + * + * Behavior: + * - If env vars not set: Skip clone, build continues (community builds) + * - If env vars set but clone fails: Build FAILS (credentials configured but clone failed) + * - If directory exists with correct commit: Skip clone, build continues + * - If directory exists with wrong commit: Log warning, build continues (respect local changes) + */ +function clonePhoenixProRepo() { + return new Promise((resolve, reject) => { + // this is only expected to be hit in github actions environment. + // in normal builds, we will bail out as soon as we detect that the environmental vars are note present. + + const proRepoUrl = process.env.PRO_REPO_URL; + const proRepoToken = process.env.PRO_REPO_ACCESS_TOKEN; + const targetDir = path.resolve(__dirname, '../src/extensionsIntegrated/phoenix-pro'); + + // Check if repository URL is set + if (!proRepoUrl) { + // this si what will happen in most dev builds. + console.log('Skipping phoenix-pro clone: PRO_REPO_URL not set'); + console.log('This is expected for community builds'); + resolve(); + return; + } + + if (!proRepoToken) { + console.warn('PRO_REPO_ACCESS_TOKEN not set, will attempt clone without authentication'); + } + + // all code below is only likely to be executed in the ci environment + + // Check if directory already exists + if (fs.existsSync(targetDir)) { + console.log('phoenix-pro directory already exists at:', targetDir); + + // Check if it's a git repository + const gitDir = path.join(targetDir, '.git'); + if (fs.existsSync(gitDir)) { + try { + // Verify current commit + const trackingRepos = require('../tracking-repos.json'); + const expectedCommit = trackingRepos.phoenixPro.commitID; + const currentCommit = execSync('git rev-parse HEAD', { + cwd: targetDir, + encoding: 'utf8' + }).trim(); + + if (currentCommit === expectedCommit) { + console.log(`✓ phoenix-pro is already at the correct commit: ${expectedCommit}`); + resolve(); + return; + } else { + // this code will only reach in ci envs with teh env variables, so ward if the commit + // is not what we expect. + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error(`Error: phoenix-pro is at commit ${currentCommit.substring(0, 8)}`); + console.error(` but tracking-repos.json specifies ${expectedCommit.substring(0, 8)}`); + console.error('Not building incorrect binary.'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + reject(); + return; + } + } catch (error) { + console.error(`Error: Could not verify phoenix-pro commit: ${error.message}`); + console.error('Not building incorrect binary.'); + reject(); + return; + } + } else { + console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.warn('Error: phoenix-pro directory exists but is not a git repository'); + console.error('Not building incorrect binary as it could not be verified.'); + console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + reject(); + return; + } + } + + // Perform the clone operation + try { + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('Cloning phoenix-pro repository...'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // Load target commit from tracking-repos.json + const trackingRepos = require('../tracking-repos.json'); + const commitID = trackingRepos.phoenixPro.commitID; + console.log(`Target commit: ${commitID}`); + + // Construct authenticated URL if token is available + const authUrl = proRepoToken + ? proRepoUrl.replace('https://', `https://oauth2:${proRepoToken}@`) + : proRepoUrl; + + // Step 1: Shallow clone + console.log('Step 1/3: Cloning repository (shallow clone)...'); + execSync(`git clone --depth 1 "${authUrl}" "${targetDir}"`, { + stdio: ['pipe', 'pipe', 'inherit'] // Hide stdout (may contain token), show stderr + }); + console.log('✓ Clone completed'); + + // Step 2: Fetch specific commit + console.log(`Step 2/3: Fetching specific commit: ${commitID}...`); + try { + execSync(`git fetch --depth 1 origin ${commitID}`, { + cwd: targetDir, + stdio: ['pipe', 'pipe', 'inherit'] + }); + console.log('✓ Fetch completed'); + } catch (fetchError) { + // Commit might already be in shallow clone + console.log(' (Commit may already be present in shallow clone)'); + } + + // Step 3: Checkout specific commit + console.log(`Step 3/3: Checking out commit: ${commitID}...`); + execSync(`git checkout ${commitID}`, { + cwd: targetDir, + stdio: ['pipe', 'pipe', 'inherit'] + }); + console.log('✓ Checkout completed'); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('✓ Successfully cloned and checked out phoenix-pro repository'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + resolve(); + + } catch (error) { + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('✗ ERROR: Failed to clone phoenix-pro repository'); + console.error(`Error: ${error.message}`); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('Build failed because:'); + console.error(' - PRO_REPO_URL is set (phoenix-pro expected)'); + console.error(' - Clone operation failed'); + console.error(''); + console.error('Possible causes:'); + console.error(' - Invalid or expired access token'); + console.error(' - Insufficient token permissions (needs "repo" scope)'); + console.error(' - Network connectivity issues'); + console.error(' - Repository URL is incorrect'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + // Clean up partial clone if it exists + if (fs.existsSync(targetDir)) { + try { + fs.rmSync(targetDir, { recursive: true, force: true }); + console.log('Cleaned up partial clone directory'); + } catch (cleanupError) { + console.warn(`Could not clean up partial clone: ${cleanupError.message}`); + } + } + + reject(new Error('Failed to clone phoenix-pro repository')); // FAIL BUILD + } + }); +} + +/** + * Generates a JSON file with phoenix-pro build information including the commit ID. + * Only generates if the phoenix-pro folder exists. + * If the phoenix-pro directory is not a git repository, the commit ID will be "unknown". + */ +function generateProBuildInfo() { + return new Promise((resolve) => { + const phoenixProPath = path.resolve(__dirname, '../src/extensionsIntegrated/phoenix-pro'); + + // Only generate if phoenix-pro folder exists + if (!fs.existsSync(phoenixProPath)) { + console.log('Phoenix Pro folder not found, skipping buildInfo.json generation'); + resolve(); + return; + } + + const gitPath = path.join(phoenixProPath, '.git'); + let commitID = "unknown"; + + if (fs.existsSync(gitPath)) { + try { + commitID = execSync('git rev-parse --short HEAD', { + cwd: phoenixProPath, + encoding: 'utf8' + }).trim(); + console.log(`Phoenix Pro commit ID: ${commitID}`); + } catch (error) { + console.warn('Could not get phoenix-pro commit ID:', error.message); + commitID = "unknown"; + } + } else { + console.log('Phoenix Pro is not a git repository, using "unknown" for commit ID'); + } + + const buildInfo = { + phoenixProCommitID: commitID + }; + + const buildInfoPath = path.join(phoenixProPath, 'proBuildInfo.json'); + fs.writeFileSync(buildInfoPath, JSON.stringify(buildInfo, null, 2)); + + console.log('Generated phoenix-pro/proBuildInfo.json'); + resolve(); + }); +} + +// Export the functions +exports.clonePhoenixProRepo = clonePhoenixProRepo; +exports.generateProBuildInfo = generateProBuildInfo; diff --git a/gulpfile.js/validate-build.js b/gulpfile.js/validate-build.js new file mode 100644 index 0000000000..cf31f5dff5 --- /dev/null +++ b/gulpfile.js/validate-build.js @@ -0,0 +1,188 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2022 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/* eslint-env node */ + +const fs = require('fs'); +const glob = require('glob'); + +// Size limits for development builds (in MB) +const DEV_MAX_FILE_SIZE_MB = 6; +const DEV_MAX_TOTAL_SIZE_MB = 90; +// Custom size limits for known large files (size in MB) For development builds +const LARGE_FILE_LIST_DEV = { + 'dist/thirdparty/no-minify/language-worker.js.map': 10, + 'dist/brackets-min.js': 15 +}; + +// Size limits for production/staging builds (in MB) +const PROD_MAX_FILE_SIZE_MB = 2; +const PROD_MAX_TOTAL_SIZE_MB = 70; +// Custom size limits for known large files (size in MB) For staging/production builds +const LARGE_FILE_LIST_PROD = { + 'dist/brackets.js': 9, // this is the full minified file itself renamed in prod + 'dist/phoenix/virtualfs.js.map': 3 +}; + +function _listFilesInDir(dir) { + return new Promise((resolve, reject)=>{ + glob(dir + '/**/*', { + nodir: true + }, (err, res)=>{ + if(err){ + reject(err); + return; + } + resolve(res); + }); + }); +} + +function _scanDistFiles(environment, largeFileList, maxFileSizeMB, maxTotalSizeMB) { + return new Promise((resolve, reject) => { + const maxTotalSizeBytes = maxTotalSizeMB * 1024 * 1024; + + _listFilesInDir('dist').then((files) => { + const oversizedFiles = []; + let totalSize = 0; + + for (let file of files) { + const stats = fs.statSync(file); + totalSize += stats.size; + + // Check if file has a custom size limit + const customLimitMB = largeFileList[file]; + const fileLimitMB = customLimitMB !== undefined ? customLimitMB : maxFileSizeMB; + const fileLimitBytes = fileLimitMB * 1024 * 1024; + + if (stats.size > fileLimitBytes) { + oversizedFiles.push({ + path: file, + sizeMB: (stats.size / (1024 * 1024)).toFixed(2), + limitMB: fileLimitMB, + isCustomLimit: customLimitMB !== undefined + }); + } + } + + const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2); + + resolve({ + oversizedFiles, + totalSizeMB, + totalLimitMB: maxTotalSizeMB, + hasTotalSizeExceeded: totalSize > maxTotalSizeBytes + }); + }).catch(reject); + }); +} + +function _displayValidationResults(scanResults, environment) { + const { + oversizedFiles, + totalSizeMB, + totalLimitMB, + hasTotalSizeExceeded + } = scanResults; + + if (oversizedFiles.length || hasTotalSizeExceeded) { + console.error(`\n========================================`); + console.error(`SIZE VALIDATION FAILED (${environment})`); + console.error(`========================================`); + + if (hasTotalSizeExceeded) { + console.error(`\nTotal dist folder size: ${totalSizeMB} MB ` + + `(exceeds ${totalLimitMB} MB limit for ${environment})`); + } + + if (oversizedFiles.length) { + // Sort by size in descending order + oversizedFiles.sort((a, b) => b.sizeBytes - a.sizeBytes); + console.error(`\nFound ${oversizedFiles.length} file(s) exceeding size limits for ${environment}:\n`); + + for (let file of oversizedFiles) { + const limitInfo = file.isCustomLimit ? + ` [custom limit: ${file.limitMB} MB]` : ` [limit: ${file.limitMB} MB]`; + console.error(` ${file.path} (${file.sizeMB} MB)${limitInfo}`); + } + } + + console.error(`\n========================================\n`); + + const errors = []; + if (hasTotalSizeExceeded) { + errors.push(`Total dist size ${totalSizeMB} MB exceeds ${totalLimitMB} MB limit`); + } + if (oversizedFiles.length) { + errors.push(`${oversizedFiles.length} file(s) exceed size limit`); + } + + return { + passed: false, + errorMessage: `Build validation failed for ${environment}: ${errors.join('; ')}` + }; + } + + console.log(`Size validation passed for ${environment}: Total dist size is ${totalSizeMB} MB ` + + `(under ${totalLimitMB} MB), all files under required limits.`); + return { + passed: true + }; +} + +function validateDistSizeRestrictions() { + return new Promise((resolve, reject) => { + // Read config to determine environment + let config; + try { + config = JSON.parse(fs.readFileSync('dist/config.json', 'utf8')); + } catch (err) { + reject(`Failed to read dist/config.json for size validation: ${err.message}`); + return; + } + + const environment = config.config?.environment || 'production'; + const isDev = environment === 'dev'; + + // Set limits based on environment + const MAX_FILE_SIZE_MB = isDev ? DEV_MAX_FILE_SIZE_MB : PROD_MAX_FILE_SIZE_MB; + const MAX_TOTAL_SIZE_MB = isDev ? DEV_MAX_TOTAL_SIZE_MB : PROD_MAX_TOTAL_SIZE_MB; + const LARGE_FILE_LIST = isDev ? LARGE_FILE_LIST_DEV : LARGE_FILE_LIST_PROD; + + console.log(`Validating dist size for ${environment} environment + (File limit: ${MAX_FILE_SIZE_MB} MB, Total limit: ${MAX_TOTAL_SIZE_MB} MB)`); + + _scanDistFiles(environment, LARGE_FILE_LIST, MAX_FILE_SIZE_MB, MAX_TOTAL_SIZE_MB) + .then((scanResults) => { + const result = _displayValidationResults(scanResults, environment); + + if (result.passed) { + resolve(); + } else { + reject(result.errorMessage); + } + }) + .catch(reject); + }); +} + +module.exports = { + validateDistSizeRestrictions +}; diff --git a/package-lock.json b/package-lock.json index 06055874e4..27808ef6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1146,9 +1146,9 @@ } }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -13806,9 +13806,9 @@ } }, "acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true }, "acorn-jsx": { diff --git a/package.json b/package.json index c1fd82627e..8a346b5b87 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "release:dev": "gulp releaseDev", "release:staging": "gulp releaseStaging", "release:prod": "gulp releaseProd", + "validate:dist-size": "gulp validateDistSizeRestrictions", "_releaseWebCache": "gulp releaseWebCache", "_patchVersionBump": "gulp patchVersionBump", "_minorVersionBump": "gulp minorVersionBump", diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js index 72b95696fb..f026291e10 100644 --- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js @@ -182,6 +182,10 @@ } } s.id = msg.params.url; + + if (window._LD && window._LD.redrawEverything) { + window._LD.redrawEverything(); + } }, /** @@ -363,7 +367,7 @@ ProtocolManager.enable(); }); - function _getAllInheritedSelectorsInOrder(element) { + function getAllInheritedSelectorsInOrder(element) { let selectorsFound= new Map(); const selectorsList = []; while (element) { @@ -383,6 +387,7 @@ return selectorsList; } + global.getAllInheritedSelectorsInOrder = getAllInheritedSelectorsInOrder; /** * Sends the message containing tagID which is being clicked @@ -407,7 +412,7 @@ "nodeID": element.id, "nodeClassList": element.classList, "nodeName": element.nodeName, - "allSelectors": _getAllInheritedSelectorsInOrder(element), + "allSelectors": getAllInheritedSelectorsInOrder(element), "contentEditable": element.contentEditable === 'true', "clicked": true, "edit": true @@ -431,7 +436,7 @@ "nodeID": element.id, "nodeClassList": element.classList, "nodeName": element.nodeName, - "allSelectors": _getAllInheritedSelectorsInOrder(element), + "allSelectors": getAllInheritedSelectorsInOrder(element), "contentEditable": element.contentEditable === 'true', "clicked": true }); @@ -440,34 +445,90 @@ } window.document.addEventListener("click", onDocumentClick); window.document.addEventListener("keydown", function (e) { + // Check if user is editing text content - if so, allow normal text cut + // Get the truly active element, even if inside shadow roots + let activeElement = document.activeElement; + + const isEditingText = activeElement && ( + // Check for standard form input elements + ['INPUT', 'TEXTAREA'].includes(activeElement.tagName) || + // Check for contentEditable elements + activeElement.isContentEditable || + // Check for ARIA roles that indicate text input + ['textbox', 'searchbox', 'combobox'].includes(activeElement.getAttribute('role')) || + // Check if element is designed to receive text input + (activeElement.hasAttribute("contenteditable") && activeElement.hasAttribute("data-brackets-id")) + ); + + // Check if a Phoenix tool is active (has data-phcode-internal-* attribute) + const isActiveElementPhoenixTool = activeElement && Array.from(activeElement.attributes || []).some(attr => + attr.name.startsWith('data-phcode-internal-') && attr.value === 'true' + ); + + const isInEditMode = window._LD && window._LD.getMode && window._LD.getMode() === 'edit'; + // for undo. refer to LivePreviewEdit.js file 'handleLivePreviewEditOperation' function - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && + (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) { MessageBroker.send({ livePreviewEditEnabled: true, undoLivePreviewOperation: true }); } - // for redo - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") { + // for redo - supports both Ctrl+Y and Ctrl+Shift+Z (Cmd+Y and Cmd+Shift+Z on Mac) + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && (e.ctrlKey || e.metaKey) && + (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) { MessageBroker.send({ livePreviewEditEnabled: true, redoLivePreviewOperation: true }); } + // Cut: Ctrl+X / Cmd+X - operates on selected element + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "x") { + + // Only handle element cut if not editing text and in edit mode + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleCutElement) { + e.preventDefault(); + window._LD.handleCutElement(); + } + } + + // Copy: Ctrl+C / Cmd+C - operates on selected element + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") { + + // Only handle element copy if not editing text and in edit mode + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleCopyElement) { + e.preventDefault(); + window._LD.handleCopyElement(); + } + } + + // Paste: Ctrl+V / Cmd+V - operates on selected element + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") { + + // Only handle element paste if not editing text and in edit mode + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handlePasteElement) { + e.preventDefault(); + window._LD.handlePasteElement(); + } + } + + if (e.key.toLowerCase() === 'delete' || e.key.toLowerCase() === 'backspace') { + if (!isEditingText && !isActiveElementPhoenixTool && isInEditMode && window._LD.handleDeleteElement) { + e.preventDefault(); + window._LD.handleDeleteElement(); + } + } + // for save if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); // to check if user was in between editing text // in such cases we first finish the editing and then save - const activeElement = document.activeElement; - if (activeElement && - activeElement.hasAttribute("contenteditable") && - activeElement.hasAttribute("data-brackets-id") && - window._LD && - window._LD.finishEditing) { + if (isEditingText && window._LD && window._LD.finishEditing) { window._LD.finishEditing(activeElement); } diff --git a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js index 50d278c061..66a0d27065 100644 --- a/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LivePreviewTransportRemote.js @@ -313,9 +313,10 @@ } }; + const ABS_REGEX = new RegExp("^(?:[a-z]+:)?\\/\\/", "i"); function getAbsoluteUrl(url) { // Check if the URL is already absolute - if (/^(?:[a-z]+:)?\/\//i.test(url)) { + if (ABS_REGEX.test(url)) { return url; // The URL is already absolute } @@ -439,6 +440,7 @@ let alertQueue = [], confirmCalled = false, promptCalled = false; let addToQueue = true; + window.__PHOENIX_APP_INFO = {isTauri, platform}; if(!isExternalBrowser){ // this is an embedded iframe we always take hold of the alert api for better ux within the live preivew frame. window.__PHOENIX_EMBED_INFO = {isTauri, platform}; diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 0b7beceaed..f170f507f0 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -1,3372 +1,265 @@ -/* - * GNU AGPL-3.0 License - * - * Copyright (c) 2021 - present core.ai . All rights reserved. - * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved. - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License - * for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. - * - */ - -/*jslint forin: true */ -/*global Node, MessageEvent */ -/*theseus instrument: false */ - -/** - * RemoteFunctions define the functions to be executed in the browser. This - * modules should define a single function that returns an object of all - * exported functions. - */ -function RemoteFunctions(config = {}) { - // this will store the element that was clicked previously (before the new click) - // we need this so that we can remove click styling from the previous element when a new element is clicked - let previouslyClickedElement = null; - - var req, timeout; - var animateHighlight = function (time) { - if(req) { - window.cancelAnimationFrame(req); - window.clearTimeout(timeout); - } - req = window.requestAnimationFrame(redrawHighlights); - - timeout = setTimeout(function () { - window.cancelAnimationFrame(req); - req = null; - }, time * 1000); - }; - - /** - * @type {DOMEditHandler} - */ - var _editHandler; - - var HIGHLIGHT_CLASSNAME = "__brackets-ld-highlight"; - - // auto-scroll variables to auto scroll the live preview when an element is dragged to the top/bottom - let _autoScrollTimer = null; - let _isAutoScrolling = false; // to disable highlights when auto scrolling - const AUTO_SCROLL_SPEED = 12; // pixels per scroll - const AUTO_SCROLL_EDGE_SIZE = 0.05; // 5% of viewport height (either top/bottom) - - // initialized from config, defaults to true if not set - let imageGallerySelected = config.imageGalleryState !== undefined ? config.imageGalleryState : true; - - /** - * this function is responsible to auto scroll the live preview when - * dragging an element to the viewport edges - * @param {number} clientY - curr mouse Y position - */ - function _handleAutoScroll(clientY) { - const viewportHeight = window.innerHeight; - const scrollEdgeSize = viewportHeight * AUTO_SCROLL_EDGE_SIZE; - - // Clear existing timer - if (_autoScrollTimer) { - clearInterval(_autoScrollTimer); - _autoScrollTimer = null; - } - - let scrollDirection = 0; - - // check if near top edge (scroll up) - if (clientY <= scrollEdgeSize) { - scrollDirection = -AUTO_SCROLL_SPEED; - } else if (clientY >= viewportHeight - scrollEdgeSize) { - // check if near bottom edge (scroll down) - scrollDirection = AUTO_SCROLL_SPEED; - } - - // Start scrolling if needed - if (scrollDirection !== 0) { - _isAutoScrolling = true; - _autoScrollTimer = setInterval(() => { - window.scrollBy(0, scrollDirection); - }, 16); // 16 is ~60fps - } - } - - // stop autoscrolling - function _stopAutoScroll() { - if (_autoScrollTimer) { - clearInterval(_autoScrollTimer); - _autoScrollTimer = null; - } - _isAutoScrolling = false; - } - - // determine whether an event should be processed for Live Development - function _validEvent(event) { - if (window.navigator.platform.substr(0, 3) === "Mac") { - // Mac - return event.metaKey; - } - // Windows - return event.ctrlKey; - } - - /** - * check if an element is inspectable. - * inspectable elements are those which doesn't have data-brackets-id, - * this normally happens when content is DOM content is inserted by some scripting language - */ - function isElementInspectable(element, onlyHighlight = false) { - if(!config.isProUser && !onlyHighlight) { - return false; - } - - if(element && // element should exist - element.tagName.toLowerCase() !== "body" && // shouldn't be the body tag - element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag - !element.closest("[data-phcode-internal-c15r5a9]") && // this attribute is used by phoenix internal elements - !_isInsideHeadTag(element)) { // shouldn't be inside the head tag like meta tags and all - return true; - } - return false; - } - - /** - * This is a checker function for editable elements, it makes sure that the element satisfies all the required check - * - When onlyHighlight is false → config.isProUser must be true - * - When onlyHighlight is true → config.isProUser can be true or false (doesn't matter) - * @param {DOMElement} element - * @param {boolean} [onlyHighlight=false] - If true, bypasses the isProUser check - * @returns {boolean} - True if the element is editable else false - */ - function isElementEditable(element, onlyHighlight = false) { - // for an element to be editable it should satisfy all inspectable checks and should also have data-brackets-id - return isElementInspectable(element, onlyHighlight) && - element.hasAttribute("data-brackets-id"); - } - - // helper function to check if an element is inside the HEAD tag - // we need this because we don't wanna trigger the element highlights on head tag and its children, - // except for
${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,17 @@ function RemoteFunctions(config = {}) { if (_hoverHighlight) { _hoverHighlight.clear(); } + if (_clickHighlight) { + _clickHighlight.clear(); + _clickHighlight = null; + } + + // Notify handlers about cleanup + getAllToolHandlers().forEach(handler => { + if (handler.onElementCleanup) { + handler.onElementCleanup(); + } + }); previouslyClickedElement = null; } @@ -4660,223 +1299,92 @@ function RemoteFunctions(config = {}) { * Called when user presses Esc key, clicks on HTML/Body tags, or other dismissal events */ function dismissUIAndCleanupState() { - dismissAllUIBoxes(); + getAllToolHandlers().forEach(handler => (handler.dismiss && handler.dismiss())); // to dismiss all UI boxes cleanupPreviousElementState(); } - /** - * this is a hard reset function, it resets every live preview edit thing, whether it be UI boxes - * highlighting, any timers or anything - */ - function resetState() { - _stopAutoScroll(); - - if (_hoverHighlight) { - _hoverHighlight.clear(); - _hoverHighlight = null; - } - if (_clickHighlight) { - _clickHighlight.clear(); - _clickHighlight = null; - } - - dismissUIAndCleanupState(); - - const allElements = window.document.querySelectorAll("[data-brackets-id]"); - for (let i = 0; i < allElements.length; i++) { - if (allElements[i]._originalBackgroundColor !== undefined) { - clearElementBackground(allElements[i]); - } - } - - if (config.isProUser) { - _hoverHighlight = new Highlight("#c8f9c5", true); - _clickHighlight = new Highlight("#cfc", true); - } - } - - - /** - * This function is responsible to move the cursor to the end of the text content when we start editing - * @param {DOMElement} element - */ - function moveCursorToEnd(selection, element) { - const range = document.createRange(); - range.selectNodeContents(element); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - - // Function to handle direct editing of elements in the live preview - function startEditing(element) { - if (!isElementEditable(element)) { - return; - } - - // Make the element editable - element.setAttribute("contenteditable", "true"); - element.focus(); - // to compare with the new text content, if same we don't make any changes in the editor area - const oldContent = element.textContent; - - // Move cursor to end if no existing selection - const selection = window.getSelection(); - if (selection.rangeCount === 0 || selection.isCollapsed) { - moveCursorToEnd(selection, element); - } - - dismissUIAndCleanupState(); - - // flag to check if escape is pressed, if pressed we prevent onBlur from handling it as keydown already handles - let isEscapePressed = false; - - function onBlur() { - // Small delay so that keydown can handle things first - setTimeout(() => { - if (isEscapePressed) { - isEscapePressed = false; - finishEditingCleanup(element); - return; - } - - const newContent = element.textContent; - if (oldContent !== newContent) { - finishEditing(element); - } else { // if same content, we just cleanup things - finishEditingCleanup(element); - } - }, 10); - } - - function onKeyDown(event) { - if (event.key === "Escape") { - isEscapePressed = true; - // Cancel editing - event.preventDefault(); - const newContent = element.textContent; - if (oldContent !== newContent) { - finishEditing(element, false); // false means that the edit operation was cancelled - } else { // no content change we can avoid sending details to the editor - finishEditingCleanup(element); - } - } else if (event.key === "Enter" && !event.shiftKey) { - isEscapePressed = false; - // Finish editing on Enter (unless Shift is held) - event.preventDefault(); - finishEditing(element); - } else if ((event.key === " " || event.key === "Spacebar") && element.tagName.toLowerCase() === 'button') { - event.preventDefault(); - document.execCommand("insertText", false, " "); - } - } - - element.addEventListener("blur", onBlur); - element.addEventListener("keydown", onKeyDown); - - // Store the event listeners for later removal - element._editListeners = { - blur: onBlur, - keydown: onKeyDown - }; - } - - function finishEditingCleanup(element) { - if (!isElementEditable(element) || !element.hasAttribute("contenteditable")) { - return; - } - - // Remove contenteditable attribute - element.removeAttribute("contenteditable"); - dismissUIAndCleanupState(); - - // Remove event listeners - if (element._editListeners) { - element.removeEventListener("blur", element._editListeners.blur); - element.removeEventListener("keydown", element._editListeners.keydown); - delete element._editListeners; - } - } - - // Function to finish editing and apply changes - // isEditSuccessful: this is a boolean value, defaults to true. false only when the edit operation is cancelled - function finishEditing(element, isEditSuccessful = true) { - finishEditingCleanup(element); - - const tagId = element.getAttribute("data-brackets-id"); - window._Brackets_MessageBroker.send({ - livePreviewEditEnabled: true, - livePreviewTextEdit: true, - element: element, - newContent: element.outerHTML, - tagId: Number(tagId), - isEditSuccessful: isEditSuccessful - }); - } - // init _editHandler = new DOMEditHandler(window.document); function registerHandlers() { - // clear previous highlighting - if (_hoverHighlight) { - _hoverHighlight.clear(); - _hoverHighlight = null; - } - if (_clickHighlight) { - _clickHighlight.clear(); - _clickHighlight = null; - } - - // Always remove existing listeners first to avoid duplicates - window.document.removeEventListener("mouseover", onElementHover); - window.document.removeEventListener("mouseout", onElementHoverOut); - window.document.removeEventListener("click", onClick); - window.document.removeEventListener("dblclick", onDoubleClick); - window.document.removeEventListener("dragover", onDragOver); - window.document.removeEventListener("drop", onDrop); - window.document.removeEventListener("dragleave", onDragLeave); + hideHighlight(); // clear previous highlighting + disableHoverListeners(); // Always remove existing listeners first to avoid duplicates window.document.removeEventListener("keydown", onKeyDown); + getAllToolHandlers().forEach(handler => { + if (handler.unregisterInteractionBlocker) { + handler.unregisterInteractionBlocker(); + } + }); - if (config.isProUser) { + if (config.mode === 'edit') { // Initialize hover highlight with Chrome-like colors _hoverHighlight = new Highlight("#c8f9c5", true); // Green similar to Chrome's padding color // Initialize click highlight with animation _clickHighlight = new Highlight("#cfc", true); // Light green for click highlight - window.document.addEventListener("mouseover", onElementHover); - window.document.addEventListener("mouseout", onElementHoverOut); - window.document.addEventListener("click", onClick); - window.document.addEventListener("dblclick", onDoubleClick); - window.document.addEventListener("dragover", onDragOver); - window.document.addEventListener("drop", onDrop); - window.document.addEventListener("dragleave", onDragLeave); + // register the event handlers + enableHoverListeners(); window.document.addEventListener("keydown", onKeyDown); + + // this is to block all the interactions of the user created elements + // so that lets say user created link doesn't redirect in edit mode + getAllToolHandlers().forEach(handler => { + if (handler.registerInteractionBlocker) { + handler.registerInteractionBlocker(); + } + }); } else { // Clean up any existing UI when edit features are disabled dismissUIAndCleanupState(); } + getAllToolHandlers().forEach(handler => { + if (handler.reRegisterEventHandlers) { + handler.reRegisterEventHandlers(); + } + }); } - registerHandlers(); + function _escapeKeyPressInEditor() { + enableHoverListeners(); // so that if hover lock is there it will get cleared + dismissUIAndCleanupState(); + getAllToolHandlers().forEach(handler => { + if (handler.handleEscapePressFromEditor) { + handler.handleEscapePressFromEditor(); + } + }); + } - return { - "DOMEditHandler" : DOMEditHandler, - "hideHighlight" : hideHighlight, - "highlight" : highlight, - "highlightRule" : highlightRule, - "redrawHighlights" : redrawHighlights, - "redrawEverything" : redrawEverything, - "applyDOMEdits" : applyDOMEdits, - "updateConfig" : updateConfig, - "startEditing" : startEditing, - "finishEditing" : finishEditing, - "hasVisibleLivePreviewBoxes" : hasVisibleLivePreviewBoxes, - "dismissUIAndCleanupState" : dismissUIAndCleanupState, - "resetState" : resetState, - "enableHoverListeners" : enableHoverListeners, - "registerHandlers" : registerHandlers, - "handleDownloadEvent" : handleDownloadEvent + // we need to refresh the config once the load is completed + // this is important because messageBroker gets ready for use only when load fires + window.addEventListener('load', function() { + window._Brackets_MessageBroker.send({ + requestConfigRefresh: true + }); + }); + + let customReturns = {}; + // only apis that needs to be called from phoenix js layer should be customReturns. APis that are shared within + // the remote function context only should not be in customReturns and should be in + // either SHARED_STATE for state vars, GLOBALS for global vars, or LivePreviewView for shared functions. + customReturns = { // we have to do this else the minifier will strip the customReturns variable + ...customReturns, + "DOMEditHandler": DOMEditHandler, + "hideHighlight": hideHighlight, + "highlight": highlight, + "highlightRule": highlightRule, + "redrawHighlights": redrawHighlights, + "redrawEverything": redrawEverything, + "applyDOMEdits": applyDOMEdits, + "updateConfig": updateConfig, + "dismissUIAndCleanupState": dismissUIAndCleanupState, + "escapeKeyPressInEditor": _escapeKeyPressInEditor, + "getMode": function() { return config.mode; } }; + + // the below code comment is replaced by added scripts for extensibility + // DONT_STRIP_MINIFY:REPLACE_WITH_ADDED_REMOTE_SCRIPTS + + registerHandlers(); + return customReturns; } diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js index 8d8598addf..b84e15290e 100644 --- a/src/LiveDevelopment/LiveDevMultiBrowser.js +++ b/src/LiveDevelopment/LiveDevMultiBrowser.js @@ -87,6 +87,7 @@ define(function (require, exports, module) { LivePreviewTransport = require("LiveDevelopment/MultiBrowserImpl/transports/LivePreviewTransport"), LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"), Metrics = require("utils/Metrics"), + WorkspaceManager = require("view/WorkspaceManager"), PageLoaderWorkerScript = require("text!LiveDevelopment/BrowserScripts/pageLoaderWorker.js"); // Documents @@ -128,6 +129,8 @@ define(function (require, exports, module) { */ var _server; + let _config = {}; + /** * @private * Determine which live document class should be used for a given document @@ -375,6 +378,18 @@ define(function (require, exports, module) { ); } + function _updateVirtualServerScripts() { + if(!_server || !_liveDocument || !_liveDocument.doc){ + return; + } + _server.addVirtualContentAtPath( + `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME}`, + _protocol.getRemoteScriptContents()); + _server.addVirtualContentAtPath( + `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME}`, + PageLoaderWorkerScript); + } + /** * @private * Creates the main live document for a given HTML document and notifies the server it exists. @@ -389,12 +404,7 @@ define(function (require, exports, module) { return; } _server.add(_liveDocument); - _server.addVirtualContentAtPath( - `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME}`, - _protocol.getRemoteScriptContents()); - _server.addVirtualContentAtPath( - `${_liveDocument.doc.file.parentPath}${LiveDevProtocol.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME}`, - PageLoaderWorkerScript); + _updateVirtualServerScripts(); } @@ -435,7 +445,6 @@ define(function (require, exports, module) { const urlString = `${url.origin}${url.pathname}`; if (_liveDocument && urlString === _resolveUrl(_liveDocument.doc.file.fullPath)) { _setStatus(STATUS_ACTIVE); - resetLPEditState(); } } Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "connect", @@ -650,7 +659,7 @@ define(function (require, exports, module) { * Initialize the LiveDevelopment module. */ function init(config) { - exports.config = config; + _config = config; MainViewManager .on("currentFileChange", _onFileChange); DocumentManager @@ -701,52 +710,46 @@ define(function (require, exports, module) { } /** - * Check if live preview boxes are currently visible + * Update configuration in the remote browser */ - function hasVisibleLivePreviewBoxes() { - if (_protocol) { - return _protocol.evaluate("_LD.hasVisibleLivePreviewBoxes()"); - } - return false; + function updateConfig(config) { + _config = config; + _updateVirtualServerScripts(); + refreshConfig(); } /** - * Dismiss live preview boxes like info box, options box, AI box + * Refresh all live previews with existing configuration in the remote browser */ - function dismissLivePreviewBoxes() { + function refreshConfig() { if (_protocol) { - _protocol.evaluate("_LD.enableHoverListeners()"); // so that if hover lock is there it will get cleared - _protocol.evaluate("_LD.dismissUIAndCleanupState()"); + _protocol.evaluate("_LD.updateConfig(" + JSON.stringify(_config) + ")"); } } - /** - * Register event handlers in the remote browser for live preview functionality - */ - function registerHandlers() { - if (_protocol) { - _protocol.evaluate("_LD.registerHandlers()"); - } - } /** - * Update configuration in the remote browser + * this function handles escape key for live preview to hide boxes if they are visible + * @param {Event} event */ - function updateConfig(configJSON) { - if (_protocol) { - _protocol.evaluate("_LD.updateConfig(" + JSON.stringify(configJSON) + ")"); + function _handleLivePreviewEscapeKey(event) { + const currLiveDoc = getCurrentLiveDoc(); + if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) { + currLiveDoc.protocol.evaluate("_LD.escapeKeyPressInEditor()"); } + // returning false to let the editor also handle the escape key + return false; } + // allow live preview to handle escape key event + // Escape is mainly to hide boxes if they are visible + WorkspaceManager.addEscapeKeyEventHandler("livePreview", _handleLivePreviewEscapeKey); /** - * this function is to completely reset the live preview edit - * its done so that when live preview is opened/popped out, we can re-update the config so that - * there are no stale markers and edit works perfectly + * gets configuration used to set in the remote browser */ - function resetLPEditState() { - if (_protocol) { - _protocol.evaluate("_LD.resetState()"); - } + function getConfig() { + // not using structured clone as it's not fast for small objects + return JSON.parse(JSON.stringify(_config || {})); } /** @@ -815,10 +818,9 @@ define(function (require, exports, module) { exports.showHighlight = showHighlight; exports.hideHighlight = hideHighlight; exports.redrawHighlight = redrawHighlight; - exports.hasVisibleLivePreviewBoxes = hasVisibleLivePreviewBoxes; - exports.dismissLivePreviewBoxes = dismissLivePreviewBoxes; - exports.registerHandlers = registerHandlers; + exports.getConfig = getConfig; exports.updateConfig = updateConfig; + exports.refreshConfig = refreshConfig; exports.init = init; exports.isActive = isActive; exports.setLivePreviewPinned= setLivePreviewPinned; diff --git a/src/LiveDevelopment/LivePreviewConstants.js b/src/LiveDevelopment/LivePreviewConstants.js new file mode 100644 index 0000000000..cc2a521ef2 --- /dev/null +++ b/src/LiveDevelopment/LivePreviewConstants.js @@ -0,0 +1,44 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global less, Phoenix */ + +/** + * main integrates LiveDevelopment into Brackets + * + * This module creates two menu items: + * + * "Go Live": open or close a Live Development session and visualize the status + * "Highlight": toggle source highlighting + */ +define(function main(require, exports, module) { + exports.LIVE_PREVIEW_MODE = "preview"; + exports.LIVE_HIGHLIGHT_MODE = "highlight"; + exports.LIVE_EDIT_MODE = "edit"; + + exports.PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode"; + + exports.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = "livePreviewElementHighlights"; + exports.HIGHLIGHT_HOVER = "hover"; + exports.HIGHLIGHT_CLICK = "click"; + + exports.PREFERENCE_SHOW_RULER_LINES = "livePreviewShowMeasurements"; +}); diff --git a/src/LiveDevelopment/LivePreviewEdit.js b/src/LiveDevelopment/LivePreviewEdit.js deleted file mode 100644 index 851e84d094..0000000000 --- a/src/LiveDevelopment/LivePreviewEdit.js +++ /dev/null @@ -1,1378 +0,0 @@ -/* - * GNU AGPL-3.0 License - * - * Copyright (c) 2021 - present core.ai . All rights reserved. - * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved. - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License - * for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. - * - */ - -/* - * This file handles all the editor side source code handling after user performed some live preview edit operation - * when any operation is performed in the browser context (handled inside remoteFunctions.js) it sends a message through - * MessageBroker, now this file then makes the change in the source code - */ -define(function (require, exports, module) { - const HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"); - const LiveDevMultiBrowser = require("LiveDevelopment/LiveDevMultiBrowser"); - const LiveDevelopment = require("LiveDevelopment/main"); - const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"); - const ProjectManager = require("project/ProjectManager"); - const CommandManager = require("command/CommandManager"); - const Commands = require("command/Commands"); - const FileSystem = require("filesystem/FileSystem"); - const PathUtils = require("thirdparty/path-utils/path-utils"); - const StringMatch = require("utils/StringMatch"); - const Dialogs = require("widgets/Dialogs"); - const StateManager = require("preferences/StateManager"); - const ProDialogs = require("services/pro-dialogs"); - const Mustache = require("thirdparty/mustache/mustache"); - const Strings = require("strings"); - const ImageFolderDialogTemplate = require("text!htmlContent/image-folder-dialog.html"); - - // state manager key, to save the download location of the image - const IMAGE_DOWNLOAD_FOLDER_KEY = "imageGallery.downloadFolder"; - const IMAGE_DOWNLOAD_PERSIST_FOLDER_KEY = "imageGallery.persistFolder"; - - const DOWNLOAD_EVENTS = { - STARTED: 'downloadStarted', - COMPLETED: 'downloadCompleted', - CANCELLED: 'downloadCancelled', - ERROR: 'downloadError' - }; - - const KernalModeTrust = window.KernalModeTrust; - if(!KernalModeTrust){ - // integrated extensions will have access to kernal mode, but not external extensions - throw new Error("LivePreviewEdit.js should have access to KernalModeTrust. Cannot boot without trust ring"); - } - - /** - * This function syncs text content changes between the original source code - * and the live preview DOM after a text edit in the browser - * - * @private - * @param {String} oldContent - the original source code from the editor - * @param {String} newContent - the outerHTML after editing in live preview - * @returns {String} - the updated content that should replace the original editor code - * - * NOTE: We don’t touch tag names or attributes — - * we only care about text changes or things like newlines,
, or formatting like , , etc. - * - * Here's the basic idea: - * - Parse both old and new HTML strings into DOM trees - * - Then walk both DOMs side by side and sync changes - * - * What we handle: - * - if both are text nodes → update the text if changed - * - if both are elements with same tag → go deeper and sync their children - * - if one is text and one is an element → replace (like when user adds/removes
or adds bold/italic) - * - if a node got added or removed → do that in the old DOM - * - * We don’t recreate or touch existing elements unless absolutely needed, - * so all original user-written attributes and tag structure stay exactly the same. - * - * This avoids the browser trying to “fix” broken HTML (which we don’t want) - */ - function _syncTextContentChanges(oldContent, newContent) { - const parser = new DOMParser(); - const oldDoc = parser.parseFromString(oldContent, "text/html"); - const newDoc = parser.parseFromString(newContent, "text/html"); - - const oldRoot = oldDoc.body; - const newRoot = newDoc.body; - - // this function is to remove the phoenix internal attributes from leaking into the user's source code - function cleanClonedElement(clonedElement) { - if (clonedElement.nodeType === Node.ELEMENT_NODE) { - // this are phoenix's internal attributes - const attrs = ["data-brackets-id", "data-ld-highlight"]; - - // remove from the cloned element - attrs.forEach(attr => clonedElement.removeAttribute(attr)); - - // also remove from its childrens - clonedElement.querySelectorAll(attrs.map(a => `[${a}]`).join(",")) - .forEach(el => attrs.forEach(attr => el.removeAttribute(attr))); - } - return clonedElement; - } - - function syncText(oldNode, newNode) { - if (!oldNode || !newNode) { - return; - } - - // when both are text nodes, we just need to replace the old text with the new one - if (oldNode.nodeType === Node.TEXT_NODE && newNode.nodeType === Node.TEXT_NODE) { - if (oldNode.nodeValue !== newNode.nodeValue) { - oldNode.nodeValue = newNode.nodeValue; - } - return; - } - - // when both are elements - if (oldNode.nodeType === Node.ELEMENT_NODE && newNode.nodeType === Node.ELEMENT_NODE) { - const oldChildren = Array.from(oldNode.childNodes); - const newChildren = Array.from(newNode.childNodes); - - const maxLen = Math.max(oldChildren.length, newChildren.length); - - for (let i = 0; i < maxLen; i++) { - const oldChild = oldChildren[i]; - const newChild = newChildren[i]; - - if (!oldChild && newChild) { - // if new child added → clone and insert - const cloned = newChild.cloneNode(true); - oldNode.appendChild(cleanClonedElement(cloned)); - } else if (oldChild && !newChild) { - // if child removed → delete - oldNode.removeChild(oldChild); - } else if ( - oldChild.nodeType === newChild.nodeType && - oldChild.nodeType === Node.ELEMENT_NODE && - oldChild.tagName === newChild.tagName - ) { - // same element tag → sync recursively - syncText(oldChild, newChild); - } else if ( - oldChild.nodeType === Node.TEXT_NODE && - newChild.nodeType === Node.TEXT_NODE - ) { - if (oldChild.nodeValue !== newChild.nodeValue) { - oldChild.nodeValue = newChild.nodeValue; - } - } else { - // different node types or tags → replace - const cloned = newChild.cloneNode(true); - oldNode.replaceChild(cleanClonedElement(cloned), oldChild); - } - } - } - } - - const oldEls = Array.from(oldRoot.children); - const newEls = Array.from(newRoot.children); - - for (let i = 0; i < Math.min(oldEls.length, newEls.length); i++) { - syncText(oldEls[i], newEls[i]); - } - - return oldRoot.innerHTML; - } - - /** - * helper function to get editor and validate basic requirements - * @param {Number} tagId - the data-brackets-id of the element - */ - function _getEditorAndValidate(tagId) { - const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(); - if (!currLiveDoc || !currLiveDoc.editor) { - return null; - } - // for undo/redo operations, tagId might not be needed, so we only check it if provided - if (tagId !== undefined && !tagId) { - return null; - } - return currLiveDoc.editor; - } - - /** - * helper function to get element range from tagId - * - * @param {Object} editor - the editor instance - * @param {Number} tagId - the data-brackets-id of the element - * @returns {Object|null} - object with startPos and endPos, or null if not found - */ - function _getElementRange(editor, tagId) { - // get the start range from the getPositionFromTagId function - // and we get the end range from the findMatchingTag function - // NOTE: we cannot get the end range from getPositionFromTagId - // because on non-beautified code getPositionFromTagId may not provide correct end position - const startRange = HTMLInstrumentation.getPositionFromTagId(editor, tagId); - if(!startRange) { - return null; - } - - const endRange = CodeMirror.findMatchingTag(editor._codeMirror, startRange.from); - if (!endRange) { - return null; - } - - const startPos = startRange.from; - // for empty tags endRange.close might not exist, for ex: img tag - const endPos = endRange.close ? endRange.close.to : endRange.open.to; - - return { startPos, endPos }; - } - - /** - * this function handles the text edit in the source code when user updates the text in the live preview - * - * @param {Object} message - the message object - * - livePreviewEditEnabled: true - * - livePreviewTextEdit: true - * - element: element - * - newContent: element.outerHTML (the edited content from live preview) - * - tagId: Number (data-brackets-id of the edited element) - * - isEditSuccessful: boolean (false when user pressed Escape to cancel, otherwise true always) - */ - function _editTextInSource(message) { - const editor = _getEditorAndValidate(message.tagId); - if (!editor) { - return; - } - - const range = _getElementRange(editor, message.tagId); - if (!range) { - return; - } - - const { startPos, endPos } = range; - - const text = editor.getTextBetween(startPos, endPos); - - // if the edit was cancelled (mainly by pressing Escape key) - // we just replace the same text with itself - // this is a quick trick because as the code is changed for that element in the file, - // the live preview for that element gets refreshed and the changes are discarded in the live preview - if(!message.isEditSuccessful) { - editor.document.batchOperation(function () { - editor.replaceRange(text, startPos, endPos); - setTimeout(() => { - editor.undo(); // undo the replaceRange so dirty icon won't appear and no net change in undo history - }, 0); - }); - } else { - - // if the edit operation was successful, we call a helper function that - // is responsible to provide the actual content that needs to be written in the editor - // - // text: the actual current source code in the editor - // message.newContent: the new content in the live preview after the edit operation - const finalText = _syncTextContentChanges(text, message.newContent); - editor.replaceRange(finalText, startPos, endPos); - } - } - - /** - * This function is responsible to duplicate an element from the source code - * @param {Number} tagId - the data-brackets-id of the DOM element - */ - function _duplicateElementInSourceByTagId(tagId) { - // this is to get the currently live document that is being served in the live preview - const editor = _getEditorAndValidate(tagId); - if (!editor) { - return; - } - - const range = _getElementRange(editor, tagId); - if (!range) { - return; - } - - const { startPos, endPos } = range; - - // this is the actual source code for the element that we need to duplicate - const text = editor.getTextBetween(startPos, endPos); - // this is the indentation on the line - const indent = editor.getTextBetween({ line: startPos.line, ch: 0 }, startPos); - - editor.document.batchOperation(function () { - // make sure there is only indentation and no text before it - if (indent.trim() === "") { - // this is the position where we need to insert - // we're giving the char as 0 because since we insert a new line using '\n' - // that's why writing any char value will not work, as the line is emptys - // and codemirror doesn't allow to insert at a column (ch) greater than the length of the line - // So, the logic is to just append the indent before the text at this insertPos - const insertPos = { - line: startPos.line + (endPos.line - startPos.line + 1), - ch: 0 - }; - - editor.replaceRange("\n", endPos); - editor.replaceRange(indent + text, insertPos); - } else { - // if there is some text, we just add the duplicated text right next to it - editor.replaceRange(text, startPos); - } - }); - } - - /** - * This function is responsible to delete an element from the source code - * @param {Number} tagId - the data-brackets-id of the DOM element - */ - function _deleteElementInSourceByTagId(tagId) { - // this is to get the currently live document that is being served in the live preview - const editor = _getEditorAndValidate(tagId); - if (!editor) { - return; - } - - const range = _getElementRange(editor, tagId); - if (!range) { - return; - } - - const { startPos, endPos } = range; - - editor.document.batchOperation(function () { - editor.replaceRange("", startPos, endPos); - - // since we remove content from the source, we want to clear the extra line - if(startPos.line !== 0 && !(editor.getLine(startPos.line).trim())) { - const prevLineText = editor.getLine(startPos.line - 1); - const chPrevLine = prevLineText ? prevLineText.length : 0; - editor.replaceRange("", {line: startPos.line - 1, ch: chPrevLine}, startPos); - } - }); - } - - /** - * this function is to clean up the empty lines after an element is removed - * @param {Object} editor - the editor instance - * @param {Object} range - the range where element was removed - */ - function _cleanupAfterRemoval(editor, range) { - const lineToCheck = range.from.line; - - // check if the line where element was removed is now empty - if (lineToCheck < editor.lineCount()) { - const currentLineText = editor.getLine(lineToCheck); - if (currentLineText && currentLineText.trim() === "") { - // remove the empty line - const lineStart = { line: lineToCheck, ch: 0 }; - const lineEnd = { line: lineToCheck + 1, ch: 0 }; - editor.replaceRange("", lineStart, lineEnd); - } - } - - // also we need to check the previous line if it became empty - if (lineToCheck > 0) { - const prevLineText = editor.getLine(lineToCheck - 1); - if (prevLineText && prevLineText.trim() === "") { - const lineStart = { line: lineToCheck - 1, ch: 0 }; - const lineEnd = { line: lineToCheck, ch: 0 }; - editor.replaceRange("", lineStart, lineEnd); - } - } - } - - /** - * this function is to make sure that we insert elements with proper indentation - * - * @param {Object} editor - the editor instance - * @param {Object} insertPos - position where to insert - * @param {Boolean} insertAfterMode - whether to insert after the position - * @param {String} targetIndent - the indentation to use - * @param {String} sourceText - the text to insert - */ - function _insertElementWithIndentation(editor, insertPos, insertAfterMode, targetIndent, sourceText) { - if (insertAfterMode) { - // Insert after the target element - editor.replaceRange("\n" + targetIndent + sourceText, insertPos); - } else { - // Insert before the target element - const insertLine = insertPos.line; - const lineStart = { line: insertLine, ch: 0 }; - - // Get current line content to preserve any existing indentation structure - const currentLine = editor.getLine(insertLine); - - if (currentLine && currentLine.trim() === "") { - // the line is empty, replace it entirely - editor.replaceRange(targetIndent + sourceText, lineStart, { line: insertLine, ch: currentLine.length }); - } else { - // the line has content, insert before it - editor.replaceRange(targetIndent + sourceText + "\n", lineStart); - } - } - } - - /** - * This function is to make sure that the target element doesn't lie completely within the source element - * because if that is the case then it means that the drag-drop was not performed correctly - * - * @param {Object} source - start/end pos of the source element - * @param {Object} target - start/end pos of the target element - * @returns {Boolean} true if target is fully inside source, false otherwise - */ - function _targetInsideSource(source, target) { - if ( - (source.from.line < target.from.line || - (source.from.line === target.from.line && source.from.ch <= target.from.ch)) && - (source.to.line > target.to.line || - (source.to.line === target.to.line && source.to.ch >= target.to.ch)) - ) { - return true; - } - - return false; - } - - /** - * This function is responsible for moving an element from one position to another in the source code - * it is called when there is drag-drop in the live preview - * @param {Number} sourceId - the data-brackets-id of the element being moved - * @param {Number} targetId - the data-brackets-id of the target element where to move - * @param {Boolean} insertAfter - whether to insert the source element after the target element - * @param {Boolean} insertInside - whether to insert the source element as a child of the target element - */ - function _moveElementInSource(sourceId, targetId, insertAfter, insertInside = false) { - // this is to get the currently live document that is being served in the live preview - const editor = _getEditorAndValidate(sourceId); - if (!editor || !targetId) { - return; - } - - const sourceRange = _getElementRange(editor, sourceId); - if (!sourceRange) { - return; - } - - const targetRange = _getElementRange(editor, targetId); - if (!targetRange) { - return; - } - - // convert to the format expected by the rest of the function - const sourceRangeObj = { - from: sourceRange.startPos, - to: sourceRange.endPos - }; - - const targetRangeObj = { - from: targetRange.startPos, - to: targetRange.endPos - }; - - // make sure that the target is not within the source - // this would otherwise remove both source and target, breaking the document - if (_targetInsideSource(sourceRangeObj, targetRangeObj)) { - return; - } - - const sourceText = editor.getTextBetween(sourceRangeObj.from, sourceRangeObj.to); - let targetIndent = editor.getTextBetween({ line: targetRangeObj.from.line, ch: 0 }, targetRangeObj.from); - if(targetIndent && targetIndent.trim() !== "") { // because indentation should hold no text - let indentLength = targetIndent.search(/\S/); - if (indentLength === -1) { - indentLength = targetIndent.length; - } - targetIndent = ' '.repeat(indentLength); - } - - // Check if source is before target to determine order of operations - // check if the source is before target or after the target - // we need this because - // If source is before target → we need to insert first, then remove - // If target is before source → remove first, then insert - const sourceBeforeTarget = - sourceRangeObj.from.line < targetRangeObj.from.line || - (sourceRangeObj.from.line === targetRangeObj.from.line && sourceRangeObj.from.ch < targetRangeObj.from.ch); - - // creating a batch operation so that undo in live preview works fine - editor.document.batchOperation(function () { - if (sourceBeforeTarget) { - // this handles the case when source is before target: insert first, then remove - if (insertInside) { - const matchingTagInfo = CodeMirror.findMatchingTag(editor._codeMirror, targetRangeObj.from); - if (matchingTagInfo && matchingTagInfo.open) { - const insertPos = { - line: matchingTagInfo.open.to.line, - ch: matchingTagInfo.open.to.ch - }; - - const indentInfo = editor._detectIndent(); - const childIndent = targetIndent + indentInfo.indent; - _insertElementWithIndentation(editor, insertPos, true, childIndent, sourceText); - } - } else if (insertAfter) { - const insertPos = { - line: targetRangeObj.to.line, - ch: targetRangeObj.to.ch - }; - _insertElementWithIndentation(editor, insertPos, true, targetIndent, sourceText); - } else { - // insert before target - _insertElementWithIndentation(editor, targetRangeObj.from, false, targetIndent, sourceText); - } - - // Now remove the source element (NOTE: the positions have shifted) - const updatedSourceRange = _getElementRange(editor, sourceId); - if (updatedSourceRange) { - const updatedSourceRangeObj = { - from: updatedSourceRange.startPos, - to: updatedSourceRange.endPos - }; - editor.replaceRange("", updatedSourceRangeObj.from, updatedSourceRangeObj.to); - _cleanupAfterRemoval(editor, updatedSourceRangeObj); - } - } else { - // This handles the case when target is before source: remove first, then insert - // Store source range before removal - const originalSourceRange = { ...sourceRangeObj }; - - // Remove the source element first - editor.replaceRange("", sourceRangeObj.from, sourceRangeObj.to); - _cleanupAfterRemoval(editor, originalSourceRange); - - // Recalculate target range after source removal as the positions have shifted - const updatedTargetRange = _getElementRange(editor, targetId); - if (!updatedTargetRange) { - return; - } - - const updatedTargetRangeObj = { - from: updatedTargetRange.startPos, - to: updatedTargetRange.endPos - }; - - if (insertInside) { - const matchingTagInfo = CodeMirror.findMatchingTag(editor._codeMirror, updatedTargetRangeObj.from); - if (matchingTagInfo && matchingTagInfo.open) { - const insertPos = { - line: matchingTagInfo.open.to.line, - ch: matchingTagInfo.open.to.ch - }; - - const indentInfo = editor._detectIndent(); - const childIndent = targetIndent + indentInfo.indent; - _insertElementWithIndentation(editor, insertPos, true, childIndent, sourceText); - } - } else if (insertAfter) { - const insertPos = { - line: updatedTargetRangeObj.to.line, - ch: updatedTargetRangeObj.to.ch - }; - _insertElementWithIndentation(editor, insertPos, true, targetIndent, sourceText); - } else { - // Insert before target - _insertElementWithIndentation(editor, updatedTargetRangeObj.from, false, targetIndent, sourceText); - } - } - }); - } - - /** - * This function is to handle the undo redo operation in the live preview - * @param {String} undoOrRedo - "undo" when to undo, and "redo" for redo - */ - function handleUndoRedoOperation(undoOrRedo) { - const editor = _getEditorAndValidate(); // no tagId needed for undo/redo - if (!editor) { - return; - } - - if (undoOrRedo === "undo") { - editor.undo(); - } else if (undoOrRedo === "redo") { - editor.redo(); - } - } - - function _getRequiredDataForAI(message) { - // this is to get the currently live document that is being served in the live preview - const editor = _getEditorAndValidate(message.tagId); - if (!editor) { - return; - } - - const range = _getElementRange(editor, message.tagId); - if (!range) { - return; - } - - const { startPos, endPos } = range; - // this is the actual source code for the element that we need to duplicate - const text = editor.getTextBetween(startPos, endPos); - const fileName = editor.document.file.name; - const filePath = editor.document.file.fullPath; - - const AIData = { - editor: editor, // the editor instance that is being served in the live preview - fileName: fileName, - filePath: filePath, // the complete absolute path - tagId: message.tagId, // the data-brackets-id of the element which was selected for AI edit - range: {startPos, endPos}, // the start and end position text in the source code for that element - text: text, // the actual source code in between the start and the end pos - prompt: message.prompt, // the prompt that user typed - model: message.selectedModel // the selected model (fast, slow or moderate) - }; - - return AIData; - } - - async function _editWithAI(message) { - const AIData = _getRequiredDataForAI(message); - const aiEntitlement = await KernalModeTrust.EntitlementsManager.getAIEntitlement(); - if (!aiEntitlement.activated) { - // Ai is not activated for user(not logged in/no ai plan/disabled by system admin) - // the showAIUpsellDialog will show an appropriate message for each case. - ProDialogs.showAIUpsellDialog(aiEntitlement); - return; - } - // todo @abose ai wire in - console.log(AIData); - } - - /** - * this is a helper function to make sure that when saving a new image, there's no existing file with the same name - * @param {String} basePath - this is the base path where the image will be saved - * @param {String} filename - the name of the image file - * @param {String} extnName - the name of the image extension. (defaults to "jpg") - * @returns {String} - the new file name - */ - function getUniqueFilename(basePath, filename, extnName) { - let counter = 0; - let uniqueFilename = filename + extnName; - - function checkAndIncrement() { - const filePath = basePath + uniqueFilename; - const file = FileSystem.getFileForPath(filePath); - - return new Promise((resolve) => { - file.exists((err, exists) => { - if (exists) { - counter++; - uniqueFilename = `${filename}-${counter}${extnName}`; - checkAndIncrement().then(resolve); - } else { - resolve(uniqueFilename); - } - }); - }); - } - - return checkAndIncrement(); - } - - /** - * This function updates the src attribute of an image element in the source code - * @param {Number} tagId - the data-brackets-id of the image element - * @param {String} newSrcValue - the new src value to set - */ - function _updateImageSrcAttribute(tagId, newSrcValue) { - const editor = _getEditorAndValidate(tagId); - if (!editor) { - return; - } - - const range = _getElementRange(editor, tagId); - if (!range) { - return; - } - - const { startPos, endPos } = range; - const elementText = editor.getTextBetween(startPos, endPos); - - // parse it using DOM parser so that we can update the src attribute - const parser = new DOMParser(); - const doc = parser.parseFromString(elementText, "text/html"); - const imgElement = doc.querySelector('img'); - - if (imgElement) { - imgElement.setAttribute('src', newSrcValue); - const updatedElementText = imgElement.outerHTML; - - editor.document.batchOperation(function () { - editor.replaceRange(updatedElementText, startPos, endPos); - }); - } - } - - function _sendDownloadStatusToBrowser(eventType, data) { - const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(); - if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) { - const dataJson = JSON.stringify(data || {}); - const evalString = `_LD.handleDownloadEvent('${eventType}', ${dataJson})`; - currLiveDoc.protocol.evaluate(evalString); - } - } - - function _handleDownloadError(error, downloadId) { - console.error('something went wrong while download the image. error:', error); - if (downloadId) { - _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.ERROR, { downloadId: downloadId }); - } - } - - function _trackDownload(downloadLocation) { - if (!downloadLocation) { - return; - } - fetch(`https://images.phcode.dev/api/images/download?download_location=${encodeURIComponent(downloadLocation)}`) - .catch(error => { - console.error('download tracking failed:', error); - }); - } - - /** - * Helper function to update image src attribute and dismiss ribbon gallery - * - * @param {Number} tagId - the data-brackets-id of the image element - * @param {String} targetPath - the full path where the image was saved - * @param {String} filename - the filename of the saved image - */ - function _updateImageAndDismissRibbon(tagId, targetPath, filename) { - const editor = _getEditorAndValidate(tagId); - if (editor) { - const htmlFilePath = editor.document.file.fullPath; - const relativePath = PathUtils.makePathRelative(targetPath, htmlFilePath); - _updateImageSrcAttribute(tagId, relativePath); - } else { - _updateImageSrcAttribute(tagId, filename); - } - - // dismiss all UI boxes including the image ribbon gallery - const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(); - if (currLiveDoc && currLiveDoc.protocol && currLiveDoc.protocol.evaluate) { - currLiveDoc.protocol.evaluate("_LD.dismissUIAndCleanupState()"); - } - } - - /** - * helper function to handle 'upload from computer' - * @param {Object} message - the message object - * @param {String} filename - the file name with which we need to save the image - * @param {Directory} projectRoot - the project root in which the image is to be saved - */ - function _handleUseThisImageLocalFiles(message, filename, projectRoot) { - const { tagId, imageData, downloadLocation, downloadId } = message; - - const uint8Array = new Uint8Array(imageData); - const targetPath = projectRoot.fullPath + filename; - - window.fs.writeFile(targetPath, window.Filer.Buffer.from(uint8Array), - { encoding: window.fs.BYTE_ARRAY_ENCODING }, (err) => { - if (err) { - _handleDownloadError(err, downloadId); - } else { - _trackDownload(downloadLocation); - _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.COMPLETED, { downloadId }); - _updateImageAndDismissRibbon(tagId, targetPath, filename); - } - }); - } - - /** - * helper function to handle 'use this image' button click on remote images - * @param {Object} message - the message object - * @param {String} filename - the file name with which we need to save the image - * @param {Directory} projectRoot - the project root in which the image is to be saved - */ - function _handleUseThisImageRemote(message, filename, projectRoot) { - const { imageUrl, tagId, downloadLocation, downloadId } = message; - - fetch(imageUrl) - .then(response => { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return response.arrayBuffer(); - }) - .then(arrayBuffer => { - const uint8Array = new Uint8Array(arrayBuffer); - const targetPath = projectRoot.fullPath + filename; - - window.fs.writeFile(targetPath, window.Filer.Buffer.from(uint8Array), - { encoding: window.fs.BYTE_ARRAY_ENCODING }, (err) => { - if (err) { - _handleDownloadError(err, downloadId); - } else { - _trackDownload(downloadLocation); - _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.COMPLETED, { downloadId }); - _updateImageAndDismissRibbon(tagId, targetPath, filename); - } - }); - }) - .catch(error => { - _handleDownloadError(error, downloadId); - }); - } - - /** - * Downloads image to the specified folder - * @private - * @param {Object} message - The message containing image download info - * @param {string} folderPath - Relative path to the folder - */ - function _downloadToFolder(message, folderPath) { - const projectRoot = ProjectManager.getProjectRoot(); - if (!projectRoot) { - console.error('No project root found'); - return; - } - - if (message.downloadId) { - _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.STARTED, { downloadId: message.downloadId }); - } - - const filename = message.filename; - const extnName = message.extnName || "jpg"; - - // the folder path should always end with / - if (!folderPath.endsWith('/')) { - folderPath += '/'; - } - - const targetPath = projectRoot.fullPath + folderPath; - const targetDir = FileSystem.getDirectoryForPath(targetPath); - - // the directory name that user wrote, first check if it exists or not - // if it doesn't exist we create it and then download the image inside it - targetDir.exists((err, exists) => { - if (err) { - _handleDownloadError(err, message.downloadId); - return; - } - - if (!exists) { - targetDir.create((err) => { - if (err) { - _handleDownloadError(err, message.downloadId); - return; - } - _downloadImageToDirectory(message, filename, extnName, targetDir); - }); - } else { - _downloadImageToDirectory(message, filename, extnName, targetDir); - } - }); - } - - /** - * This function is to determine whether we need to exclude a folder from the suggestions list - * so we exclude all the folders that start with . 'dot' as this are generally irrelevant dirs - * secondly, we also exclude large dirs like node modules as they might freeze the UI if we scan them - * @param {String} folderName - the folder name to check if we need to exclude it or not - * @returns {Boolean} - true if we should exclude otherwise false - */ - function _isExcludedFolder(folderName) { - if (folderName.startsWith('.')) { return true; } - - const UNNECESSARY_FOLDERS = ['node_modules', 'bower_components']; - if (UNNECESSARY_FOLDERS.includes(folderName)) { return true; } - - return false; - } - - /** - * this function scans all the root directories - * root directories means those directories that are directly inside the project folder - * we need this to show when the query is empty - * - * @param {Directory} directory - project root directory - * @param {Array} folderList - array to store discovered root folder paths - * @return {Promise} Resolves when root scan is complete - */ - function _scanRootDirectoriesOnly(directory, folderList) { - return new Promise((resolve) => { - directory.getContents((err, contents) => { - if (err) { - resolve(); - return; - } - - const directories = contents.filter(entry => entry.isDirectory); - - directories.forEach(dir => { - if (_isExcludedFolder(dir.name)) { return; } - // add root folder name with trailing slash - folderList.push(dir.name + '/'); - }); - resolve(); - }); - }); - } - - /** - * this function scans all the directories recursively - * and then add the relative paths of the directories to the folderList array - * - * @param {Directory} directory - The parent directory to scan - * @param {string} relativePath - The relative path from project root - * @param {Array} folderList - Array to store all discovered folder paths - * @return {Promise} Resolves when scanning is complete - */ - function _scanDirectories(directory, relativePath, folderList) { - return new Promise((resolve) => { - directory.getContents((err, contents) => { - if (err) { - resolve(); - return; - } - - const directories = contents.filter(entry => entry.isDirectory); - const scanPromises = []; - - directories.forEach(dir => { - if (_isExcludedFolder(dir.name)) { return; } - - const dirRelativePath = relativePath ? `${relativePath}${dir.name}/` : `${dir.name}/`; - folderList.push(dirRelativePath); - - // also check subdirectories for this dir - scanPromises.push(_scanDirectories(dir, dirRelativePath, folderList)); - }); - - Promise.all(scanPromises).then(() => resolve()); - }); - }); - } - - /** - * this function is responsible to get the subdirectories inside a directory - * we need this because we need to show the drilled down folders... - * @param {String} parentPath - Parent folder path (e.g., "images/") - * @param {Array} folderList - Complete list of all folder paths - * @return {Array} Array of direct subfolders only - */ - function _getSubfolders(parentPath, folderList) { - return folderList.filter(folder => { - if (!folder.startsWith(parentPath)) { return false; } - - const relativePath = folder.substring(parentPath.length); - const pathWithoutTrailingSlash = relativePath.replace(/\/$/, ''); - return !pathWithoutTrailingSlash.includes('/'); - }); - } - - /** - * Renders folder suggestions as a dropdown in the UI with fuzzy match highlighting - * - * @param {Array} matches - Array of folder paths (strings) or fuzzy match objects with stringRanges - * @param {JQuery} $suggestions - jQuery element for the suggestions container - * @param {JQuery} $input - jQuery element for the input field - */ - function _renderFolderSuggestions(matches, $suggestions, $input) { - if (matches.length === 0) { - $suggestions.empty(); - return; - } - - let html = '
    '; - matches.forEach((match, index) => { - let displayHTML = ''; - let folderPath = ''; - - // Check if match is a string or an object - if (typeof match === 'string') { - // Simple string (from empty query showing folders) - displayHTML = match; - folderPath = match; - } else if (match && match.stringRanges) { - // fuzzy match, highlight matched chars - match.stringRanges.forEach(range => { - if (range.matched) { - displayHTML += `${range.text}`; - } else { - displayHTML += range.text; - } - }); - folderPath = match.label || ''; - } - - // first item should be selected by default - const selectedClass = index === 0 ? ' selected' : ''; - html += `
  • ${displayHTML}
  • `; - }); - html += '
'; - - $suggestions.html(html); - $suggestions.scrollTop(0); // always need to scroll to top when query changes - - // when a suggestion is clicked we add the folder path in the input box - $suggestions.find('.folder-suggestion-item').on('click', function() { - const folderPath = $(this).data('path'); - $input.val(folderPath).trigger('input'); - }); - } - - /** - * This function is responsible to update the folder suggestion everytime a new char is inserted in the input field - * - * @param {string} query - The search query from the input field - * @param {Array} folderList - List of all available folder paths - * @param {Array} rootFolders - list of root-level folder paths - * @param {StringMatch.StringMatcher} stringMatcher - StringMatcher instance for fuzzy matching - * @param {JQuery} $suggestions - jQuery element for the suggestions container - * @param {JQuery} $input - jQuery element for the input field - */ - function _updateFolderSuggestions(query, folderList, rootFolders, stringMatcher, $suggestions, $input) { - if (!query || query.trim() === '') { - // when input is empty we show the root folders - _renderFolderSuggestions(rootFolders.slice(0, 15), $suggestions, $input); - return; - } - - // if the query ends with a / - // we then show the drilled down list of dirs inside that parent directory - if (query.endsWith('/')) { - const subfolders = _getSubfolders(query, folderList); - const formattedSubfolders = subfolders.map(folder => { - return stringMatcher.match(folder, query) || { label: folder, stringRanges: [{ text: folder, matched: false }] }; - }); - - _renderFolderSuggestions(formattedSubfolders.slice(0, 15), $suggestions, $input); - return; - } - - if (!stringMatcher) { return; } - - // filter folders using fuzzy matching - const matches = folderList - .map(folder => { - const result = stringMatcher.match(folder, query); - if (result) { - // get the last folder name (e.g., "assets/images/" -> "images") - const folderPath = result.label || folder; - const segments = folderPath.split('/').filter(s => s.length > 0); - const lastSegment = segments[segments.length - 1] || ''; - result.folderName = lastSegment.toLowerCase(); - - // we need to boost the score significantly if the last folder segment starts with the query - // This ensures folders like "images/" rank higher than "testing/maps/google/" when typing "image" - // note: here testing/maps/google has all the chars of 'image' - if (lastSegment.toLowerCase().startsWith(query.toLowerCase())) { - // Use a large positive boost (matchGoodness is negative, so we subtract a large negative number) - result.matchGoodness -= 10000; - } - // Also boost (but less) if the last segment contains the query as a substring - else if (lastSegment.toLowerCase().includes(query.toLowerCase())) { - result.matchGoodness -= 1000; - } - } - return result; - }) - .filter(result => result !== null && result !== undefined); - - // Sort by matchGoodness first (prefix matches will have best scores), - // then alphabetically by folder name, then by full path - StringMatch.multiFieldSort(matches, { matchGoodness: 0, folderName: 1, label: 2 }); - - const topMatches = matches.slice(0, 15); - _renderFolderSuggestions(topMatches, $suggestions, $input); - } - - /** - * register the input box handlers (folder selection dialog) - * also registers the 'arrow up/down and enter' key handler for folder selection and move the selected folder, - * in the list of suggestions - * - * @param {JQuery} $input - the input box element - * @param {JQuery} $suggestions - the suggestions list element - * @param {JQuery} $dlg - the dialog box element - */ - function _registerFolderDialogInputHandlers($input, $suggestions, $dlg) { - // keyboard navigation handler for arrow keys - $input.on('keydown', function(e) { - const isArrowDown = e.keyCode === 40; - const isArrowUp = e.keyCode === 38; - // we only want to handle the arrow up arrow down keys - if (!isArrowDown && !isArrowUp) { return; } - - e.preventDefault(); - const $items = $suggestions.find('.folder-suggestion-item'); - if ($items.length === 0) { return; } - - const $selected = $items.filter('.selected'); - - // determine which item to select next - let $nextItem; - if ($selected.length === 0) { - // no selection - select first or last based on direction - $nextItem = isArrowDown ? $items.first() : $items.last(); - } else { - // move selection - const currentIndex = $items.index($selected); - $selected.removeClass('selected'); - const nextIndex = isArrowDown - ? (currentIndex + 1) % $items.length - : (currentIndex - 1 + $items.length) % $items.length; - $nextItem = $items.eq(nextIndex); - } - - // apply selection and scroll the selected item into view (if not in view) - $nextItem.addClass('selected'); - if ($nextItem.length > 0) { - $nextItem[0].scrollIntoView({ block: "nearest", behavior: "auto" }); - } - }); - - // for enter key, we're using keyup handler because keydown was interfering with dialog's default behaviour - // when enter key is pressed, we check if there are any selected folders in the suggestions - // if yes, we type the folder path in the input box, - // if no, we click the ok button of the dialog - $input.on('keyup', function(e) { - if (e.keyCode === 13) { // enter key - const $items = $suggestions.find('.folder-suggestion-item'); - const $selected = $items.filter('.selected'); - - // if there's a selected suggestion, use it - if ($selected.length > 0) { - const folderPath = $selected.data('path'); - $input.val(folderPath).trigger('input'); - } else { - // no suggestions, trigger OK button click - $dlg.find('[data-button-id="ok"]').click(); - } - } - }); - } - - /** - * this shows the folder selection dialog for choosing where to download images - * @param {Object} message - the message object (optional, only needed when downloading image) - * @private - */ - function _showFolderSelectionDialog(message) { - const projectRoot = ProjectManager.getProjectRoot(); - if (!projectRoot) { return; } - - // show the dialog with a text box to select a folder - // dialog html is written in 'image-folder-dialog.html' - const templateVars = { - Strings: Strings - }; - const dialog = Dialogs.showModalDialogUsingTemplate(Mustache.render(ImageFolderDialogTemplate, templateVars), false); - const $dlg = dialog.getElement(); - const $input = $dlg.find("#folder-path-input"); - const $suggestions = $dlg.find("#folder-suggestions"); - const $rememberCheckbox = $dlg.find("#remember-folder-checkbox"); - - let folderList = []; - let rootFolders = []; - let stringMatcher = null; - - const persistFolder = StateManager.get(IMAGE_DOWNLOAD_PERSIST_FOLDER_KEY, StateManager.PROJECT_CONTEXT); - const shouldBeChecked = persistFolder !== false; - $rememberCheckbox.prop('checked', shouldBeChecked); - - _scanRootDirectoriesOnly(projectRoot, rootFolders).then(() => { - stringMatcher = new StringMatch.StringMatcher({ segmentedSearch: true }); - _renderFolderSuggestions(rootFolders.slice(0, 15), $suggestions, $input); - }); - - _scanDirectories(projectRoot, '', folderList); - - // input event handler - $input.on('input', function() { - _updateFolderSuggestions($input.val(), folderList, rootFolders, stringMatcher, $suggestions, $input); - }); - _registerFolderDialogInputHandlers($input, $suggestions, $dlg); - // focus the input box - setTimeout(function() { - $input.focus(); - }, 100); - - // handle dialog button clicks - // so the logic is either its an ok button click or cancel button click, so if its ok click - // then we download image in that folder and close the dialog, in close btn click we directly close the dialog - $dlg.one("buttonClick", function(e, buttonId) { - if (buttonId === Dialogs.DIALOG_BTN_OK) { - const folderPath = $input.val().trim(); - - // if the checkbox is checked, we save the folder preference for this project - const isChecked = $rememberCheckbox.is(':checked'); - StateManager.set(IMAGE_DOWNLOAD_PERSIST_FOLDER_KEY, isChecked, StateManager.PROJECT_CONTEXT); - if (isChecked) { - StateManager.set(IMAGE_DOWNLOAD_FOLDER_KEY, folderPath, StateManager.PROJECT_CONTEXT); - } else { - StateManager.set(IMAGE_DOWNLOAD_FOLDER_KEY, undefined, StateManager.PROJECT_CONTEXT); - } - - // if message is provided, download the image - if (message) { - _downloadToFolder(message, folderPath); - } - } else { - if (message && message.downloadId) { - _sendDownloadStatusToBrowser(DOWNLOAD_EVENTS.CANCELLED, { downloadId: message.downloadId }); - } - } - dialog.close(); - }); - } - - /** - * This function is called when 'use this image' button is clicked in the image ribbon gallery - * or user loads an image file from the computer - * this is responsible to download the image in the appropriate place - * and also change the src attribute of the element (by calling appropriate helper functions) - * - * @param {Object} message - the message object which stores all the required data for this operation - */ - function _handleUseThisImage(message) { - const projectRoot = ProjectManager.getProjectRoot(); - if (!projectRoot) { return; } - - // check if user has already saved a folder preference for this project - const savedFolder = StateManager.get(IMAGE_DOWNLOAD_FOLDER_KEY, StateManager.PROJECT_CONTEXT); - // we specifically check for nullish type vals because empty string is possible as it means project root - if (savedFolder !== null && savedFolder !== undefined) { - _downloadToFolder(message, savedFolder); - } else { - // show the folder selection dialog - _showFolderSelectionDialog(message); - } - } - - /** - * Helper function to download image to the specified directory - * - * @param {Object} message - Message containing image download info - * @param {string} filename - Name of the image file - * @param {string} extnName - File extension (e.g., "jpg") - * @param {Directory} targetDir - Target directory to save the image - */ - function _downloadImageToDirectory(message, filename, extnName, targetDir) { - getUniqueFilename(targetDir.fullPath, filename, extnName).then((uniqueFilename) => { - // check if the image is loaded from computer or from remote - if (message.isLocalFile && message.imageData) { - _handleUseThisImageLocalFiles(message, uniqueFilename, targetDir); - } else { - _handleUseThisImageRemote(message, uniqueFilename, targetDir); - } - }).catch(error => { - _handleDownloadError(error, message.downloadId); - }); - } - - /** - * Handles reset of image folder selection - clears the saved preference and shows the dialog - * @private - */ - function _handleResetImageFolderSelection() { - // clear the saved folder preference for this project - StateManager.set(IMAGE_DOWNLOAD_FOLDER_KEY, null, StateManager.PROJECT_CONTEXT); - - // show the folder selection dialog for the user to choose a new folder - // we pass null because we're not downloading an image, just setting the preference - _showFolderSelectionDialog(null); - } - - /** - * this function is responsible to save the active file (and previewed file, both might be same though) - * when ctrl/cmd + s is pressed in the live preview - */ - function _handleLivePreviewSave() { - // this saves the active file - CommandManager.execute(Commands.FILE_SAVE); - - // we also save the previewed file, (active file might be same as previewed or different) - const currLiveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(); - if (currLiveDoc && currLiveDoc.editor) { - const previewedDoc = currLiveDoc.editor.document; - CommandManager.execute(Commands.FILE_SAVE, { doc: previewedDoc }); - } - } - - /** - * This function is responsible to toggle the live preview Preview mode (play icon) - * this is done when user presses F8 key in the live preview - */ - function _handlePreviewModeToggle() { - const $previewBtn = $("#previewModeLivePreviewButton"); - if ($previewBtn.length > 0) { - $previewBtn.trigger("click"); - } - } - - /** - * This is the main function that is exported. - * it will be called by LiveDevProtocol when it receives a message from RemoteFunctions.js - * or LiveDevProtocolRemote.js (for undo) using MessageBroker - * Refer to: `handleOptionClick` function in the RemoteFunctions.js and `_receive` function in LiveDevProtocol.js - * - * @param {Object} message - this is the object that is passed by RemoteFunctions.js using MessageBroker - * this object will be in the format - * { - livePreviewEditEnabled: true, - tagId: tagId, - delete || duplicate || livePreviewTextEdit || AISend: true - undoLivePreviewOperation: true (this property is available only for undo operation) - - prompt: prompt (only for AI) - - sourceId: sourceId, (these are for move (drag & drop)) - targetId: targetId, - insertAfter: boolean, (whether to insert after the target element) - move: true - } - * these are the main properties that are passed through the message - */ - function handleLivePreviewEditOperation(message) { - // handle save current document in live preview (ctrl/cmd + s) - if (message.saveCurrentDocument) { - _handleLivePreviewSave(); - return; - } - - // toggle live preview mode using F8 key - if (message.toggleLivePreviewMode) { - _handlePreviewModeToggle(); - return; - } - - // handle reset image folder selection - if (message.resetImageFolderSelection) { - _handleResetImageFolderSelection(); - return; - } - - // handle image gallery state change message - if (message.type === "imageGalleryStateChange") { - LiveDevelopment.setImageGalleryState(message.selected); - return; - } - - // handle move(drag & drop) - if (message.move && message.sourceId && message.targetId) { - _moveElementInSource(message.sourceId, message.targetId, message.insertAfter, message.insertInside); - return; - } - - // use this image - if (message.useImage && message.imageUrl && message.filename) { - _handleUseThisImage(message); - return; - } - - if (!message.element || !message.tagId) { - // check for undo - if (message.undoLivePreviewOperation || message.redoLivePreviewOperation) { - message.undoLivePreviewOperation ? handleUndoRedoOperation("undo") : handleUndoRedoOperation("redo"); - } - return; - } - - // just call the required functions - if (message.delete) { - _deleteElementInSourceByTagId(message.tagId); - } else if (message.duplicate) { - _duplicateElementInSourceByTagId(message.tagId); - } else if (message.livePreviewTextEdit) { - _editTextInSource(message); - } else if (message.AISend) { - _editWithAI(message); - } - } - - exports.handleLivePreviewEditOperation = handleLivePreviewEditOperation; -}); diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js index 57b63570e3..cb0d7a554a 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js @@ -22,7 +22,8 @@ define(function (require, exports, module) { - var EditorManager = require("editor/EditorManager"), + const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), + EditorManager = require("editor/EditorManager"), EventDispatcher = require("utils/EventDispatcher"), PreferencesManager = require("preferences/PreferencesManager"), _ = require("thirdparty/lodash"); @@ -34,6 +35,16 @@ define(function (require, exports, module) { */ var SYNC_ERROR_CLASS = "live-preview-sync-error"; + function _simpleHash(str) { + let hash = 5381; + for (let i = 0; i < str.length; ) { + // eslint-disable-next-line no-bitwise + hash = (hash * 33) ^ str.charCodeAt(i++); + } + // eslint-disable-next-line no-bitwise + return hash >>> 0; + } + /** * @constructor * Base class for managing the connection between a live editor and the browser. Provides functions @@ -62,16 +73,11 @@ define(function (require, exports, module) { this._onActiveEditorChange = this._onActiveEditorChange.bind(this); this._onCursorActivity = this._onCursorActivity.bind(this); - this._onHighlightPrefChange = this._onHighlightPrefChange.bind(this); - - EditorManager.on(`activeEditorChange.LiveDocument-${this.doc.file.fullPath}`, this._onActiveEditorChange); - PreferencesManager.stateManager.getPreference("livedevHighlight") - .on(`change.LiveDocument-${this.doc.file.fullPath}`, this._onHighlightPrefChange); - - // Redraw highlights when window gets focus. This ensures that the highlights - // will be in sync with any DOM changes that may have occurred. - $(window).focus(this._onHighlightPrefChange); + // we cant use file paths for event registration - paths may have spaces(treated as an event list separator) + this.fileHashForEvents = _simpleHash(this.doc.file.fullPath); + EditorManager.off(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`); + EditorManager.on(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`, this._onActiveEditorChange); if (editor) { // Attach now @@ -85,12 +91,9 @@ define(function (require, exports, module) { * Closes the live document, terminating its connection to the browser. */ LiveDocument.prototype.close = function () { - + EditorManager.off(`activeEditorChange.LiveDocument-${this.fileHashForEvents}`); this._clearErrorDisplay(); this._detachFromEditor(); - EditorManager.off(`activeEditorChange.LiveDocument-${this.doc.file.fullPath}`); - PreferencesManager.stateManager.getPreference("livedevHighlight") - .off(`change.LiveDocument-${this.doc.file.fullPath}`); }; /** @@ -126,18 +129,6 @@ define(function (require, exports, module) { }; }; - /** - * @private - * Handles changes to the "Live Highlight" preference, switching it on/off in the browser as appropriate. - */ - LiveDocument.prototype._onHighlightPrefChange = function () { - if (this.isHighlightEnabled()) { - this.updateHighlight(); - } else { - this.hideHighlight(); - } - }; - /** * @private * Handles when the active editor changes, attaching to the new editor if it's for the current document. @@ -163,6 +154,7 @@ define(function (require, exports, module) { if (this.editor) { this.setInstrumentationEnabled(true, true); + this.editor.off("cursorActivity", this._onCursorActivity); this.editor.on("cursorActivity", this._onCursorActivity); this.updateHighlight(); } @@ -262,7 +254,7 @@ define(function (require, exports, module) { * @return {boolean} */ LiveDocument.prototype.isHighlightEnabled = function () { - return PreferencesManager.getViewState("livedevHighlight"); + return PreferencesManager.get(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE) !== CONSTANTS.LIVE_PREVIEW_MODE; }; /** diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js index 8d5b06138d..16730b2d7d 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js @@ -33,8 +33,8 @@ define(function (require, exports, module) { PerfUtils = require("utils/PerfUtils"), _ = require("thirdparty/lodash"), LiveDocument = require("LiveDevelopment/MultiBrowserImpl/documents/LiveDocument"), - HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"); - + HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"), + CSSUtils = require("language/CSSUtils"); /** * @constructor @@ -156,7 +156,28 @@ define(function (require, exports, module) { return; } var editor = this.editor, - ids = []; + mode = editor.getModeForSelection(), + ids = [], + selectors = []; + + // check if the cursor is in a stylesheet context (internal styles) + if (mode === "css" || mode === "text/x-scss" || mode === "text/x-less") { + // find the css selector + _.each(this.editor.getSelections(), function (sel) { + let selector = CSSUtils.findSelectorAtDocumentPos(editor, (sel.reversed ? sel.end : sel.start)); + if (selector) { + selectors.push(selector); + } + }); + + if (selectors.length) { + // to highlight the elements that match the css selectors + this.highlightRule(selectors.join(",")); + return; + } + } + + // its not found in css context, then it must be a inline style or a normal html element _.each(this.editor.getSelections(), function (sel) { var tagID = HTMLInstrumentation._getTagIDAtDocumentPos( editor, diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js index 0d660ec2e2..cfa52533f6 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js @@ -42,7 +42,8 @@ define(function (require, exports, module) { const EventDispatcher = require("utils/EventDispatcher"); // Text of the script we'll inject into the browser that handles protocol requests. - const LiveDevProtocolRemote = require("text!LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js"), + const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), + LiveDevProtocolRemote = require("text!LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js"), DocumentObserver = require("text!LiveDevelopment/BrowserScripts/DocumentObserver.js"), LanguageManager = require("language/LanguageManager"), RemoteFunctions = require("text!LiveDevelopment/BrowserScripts/RemoteFunctions.js"), @@ -52,8 +53,7 @@ define(function (require, exports, module) { HTMLInstrumentation = require("LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation"), StringUtils = require("utils/StringUtils"), FileViewController = require("project/FileViewController"), - MainViewManager = require("view/MainViewManager"), - LivePreviewEdit = require("LiveDevelopment/LivePreviewEdit"); + MainViewManager = require("view/MainViewManager"); const LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = `phoenix_live_preview_scripts_instrumented_${StringUtils.randomString(8)}.js`; const LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = `pageLoaderWorker_${StringUtils.randomString(8)}.js`; @@ -89,6 +89,16 @@ define(function (require, exports, module) { */ var _responseDeferreds = {}; + let _remoteFunctionProvider = null; + + /** + * The callback fn must return a single text string that will be used as remote function script + * @param callbackFn + */ + function setCustomRemoteFunctionProvider(callbackFn) { + _remoteFunctionProvider = callbackFn; + } + /** * Returns an array of the client IDs that are being managed by this live document. * @return {Array.} @@ -149,9 +159,9 @@ define(function (require, exports, module) { } function _tagSelectedInLivePreview(tagId, nodeName, contentEditable, allSelectors) { - const highlightPref = PreferencesManager.getViewState("livedevHighlight"); - if(!highlightPref){ - // live preview highlight and reverse highlight feature is disabled + const livePreviewMode = PreferencesManager.get(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE); + if(livePreviewMode === CONSTANTS.LIVE_PREVIEW_MODE){ + // hilights are enabled only in edit and highlight mode return; } const liveDoc = LiveDevMultiBrowser.getCurrentLiveDoc(), @@ -203,6 +213,11 @@ define(function (require, exports, module) { // for a fraction of a second. so a size of 1000 should be more than enough. }); + let _livePreviewMessageHandler; + function setLivePreviewMessageHandler(handler) { + _livePreviewMessageHandler = handler; + } + /** * @private * Handles a message received from the remote protocol handler via the transport. @@ -219,16 +234,23 @@ define(function (require, exports, module) { * only processed once and not from any reflections. */ function _receive(clientId, msgStr, messageID) { - var msg = JSON.parse(msgStr), - event = msg.method || "event", - deferred; + const msg = JSON.parse(msgStr), + event = msg.method || "event"; + let deferred; if(messageID && processedMessageIDs.has(messageID)){ return; // this message is already processed. } else if (messageID) { processedMessageIDs.set(messageID, true); } - if (msg.livePreviewEditEnabled) { - LivePreviewEdit.handleLivePreviewEditOperation(msg); + if(_livePreviewMessageHandler) { + let preventDefault = _livePreviewMessageHandler(msg); + if(preventDefault){ + return; + } + } + if(msg.requestConfigRefresh){ + LiveDevMultiBrowser.refreshConfig(); + return; } if (msg.id) { @@ -263,6 +285,11 @@ define(function (require, exports, module) { function _send(msg, clients) { var id = _nextMsgId++, result = new $.Deferred(); + if(!_transport){ + console.error("Cannot send message before live preview transport initialised"); + result.reject(); + return result.promise(); + } // broadcast if there are no specific clients clients = clients || getConnectionIds(); @@ -331,7 +358,6 @@ define(function (require, exports, module) { _transport.start(); } - /** * Returns a script that should be injected into the HTML that's launched in the * browser in order to implement remote commands that handle protocol requests. @@ -343,7 +369,12 @@ define(function (require, exports, module) { // Inject DocumentObserver into the browser (tracks related documents) script += DocumentObserver; // Inject remote functions into the browser. - script += "\nwindow._LD=(" + RemoteFunctions + "(" + JSON.stringify(LiveDevMultiBrowser.config) + "))"; + if(_remoteFunctionProvider){ + script += _remoteFunctionProvider(); + } else { + script += "\nwindow._LD=(" + RemoteFunctions + + "(" + JSON.stringify(LiveDevMultiBrowser.getConfig()) + "))"; + } return "\n" + script + "\n"; } @@ -481,6 +512,8 @@ define(function (require, exports, module) { exports.close = close; exports.getConnectionIds = getConnectionIds; exports.closeAllConnections = closeAllConnections; + exports.setLivePreviewMessageHandler = setLivePreviewMessageHandler; + exports.setCustomRemoteFunctionProvider = setCustomRemoteFunctionProvider; exports.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME; exports.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME; exports.EVENT_LIVE_PREVIEW_CLICKED = EVENT_LIVE_PREVIEW_CLICKED; diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js index ad343d5880..706c4111f7 100644 --- a/src/LiveDevelopment/main.js +++ b/src/LiveDevelopment/main.js @@ -19,7 +19,7 @@ * */ -/*global less, Phoenix */ +/*global less */ /** * main integrates LiveDevelopment into Brackets @@ -32,7 +32,8 @@ define(function main(require, exports, module) { - const Commands = require("command/Commands"), + const CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), + Commands = require("command/Commands"), AppInit = require("utils/AppInit"), MultiBrowserLiveDev = require("LiveDevelopment/LiveDevMultiBrowser"), LivePreviewTransport = require("LiveDevelopment/MultiBrowserImpl/transports/LivePreviewTransport"), @@ -43,24 +44,31 @@ define(function main(require, exports, module) { Strings = require("strings"), ExtensionUtils = require("utils/ExtensionUtils"), StringUtils = require("utils/StringUtils"), - EventDispatcher = require("utils/EventDispatcher"), - WorkspaceManager = require("view/WorkspaceManager"), - EditorManager = require("editor/EditorManager"); + EventDispatcher = require("utils/EventDispatcher"); - - const KernalModeTrust = window.KernalModeTrust; + const LIVE_PREVIEW_MODE = CONSTANTS.LIVE_PREVIEW_MODE, + LIVE_HIGHLIGHT_MODE = CONSTANTS.LIVE_HIGHLIGHT_MODE, + LIVE_EDIT_MODE = CONSTANTS.LIVE_EDIT_MODE; // this will later be assigned its correct values once entitlementsManager loads - let isProUser = false; - let isFreeTrialUser = false; + let hasLiveEditCapability = false; + let isPaidUser = false; - const EVENT_LIVE_HIGHLIGHT_PREF_CHANGED = "liveHighlightPrefChange"; - const PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode"; + const PREFERENCE_LIVE_PREVIEW_MODE = CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE; // state manager key to track image gallery selected state, by default we keep this as selected // if this is true we show the image gallery when an image element is clicked const IMAGE_GALLERY_STATE = "livePreview.imageGallery.state"; + PreferencesManager.definePreference(PREFERENCE_LIVE_PREVIEW_MODE, "string", LIVE_HIGHLIGHT_MODE, { + description: StringUtils.format( + Strings.LIVE_PREVIEW_MODE_PREFERENCE, LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE, LIVE_EDIT_MODE), + values: [LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE, LIVE_EDIT_MODE] + }).on("change", function () { + // when mode changes we update the config mode and notify remoteFunctions so that it can get updated + _previewModeUpdated(); + }); + /** * get the image gallery state from StateManager * @returns {boolean} true (default) @@ -78,83 +86,26 @@ define(function main(require, exports, module) { StateManager.set(IMAGE_GALLERY_STATE, state); // update the config with the new state + const config = MultiBrowserLiveDev.getConfig(); config.imageGalleryState = state; - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - } + MultiBrowserLiveDev.updateConfig(config); } - var params = new UrlParams(); - var config = { - experimental: false, // enable experimental features - debug: true, // enable debug output and helpers - highlight: true, // enable highlighting? - highlightConfig: { // the highlight configuration for the Inspector - borderColor: {r: 255, g: 229, b: 153, a: 0.66}, - contentColor: {r: 111, g: 168, b: 220, a: 0.55}, - marginColor: {r: 246, g: 178, b: 107, a: 0.66}, - paddingColor: {r: 147, g: 196, b: 125, a: 0.66}, - showInfo: true - }, - isProUser: isProUser, - elemHighlights: "hover", // default value, this will get updated when the extension loads + let params = new UrlParams(); + const defaultConfig = { + mode: LIVE_HIGHLIGHT_MODE, // will be updated when we fetch entitlements + elemHighlights: CONSTANTS.HIGHLIGHT_HOVER, // default value, this will get updated when the extension loads + showRulerLines: false, // default value, this will get updated when the extension loads imageGalleryState: _getImageGalleryState(), // image gallery selected state - // this strings are used in RemoteFunctions.js - // we need to pass this through config as remoteFunctions runs in browser context and cannot - // directly reference Strings file - strings: { - selectParent: Strings.LIVE_DEV_MORE_OPTIONS_SELECT_PARENT, - editText: Strings.LIVE_DEV_MORE_OPTIONS_EDIT_TEXT, - duplicate: Strings.LIVE_DEV_MORE_OPTIONS_DUPLICATE, - delete: Strings.LIVE_DEV_MORE_OPTIONS_DELETE, - ai: Strings.LIVE_DEV_MORE_OPTIONS_AI, - imageGallery: Strings.LIVE_DEV_MORE_OPTIONS_IMAGE_GALLERY, - aiPromptPlaceholder: Strings.LIVE_DEV_AI_PROMPT_PLACEHOLDER, - imageGalleryUseImage: Strings.LIVE_DEV_IMAGE_GALLERY_USE_IMAGE, - imageGallerySelectDownloadFolder: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_DOWNLOAD_FOLDER, - imageGallerySearchPlaceholder: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER, - imageGallerySearchButton: Strings.LIVE_DEV_IMAGE_GALLERY_SEARCH_BUTTON, - imageGalleryLoadingInitial: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_INITIAL, - imageGalleryLoadingMore: Strings.LIVE_DEV_IMAGE_GALLERY_LOADING_MORE, - imageGalleryNoImages: Strings.LIVE_DEV_IMAGE_GALLERY_NO_IMAGES, - imageGalleryLoadError: Strings.LIVE_DEV_IMAGE_GALLERY_LOAD_ERROR, - imageGalleryClose: Strings.LIVE_DEV_IMAGE_GALLERY_CLOSE, - imageGallerySelectFromComputer: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER, - imageGallerySelectFromComputerTooltip: Strings.LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER_TOOLTIP, - toastNotEditable: Strings.LIVE_DEV_TOAST_NOT_EDITABLE - } + isPaidUser: false // will be updated when we fetch entitlements }; + // Status labels/styles are ordered: error, not connected, progress1, progress2, connected. var _status, _allStatusStyles = ["warning", "info", "success", "out-of-sync", "sync-error"].join(" "); var _$btnGoLive; // reference to the GoLive button - var prefs = PreferencesManager.getExtensionPrefs("livedev"); - - // "livedev.remoteHighlight" preference - var PREF_REMOTEHIGHLIGHT = "remoteHighlight"; - var remoteHighlightPref = prefs.definePreference(PREF_REMOTEHIGHLIGHT, "object", { - animateStartValue: { - "background-color": "rgba(0, 162, 255, 0.5)", - "opacity": 0 - }, - animateEndValue: { - "background-color": "rgba(0, 162, 255, 0)", - "opacity": 0.6 - }, - "paddingStyling": { - "background-color": "rgba(200, 249, 197, 0.7)" - }, - "marginStyling": { - "background-color": "rgba(249, 204, 157, 0.7)" - }, - "borderColor": "rgba(200, 249, 197, 0.85)", - "showPaddingMargin": true - }, { - description: Strings.DESCRIPTION_LIVE_DEV_HIGHLIGHT_SETTINGS - }); - /** Load Live Development LESS Style */ function _loadStyles() { var lessText = require("text!LiveDevelopment/main.less"); @@ -269,105 +220,40 @@ define(function main(require, exports, module) { // Add checkmark when status is STATUS_ACTIVE; otherwise remove it CommandManager.get(Commands.FILE_LIVE_FILE_PREVIEW) .setChecked(status === MultiBrowserLiveDev.STATUS_ACTIVE); - CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT) - .setEnabled(status === MultiBrowserLiveDev.STATUS_ACTIVE); }); } - function _updateHighlightCheckmark() { - CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).setChecked(config.highlight); - exports.trigger(EVENT_LIVE_HIGHLIGHT_PREF_CHANGED, config.highlight); - } - - function togglePreviewHighlight() { - config.highlight = !config.highlight; - _updateHighlightCheckmark(); - if (config.highlight) { - MultiBrowserLiveDev.showHighlight(); - } else { - MultiBrowserLiveDev.hideHighlight(); - } - PreferencesManager.setViewState("livedevHighlight", config.highlight); - } - - /** Setup window references to useful LiveDevelopment modules */ - function _setupDebugHelpers() { - window.report = function report(params) { window.params = params; console.info(params); }; - } - - /** force reload the live preview currently only with shortcut ctrl-shift-R */ - function _handleReloadLivePreviewCommand() { - if (MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.reload(); - } - } - /** - * this function handles escape key for live preview to hide boxes if they are visible - * @param {Event} event + * Internal api used to update live edit capability status as entitlements changes. calling this will update the UI + * but will not functionally enable live editing capabilities as that are dependent on entitlements framework. + * @param newCapability + * @private */ - function _handleLivePreviewEscapeKey(event) { - // we only handle the escape keypress for live preview when its active - if (MultiBrowserLiveDev.status === MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.dismissLivePreviewBoxes(); - } - // returning false to let the editor also handle the escape key - return false; - } - - // default mode means on first load for pro user we have edit mode - // for free user we have highlight mode - function _getDefaultMode() { - return isProUser ? "edit" : "highlight"; - } - - // to set that mode in the preferences - function _initializeMode() { - if (isFreeTrialUser) { - PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "edit"); - return; - } - - const savedMode = PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode(); - - if (savedMode === "highlight" && isProUser) { - PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "edit"); - } else if (savedMode === "edit" && !isProUser) { - PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight"); + function _liveEditCapabilityChanged(newCapability) { + if(newCapability !== hasLiveEditCapability){ + hasLiveEditCapability = newCapability; + if(!hasLiveEditCapability && getCurrentMode() === LIVE_EDIT_MODE){ + // downgraded, so we need to disable live edit mode + setMode(LIVE_HIGHLIGHT_MODE); + } else if(hasLiveEditCapability) { + // this means that the user has switched to pro-account and we need to enable live edit mode + // as user may have just logged in with a pro-capable account/upgraded to pro. + setMode(LIVE_EDIT_MODE); + } } } - // this is called everytime there is a change in entitlements - async function _updateProUserStatus() { - if (!KernalModeTrust) { - return; - } - - try { - const entitlement = await KernalModeTrust.EntitlementsManager.getLiveEditEntitlement(); - - isProUser = entitlement.activated; - isFreeTrialUser = await KernalModeTrust.EntitlementsManager.isInProTrial(); - - config.isProUser = isProUser; - exports.isProUser = isProUser; - exports.isFreeTrialUser = isFreeTrialUser; - - _initializeMode(); - - if (MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - MultiBrowserLiveDev.registerHandlers(); - } - } catch (error) { - console.error("Error updating pro user status:", error); - isProUser = false; - isFreeTrialUser = false; + function _isPaidUserChanged(newStatus) { + if(newStatus !== isPaidUser){ + isPaidUser = newStatus; + const config = MultiBrowserLiveDev.getConfig(); + config.isPaidUser = isPaidUser; + MultiBrowserLiveDev.updateConfig(config); } } function setMode(mode) { - if (mode === "edit" && !exports.isProUser) { + if (mode === LIVE_EDIT_MODE && !hasLiveEditCapability) { return false; } PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, mode); @@ -375,31 +261,21 @@ define(function main(require, exports, module) { } function getCurrentMode() { - return PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE) || _getDefaultMode(); + return PreferencesManager.get(PREFERENCE_LIVE_PREVIEW_MODE); + } + + function isInPreviewMode() { + return getCurrentMode() === LIVE_PREVIEW_MODE; } /** Initialize LiveDevelopment */ AppInit.appReady(function () { params.parse(); - config.remoteHighlight = prefs.get(PREF_REMOTEHIGHLIGHT); - - // init experimental multi-browser implementation - // it can be enable by setting 'livedev.multibrowser' preference to true. - // It has to be initiated at this point in case of dynamically switching - // by changing the preference value. + const config = Object.assign({}, defaultConfig, MultiBrowserLiveDev.getConfig()); + config.mode = getCurrentMode(); MultiBrowserLiveDev.init(config); _loadStyles(); - _updateHighlightCheckmark(); - - // init pro user status and listen for changes - if (KernalModeTrust) { - _updateProUserStatus(); - KernalModeTrust.EntitlementsManager.on( - KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, - _updateProUserStatus - ); - } // update styles for UI status _status = [ @@ -416,18 +292,6 @@ define(function main(require, exports, module) { _setupGoLiveButton(); _setupGoLiveMenu(); - if (config.debug) { - _setupDebugHelpers(); - } - - remoteHighlightPref - .on("change", function () { - config.remoteHighlight = prefs.get(PREF_REMOTEHIGHLIGHT); - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - } - }); - MultiBrowserLiveDev.on(MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL, function (event, previewDetails) { exports.trigger(exports.EVENT_OPEN_PREVIEW_URL, previewDetails); }); @@ -440,82 +304,63 @@ define(function main(require, exports, module) { MultiBrowserLiveDev.on(MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_RELOAD, function (_event, clientDetails) { exports.trigger(exports.EVENT_LIVE_PREVIEW_RELOAD, clientDetails); }); - - // allow live preview to handle escape key event - // Escape is mainly to hide boxes if they are visible - WorkspaceManager.addEscapeKeyEventHandler("livePreview", _handleLivePreviewEscapeKey); }); - // init prefs - PreferencesManager.stateManager.definePreference("livedevHighlight", "boolean", true) - .on("change", function () { - config.highlight = PreferencesManager.getViewState("livedevHighlight"); - _updateHighlightCheckmark(); - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - } - }); - - PreferencesManager.definePreference(PREFERENCE_LIVE_PREVIEW_MODE, "string", _getDefaultMode(), { - description: StringUtils.format(Strings.LIVE_PREVIEW_MODE_PREFERENCE, "'preview'", "'highlight'", "'edit'"), - values: ["preview", "highlight", "edit"] - }); - - config.highlight = PreferencesManager.getViewState("livedevHighlight"); - - function setLivePreviewEditFeaturesActive(enabled) { - isProUser = enabled; - config.isProUser = enabled; - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - MultiBrowserLiveDev.registerHandlers(); + function _previewModeUpdated() { + const currentMode = getCurrentMode(); + if (currentMode === LIVE_EDIT_MODE && !hasLiveEditCapability) { + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, LIVE_HIGHLIGHT_MODE); + // we will get another update event for this immediately, so just return. + return; } + const config = MultiBrowserLiveDev.getConfig(); + config.mode = currentMode; + MultiBrowserLiveDev.updateConfig(config); } // this function is responsible to update element highlight config // called from live preview extension when preference changes function updateElementHighlightConfig() { - const prefValue = PreferencesManager.get("livePreviewElementHighlights"); - config.elemHighlights = prefValue || "hover"; - if (MultiBrowserLiveDev && MultiBrowserLiveDev.status >= MultiBrowserLiveDev.STATUS_ACTIVE) { - MultiBrowserLiveDev.updateConfig(JSON.stringify(config)); - MultiBrowserLiveDev.registerHandlers(); - } + const prefValue = PreferencesManager.get(CONSTANTS.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT); + const config = MultiBrowserLiveDev.getConfig(); + config.elemHighlights = prefValue || CONSTANTS.HIGHLIGHT_HOVER; + MultiBrowserLiveDev.updateConfig(config); } - // init commands - CommandManager.register(Strings.CMD_LIVE_HIGHLIGHT, Commands.FILE_LIVE_HIGHLIGHT, togglePreviewHighlight); - CommandManager.register(Strings.CMD_RELOAD_LIVE_PREVIEW, Commands.CMD_RELOAD_LIVE_PREVIEW, _handleReloadLivePreviewCommand); - - CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).setEnabled(false); + function updateRulerLinesConfig() { + const prefValue = PreferencesManager.get(CONSTANTS.PREFERENCE_SHOW_RULER_LINES); + const config = MultiBrowserLiveDev.getConfig(); + config.showRulerLines = prefValue || false; + MultiBrowserLiveDev.updateConfig(config); + } EventDispatcher.makeEventDispatcher(exports); - exports.isProUser = isProUser; - exports.isFreeTrialUser = isFreeTrialUser; + // private api + exports._liveEditCapabilityChanged = _liveEditCapabilityChanged; + exports._isPaidUserChanged = _isPaidUserChanged; // public events exports.EVENT_OPEN_PREVIEW_URL = MultiBrowserLiveDev.EVENT_OPEN_PREVIEW_URL; exports.EVENT_CONNECTION_CLOSE = MultiBrowserLiveDev.EVENT_CONNECTION_CLOSE; exports.EVENT_LIVE_PREVIEW_CLICKED = MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_CLICKED; exports.EVENT_LIVE_PREVIEW_RELOAD = MultiBrowserLiveDev.EVENT_LIVE_PREVIEW_RELOAD; - exports.EVENT_LIVE_HIGHLIGHT_PREF_CHANGED = EVENT_LIVE_HIGHLIGHT_PREF_CHANGED; // Export public functions + exports.CONSTANTS = CONSTANTS; exports.openLivePreview = openLivePreview; exports.closeLivePreview = closeLivePreview; exports.isInactive = isInactive; exports.isActive = isActive; exports.setLivePreviewPinned = setLivePreviewPinned; exports.setLivePreviewTransportBridge = setLivePreviewTransportBridge; - exports.togglePreviewHighlight = togglePreviewHighlight; - exports.setLivePreviewEditFeaturesActive = setLivePreviewEditFeaturesActive; exports.setImageGalleryState = setImageGalleryState; exports.updateElementHighlightConfig = updateElementHighlightConfig; + exports.updateRulerLinesConfig = updateRulerLinesConfig; exports.getConnectionIds = MultiBrowserLiveDev.getConnectionIds; exports.getLivePreviewDetails = MultiBrowserLiveDev.getLivePreviewDetails; exports.hideHighlight = MultiBrowserLiveDev.hideHighlight; - exports.dismissLivePreviewBoxes = MultiBrowserLiveDev.dismissLivePreviewBoxes; exports.setMode = setMode; exports.getCurrentMode = getCurrentMode; + exports.isInPreviewMode = isInPreviewMode; }); diff --git a/src/command/Commands.js b/src/command/Commands.js index dd9d5b0e64..188d1dfeff 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -103,9 +103,6 @@ define(function (require, exports, module) { /** Reloads live preview */ exports.CMD_RELOAD_LIVE_PREVIEW = "file.reloadLivePreview"; // LiveDevelopment/main.js _handleReloadLivePreviewCommand() - /** Toggles live highlight */ - exports.FILE_LIVE_HIGHLIGHT = "file.previewHighlight"; // LiveDevelopment/main.js _handlePreviewHighlightCommand() - /** Opens project settings */ exports.FILE_PROJECT_SETTINGS = "file.projectSettings"; // ProjectManager.js _projectSettings() diff --git a/src/editor/EditorHelper/ChangeHelper.js b/src/editor/EditorHelper/ChangeHelper.js index 9b20eb2dc6..3f9d50ba4d 100644 --- a/src/editor/EditorHelper/ChangeHelper.js +++ b/src/editor/EditorHelper/ChangeHelper.js @@ -25,6 +25,11 @@ define(function (require, exports, module) { + let _cutInterceptor = null; + let _copyInterceptor = null; + let _pasteInterceptor = null; + let _keyEventInterceptor = null; + const CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"), Menus = require("command/Menus"); @@ -170,6 +175,10 @@ define(function (require, exports, module) { // Redispatch these CodeMirror key events as Editor events function _onKeyEvent(instance, event) { + if(_keyEventInterceptor && _keyEventInterceptor(self, self._codeMirror, event)){ + // the interceptor processed it, so don't pass it along to CodeMirror' + return; + } self.trigger("keyEvent", self, event); // deprecated self.trigger(event.type, self, event); return event.defaultPrevented; // false tells CodeMirror we didn't eat the event @@ -242,6 +251,29 @@ define(function (require, exports, module) { elt.style.textIndent = "-" + off + "px"; elt.style.paddingLeft = off + "px"; }); + self._codeMirror.on("cut", function(cm, e) { + // Let interceptor decide what to do with the event (including preventDefault) + if (_cutInterceptor) { + return _cutInterceptor(self, cm, e); + } + // Otherwise allow normal cut behavior + }); + + self._codeMirror.on("copy", function(cm, e) { + // Let interceptor decide what to do with the event (including preventDefault) + if (_copyInterceptor) { + return _copyInterceptor(self, cm, e); + } + // Otherwise allow normal copy behavior + }); + + self._codeMirror.on("paste", function(cm, e) { + // Let interceptor decide what to do with the event (including preventDefault) + if (_pasteInterceptor) { + return _pasteInterceptor(self, cm, e); + } + // Otherwise allow normal paste behavior + }); } /** @@ -282,5 +314,45 @@ define(function (require, exports, module) { Editor.prototype._dontDismissPopupOnScroll = _dontDismissPopupOnScroll; } + /** + * Sets the cut interceptor function in codemirror + * @param {Function} interceptor - Function(editor, cm, event) that returns true to + preventDefault + */ + function setCutInterceptor(interceptor) { + _cutInterceptor = interceptor; + } + + /** + * Sets the copy interceptor function in codemirror + * @param {Function} interceptor - Function(editor, cm, event) that returns true to + preventDefault + */ + function setCopyInterceptor(interceptor) { + _copyInterceptor = interceptor; + } + + /** + * Sets the paste interceptor function in codemirror + * @param {Function} interceptor - Function(editor, cm, event) that returns true to + preventDefault + */ + function setPasteInterceptor(interceptor) { + _pasteInterceptor = interceptor; + } + + /** + * Sets the key down/up/press interceptor function in codemirror + * @param {Function} interceptor - Function(editor, cm, event) that returns true to + preventDefault + */ + function setKeyEventInterceptor(interceptor) { + _keyEventInterceptor = interceptor; + } + exports.addHelpers =addHelpers; + exports.setCutInterceptor = setCutInterceptor; + exports.setCopyInterceptor = setCopyInterceptor; + exports.setPasteInterceptor = setPasteInterceptor; + exports.setKeyEventInterceptor = setKeyEventInterceptor; }); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js b/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js index 65a65cad0c..e656669fa6 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js @@ -34,8 +34,6 @@ define(function (require, exports, module) { Mustache = require("thirdparty/mustache/mustache"), FileSystem = require("filesystem/FileSystem"), EventDispatcher = require("utils/EventDispatcher"), - CommandManager = require("command/CommandManager"), - Commands = require("command/Commands"), StringUtils = require("utils/StringUtils"), EventManager = require("utils/EventManager"), LivePreviewSettings = require("./LivePreviewSettings"), @@ -730,11 +728,8 @@ define(function (require, exports, module) { }); }); - function _isLiveHighlightEnabled() { - return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked(); - } exports.on(EVENT_EMBEDDED_IFRAME_ESCAPE_PRESS, function () { - if(!_isLiveHighlightEnabled()){ + if(LiveDevelopment.isInPreviewMode()){ return; } utils.focusActiveEditorIfFocusInLivePreview(); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js b/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js index 6a4e385797..77fb640a0c 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js @@ -37,8 +37,6 @@ define(function (require, exports, module) { LivePreviewSettings = require("./LivePreviewSettings"), ProjectManager = require("project/ProjectManager"), EventManager = require("utils/EventManager"), - CommandManager = require("command/CommandManager"), - Commands = require("command/Commands"), Strings = require("strings"), utils = require('./utils'), NativeApp = require("utils/NativeApp"), @@ -777,11 +775,8 @@ define(function (require, exports, module) { } }); - function _isLiveHighlightEnabled() { - return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked(); - } exports.on(EVENT_EMBEDDED_IFRAME_ESCAPE_PRESS, function () { - if(!_isLiveHighlightEnabled()){ + if(LiveDevelopment.isInPreviewMode()){ return; } utils.focusActiveEditorIfFocusInLivePreview(); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index 547851b0ea..bd6187d630 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -36,7 +36,7 @@ */ /*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ -/*global path, jsPromise*/ +/*global path*/ //jshint-ignore:no-start define(function (require, exports, module) { @@ -57,6 +57,7 @@ define(function (require, exports, module) { Strings = require("strings"), Mustache = require("thirdparty/mustache/mustache"), Metrics = require("utils/Metrics"), + CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), LiveDevelopment = require("LiveDevelopment/main"), LiveDevServerManager = require("LiveDevelopment/LiveDevServerManager"), MultiBrowserLiveDev = require("LiveDevelopment/LiveDevMultiBrowser"), @@ -75,6 +76,11 @@ define(function (require, exports, module) { ProDialogs = require("services/pro-dialogs"), utils = require('./utils'); + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + throw new Error("KernalModeTrust is not defined. Cannot boot without trust ring"); + } + const StateManager = PreferencesManager.stateManager; const STATE_CUSTOM_SERVER_BANNER_ACK = "customServerBannerDone"; let customServerModalBar; @@ -93,9 +99,16 @@ define(function (require, exports, module) { const PREFERENCE_LIVE_PREVIEW_MODE = "livePreviewMode"; // live preview element highlights preference (whether on hover or click) - const PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = "livePreviewElementHighlights"; - PreferencesManager.definePreference(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, "string", "hover", { - description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE + const PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT = CONSTANTS.PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT; + PreferencesManager.definePreference(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, "string", CONSTANTS.HIGHLIGHT_HOVER, { + description: Strings.LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE, + values: [CONSTANTS.HIGHLIGHT_HOVER, CONSTANTS.HIGHLIGHT_CLICK] + }); + + // live preview ruler lines preference (show/hide ruler lines on element selection) + const PREFERENCE_SHOW_RULER_LINES = CONSTANTS.PREFERENCE_SHOW_RULER_LINES; + PreferencesManager.definePreference(PREFERENCE_SHOW_RULER_LINES, "boolean", false, { + description: Strings.LIVE_DEV_SETTINGS_SHOW_RULER_LINES_PREFERENCE }); const LIVE_PREVIEW_PANEL_ID = "live-preview-panel"; @@ -150,6 +163,18 @@ define(function (require, exports, module) { let connectingOverlayTimer = null; // this is needed as we show the connecting overlay after 3s let connectingOverlayTimeDuration = 3000; + let isProEditUser = false; + // this is called everytime there is a change in entitlements + async function _entitlementsChanged() { + try { + const entitlement = await KernalModeTrust.EntitlementsManager.getLiveEditEntitlement(); + isProEditUser = entitlement.activated; + } catch (error) { + console.error("Error updating pro user status:", error); + isProEditUser = false; + } + } + StaticServer.on(EVENT_EMBEDDED_IFRAME_WHO_AM_I, function () { if($iframe && $iframe[0]) { const iframeDom = $iframe[0]; @@ -189,6 +214,12 @@ define(function (require, exports, module) { // for connecting status, we delay showing the overlay by 3 seconds if(status === MultiBrowserLiveDev.STATUS_CONNECTING) { connectingOverlayTimer = setTimeout(() => { + // before creating the overlays we need to do a recheck for custom server + // cause project prefs sometimes takes time to reload which causes overlays to appear for custom servers + if(LivePreviewSettings.isUsingCustomServer()){ + connectingOverlayTimer = null; + return; + } _createAndShowOverlay(textMessage, status); connectingOverlayTimer = null; }, connectingOverlayTimeDuration); @@ -274,44 +305,6 @@ define(function (require, exports, module) { } } - // this function is to check if the live highlight feature is enabled or not - function _isLiveHighlightEnabled() { - return CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).getChecked(); - } - - /** - * Live Preview 'Preview Mode'. in this mode no live preview highlight or any such features are active - * Just the plain website - */ - function _LPPreviewMode() { - LiveDevelopment.setLivePreviewEditFeaturesActive(false); - if(_isLiveHighlightEnabled()) { - LiveDevelopment.togglePreviewHighlight(); - } - } - - /** - * Live Preview 'Highlight Mode'. in this mode only the live preview matching with the source code is active - * Meaning that if user clicks on some element that element's source code will be highlighted and vice versa - */ - function _LPHighlightMode() { - LiveDevelopment.setLivePreviewEditFeaturesActive(false); - if(!_isLiveHighlightEnabled()) { - LiveDevelopment.togglePreviewHighlight(); - } - } - - /** - * Live Preview 'Edit Mode'. this is the most interactive mode, in here the highlight features are available - * along with that we also show element's highlighted boxes and such - */ - function _LPEditMode() { - LiveDevelopment.setLivePreviewEditFeaturesActive(true); - if(!_isLiveHighlightEnabled()) { - LiveDevelopment.togglePreviewHighlight(); - } - } - /** * update the mode button text in the live preview toolbar UI based on the current mode * @param {String} mode - The current mode ("preview", "highlight", or "edit") @@ -331,22 +324,18 @@ define(function (require, exports, module) { function _initializeMode() { const currentMode = LiveDevelopment.getCurrentMode(); - if (currentMode === "highlight") { - _LPHighlightMode(); - $previewBtn.removeClass('selected'); - } else if (currentMode === "edit") { - _LPEditMode(); - $previewBtn.removeClass('selected'); - } else { - _LPPreviewMode(); + // when in preview mode, we need to give the play button a selected state + if (currentMode === LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE) { $previewBtn.addClass('selected'); + } else { + $previewBtn.removeClass('selected'); } _updateModeButton(currentMode); } function _showModeSelectionDropdown(event) { - const isEditFeaturesActive = LiveDevelopment.isProUser; + const isEditFeaturesActive = isProEditUser; const items = [ Strings.LIVE_PREVIEW_MODE_PREVIEW, Strings.LIVE_PREVIEW_MODE_HIGHLIGHT, @@ -357,6 +346,7 @@ define(function (require, exports, module) { if (isEditFeaturesActive) { items.push("---"); items.push(Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON); + items.push(Strings.LIVE_PREVIEW_SHOW_RULER_LINES); } const currentMode = LiveDevelopment.getCurrentMode(); @@ -364,22 +354,33 @@ define(function (require, exports, module) { $dropdown = new DropdownButton.DropdownButton("", items, function(item, index) { if (item === Strings.LIVE_PREVIEW_MODE_PREVIEW) { // using empty spaces to keep content aligned - return currentMode === "preview" ? `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; + return currentMode === LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE ? + `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; } else if (item === Strings.LIVE_PREVIEW_MODE_HIGHLIGHT) { - return currentMode === "highlight" ? `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; + return currentMode === LiveDevelopment.CONSTANTS.LIVE_HIGHLIGHT_MODE ? + `✓ ${item}` : `${'\u00A0'.repeat(4)}${item}`; } else if (item === Strings.LIVE_PREVIEW_MODE_EDIT) { - const checkmark = currentMode === "edit" ? "✓ " : `${'\u00A0'.repeat(4)}`; - const crownIcon = !isEditFeaturesActive ? ' Pro' : ''; + const checkmark = currentMode === LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE ? + "✓ " : `${'\u00A0'.repeat(4)}`; + const crownIcon = !isEditFeaturesActive ? + ' Pro' : ''; return { html: `${checkmark}${item}${crownIcon}`, enabled: true }; } else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) { - const isHoverMode = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT) !== "click"; + const isHoverMode = + PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT) === CONSTANTS.HIGHLIGHT_HOVER; if(isHoverMode) { return `✓ ${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`; } return `${'\u00A0'.repeat(4)}${Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON}`; + } else if (item === Strings.LIVE_PREVIEW_SHOW_RULER_LINES) { + const isEnabled = PreferencesManager.get(PREFERENCE_SHOW_RULER_LINES); + if(isEnabled) { + return `✓ ${Strings.LIVE_PREVIEW_SHOW_RULER_LINES}`; + } + return `${'\u00A0'.repeat(4)}${Strings.LIVE_PREVIEW_SHOW_RULER_LINES}`; } return item; }); @@ -405,11 +406,11 @@ define(function (require, exports, module) { // handle the option selection $dropdown.on("select", function (e, item, index) { if (index === 0) { - LiveDevelopment.setMode("preview"); + LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE); } else if (index === 1) { - LiveDevelopment.setMode("highlight"); + LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_HIGHLIGHT_MODE); } else if (index === 2) { - if (!LiveDevelopment.setMode("edit")) { + if (!LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE)) { ProDialogs.showProUpsellDialog(ProDialogs.UPSELL_TYPE_LIVE_EDIT); } } else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) { @@ -419,14 +420,20 @@ define(function (require, exports, module) { } // Toggle between hover and click const currMode = PreferencesManager.get(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT); - const newMode = currMode !== "click" ? "click" : "hover"; + const newMode = (currMode !== CONSTANTS.HIGHLIGHT_CLICK) ? + CONSTANTS.HIGHLIGHT_CLICK : CONSTANTS.HIGHLIGHT_HOVER; PreferencesManager.set(PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, newMode); return; // Don't dismiss highlights for this option + } else if (item === Strings.LIVE_PREVIEW_SHOW_RULER_LINES) { + // Don't allow ruler lines toggle if edit features are not active + if (!isEditFeaturesActive) { + return; + } + // Toggle ruler lines on/off + const currentValue = PreferencesManager.get(PREFERENCE_SHOW_RULER_LINES); + PreferencesManager.set(PREFERENCE_SHOW_RULER_LINES, !currentValue); + return; // Don't dismiss highlights for this option } - - // need to dismiss the previous highlighting and stuff - LiveDevelopment.hideHighlight(); - LiveDevelopment.dismissLivePreviewBoxes(); }); // Remove the button after the dropdown is hidden @@ -669,10 +676,14 @@ define(function (require, exports, module) { function _handlePreviewBtnClick() { if($previewBtn.hasClass('selected')) { $previewBtn.removeClass('selected'); - const isEditFeaturesActive = LiveDevelopment.isProUser; + const isEditFeaturesActive = isProEditUser; if(modeThatWasSelected) { - if(modeThatWasSelected === 'edit' && !isEditFeaturesActive) { - // we just set the preference as preference has change handlers that will update the config + // If the last selected mode was preview itself, default to the best mode for user's entitlement + if(modeThatWasSelected === 'preview') { + const defaultMode = isEditFeaturesActive ? 'edit' : 'highlight'; + PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, defaultMode); + } else if(modeThatWasSelected === 'edit' && !isEditFeaturesActive) { + // Non-pro users can't be in edit mode - switch to highlight PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "highlight"); } else { PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, modeThatWasSelected); @@ -1191,10 +1202,15 @@ define(function (require, exports, module) { }); CommandManager.register(Strings.CMD_LIVE_FILE_PREVIEW_SETTINGS, Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, _showSettingsDialog); + CommandManager.register(Strings.CMD_RELOAD_LIVE_PREVIEW, Commands.CMD_RELOAD_LIVE_PREVIEW, function () { + _loadPreview(true, true); + }); let fileMenu = Menus.getMenu(Menus.AppMenuBar.FILE_MENU); fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW, "", Menus.AFTER, Commands.FILE_EXTENSION_MANAGER); - fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, "", + fileMenu.addMenuItem(Commands.CMD_RELOAD_LIVE_PREVIEW, "", Menus.AFTER, Commands.FILE_LIVE_FILE_PREVIEW); + fileMenu.addMenuItem(Commands.FILE_LIVE_FILE_PREVIEW_SETTINGS, "", + Menus.AFTER, Commands.CMD_RELOAD_LIVE_PREVIEW); fileMenu.addMenuDivider(Menus.BEFORE, Commands.FILE_LIVE_FILE_PREVIEW); _registerHandlers(); @@ -1205,13 +1221,17 @@ define(function (require, exports, module) { _initializeMode(); }); - // Handle element highlight preference changes from this extension + // Handle element highlight & ruler lines preference changes PreferencesManager.on("change", PREFERENCE_PROJECT_ELEMENT_HIGHLIGHT, function() { LiveDevelopment.updateElementHighlightConfig(); }); + PreferencesManager.on("change", PREFERENCE_SHOW_RULER_LINES, function() { + LiveDevelopment.updateRulerLinesConfig(); + }); - // Initialize element highlight config on startup + // Initialize element highlight and ruler lines config on startup LiveDevelopment.updateElementHighlightConfig(); + LiveDevelopment.updateRulerLinesConfig(); LiveDevelopment.openLivePreview(); LiveDevelopment.on(LiveDevelopment.EVENT_OPEN_PREVIEW_URL, _openLivePreviewURL); @@ -1291,6 +1311,13 @@ define(function (require, exports, module) { } }, 1000); _projectOpened(); + if(!Phoenix.isSpecRunnerWindow){ + _entitlementsChanged(); + KernalModeTrust.EntitlementsManager.on( + KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, + _entitlementsChanged + ); + } }); // private API to be used inside phoenix codebase only diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js index 523db814bb..a93c82ef1d 100644 --- a/src/extensionsIntegrated/loader.js +++ b/src/extensionsIntegrated/loader.js @@ -46,4 +46,5 @@ define(function (require, exports, module) { require("./TabBar/main"); require("./CustomSnippets/main"); require("./CollapseFolders/main"); + require("./pro-loader"); }); diff --git a/src/help/HelpCommandHandlers.js b/src/help/HelpCommandHandlers.js index 3286f8abb5..41b5012593 100644 --- a/src/help/HelpCommandHandlers.js +++ b/src/help/HelpCommandHandlers.js @@ -72,6 +72,11 @@ define(function (require, exports, module) { Strings: Strings }; + // If we have phoenix pro optional features built in + if (Phoenix.pro.commitID) { + templateVars.PRO_BUILD_COMMIT = window.Phoenix.pro.commitID; + } + Dialogs.showModalDialogUsingTemplate(Mustache.render(AboutDialogTemplate, templateVars)); // Get containers diff --git a/src/htmlContent/about-dialog.html b/src/htmlContent/about-dialog.html index cac9afe93c..81f495c252 100644 --- a/src/htmlContent/about-dialog.html +++ b/src/htmlContent/about-dialog.html @@ -8,6 +8,9 @@

{{Strings.ABOUT}}

{{APP_NAME_ABOUT_BOX}}

{{Strings.ABOUT_TEXT_LINE1}} {{BUILD_INFO}} + {{#PRO_BUILD_COMMIT}} +
{{Strings.ABOUT_TEXT_PRO_BUILD}}{{PRO_BUILD_COMMIT}} + {{/PRO_BUILD_COMMIT}} {{#BUILD_TIMESTAMP}}
{{Strings.ABOUT_TEXT_BUILD_TIMESTAMP}}{{BUILD_TIMESTAMP}} {{/BUILD_TIMESTAMP}} diff --git a/src/htmlContent/image-folder-dialog.html b/src/htmlContent/image-folder-dialog.html deleted file mode 100644 index 278fb574e2..0000000000 --- a/src/htmlContent/image-folder-dialog.html +++ /dev/null @@ -1,32 +0,0 @@ -

diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index a930263878..b29f1e554b 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -183,13 +183,20 @@ define({ "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT": "Show Live Preview Element Highlights on:", "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_HOVER": "hover", "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_CLICK": "click", - "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE": "show live preview element highlights on 'hover' or 'click'. Defaults to 'hover'", + "LIVE_DEV_SETTINGS_ELEMENT_HIGHLIGHT_PREFERENCE": "Show live preview element highlights on 'hover' or 'click'. Defaults to 'hover'", + "LIVE_DEV_SETTINGS_SHOW_RULER_LINES_PREFERENCE": "Show measurements when elements are selected in live preview. Defaults to 'false'", "LIVE_DEV_MORE_OPTIONS_SELECT_PARENT": "Select Parent", "LIVE_DEV_MORE_OPTIONS_EDIT_TEXT": "Edit Text", + "LIVE_DEV_MORE_OPTIONS_EDIT_HYPERLINK": "Edit Hyperlink", + "LIVE_DEV_HYPERLINK_NO_HREF": "No href set", "LIVE_DEV_MORE_OPTIONS_DUPLICATE": "Duplicate", "LIVE_DEV_MORE_OPTIONS_DELETE": "Delete", "LIVE_DEV_MORE_OPTIONS_AI": "Edit with AI", "LIVE_DEV_MORE_OPTIONS_IMAGE_GALLERY": "Image Gallery", + "LIVE_DEV_MORE_OPTIONS_MORE": "More Options", + "LIVE_DEV_MORE_OPTIONS_CUT": "Cut", + "LIVE_DEV_MORE_OPTIONS_COPY": "Copy", + "LIVE_DEV_MORE_OPTIONS_PASTE": "Paste", "LIVE_DEV_IMAGE_GALLERY_USE_IMAGE": "Use this image", "LIVE_DEV_IMAGE_GALLERY_SELECT_DOWNLOAD_FOLDER": "Choose image download folder", "LIVE_DEV_IMAGE_GALLERY_SEARCH_PLACEHOLDER": "Search images\u2026", @@ -201,7 +208,13 @@ define({ "LIVE_DEV_IMAGE_GALLERY_CLOSE": "Close", "LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER_TOOLTIP": "Select an image from your device", "LIVE_DEV_IMAGE_GALLERY_SELECT_FROM_COMPUTER": "Select from device", + "LIVE_DEV_IMAGE_GALLERY_DIALOG_OVERLAY_MESSAGE": "Select image download location in the editor to continue", + "LIVE_DEV_IMAGE_GALLERY_OFFLINE_BANNER": "No connection - Working in offline mode", + "LIVE_DEV_IMAGE_GALLERY_OFFLINE_RETRY": "Retry", + "LIVE_DEV_IMAGE_GALLERY_CHECKING_CONNECTION": "Checking connection", + "LIVE_DEV_IMAGE_GALLERY_STILL_OFFLINE": "Still offline. Please check your connection.", "LIVE_DEV_TOAST_NOT_EDITABLE": "Element not editable - generated by script.", + "LIVE_DEV_COPY_TOAST_MESSAGE": "Element copied! Click 'Paste' on any element to insert it above.", "LIVE_DEV_IMAGE_FOLDER_DIALOG_TITLE": "Select Folder to Save Image", "LIVE_DEV_IMAGE_FOLDER_DIALOG_DESCRIPTION": "Choose where to download the image:", "LIVE_DEV_IMAGE_FOLDER_DIALOG_PLACEHOLDER": "Type folder path (e.g., assets/images/)", @@ -214,7 +227,8 @@ define({ "LIVE_PREVIEW_MODE_HIGHLIGHT": "Highlight Mode", "LIVE_PREVIEW_MODE_EDIT": "Edit Mode", "LIVE_PREVIEW_EDIT_HIGHLIGHT_ON": "Edit Highlights on Hover", - "LIVE_PREVIEW_MODE_PREFERENCE": "{0} shows only the webpage, {1} connects the webpage to your code - click on elements to jump to their code and vice versa, {2} provides highlighting along with advanced element manipulation", + "LIVE_PREVIEW_SHOW_RULER_LINES": "Show Measurements", + "LIVE_PREVIEW_MODE_PREFERENCE": "'{0}' shows only the webpage, '{1}' connects the webpage to your code - click on elements to jump to their code and vice versa, '{2}' provides highlighting along with advanced element manipulation", "LIVE_PREVIEW_CONFIGURE_MODES": "Configure Live Preview Modes", "LIVE_DEV_DETACHED_REPLACED_WITH_DEVTOOLS": "Live Preview was canceled because the browser's developer tools were opened", @@ -529,7 +543,7 @@ define({ "CMD_LIVE_FILE_PREVIEW": "Live Preview", "CMD_LIVE_FILE_PREVIEW_SETTINGS": "Live Preview Settings", "CMD_TOGGLE_LIVE_PREVIEW_MB_MODE": "Enable Experimental Live Preview", - "CMD_RELOAD_LIVE_PREVIEW": "Force Reload Live Preview", + "CMD_RELOAD_LIVE_PREVIEW": "Reload Live Preview", "CMD_PROJECT_SETTINGS": "Project Settings\u2026", "CMD_FILE_RENAME": "Rename", "CMD_FILE_DELETE": "Delete", @@ -606,7 +620,6 @@ define({ "CMD_TOGGLE_LINE_NUMBERS": "Line Numbers", "CMD_TOGGLE_ACTIVE_LINE": "Highlight Active Line", "CMD_TOGGLE_WORD_WRAP": "Word Wrap", - "CMD_LIVE_HIGHLIGHT": "Live Preview Highlight", "CMD_VIEW_TOGGLE_INSPECTION": "Lint Files on Save", "CMD_VIEW_TOGGLE_PROBLEMS": "Problems", "CMD_WORKINGSET_SORT_BY_ADDED": "Sort by Added", @@ -693,6 +706,7 @@ define({ "CLOSE": "Close", "ABOUT_TEXT_LINE1": "Release {VERSION_MAJOR}.{VERSION_MINOR} {BUILD_TYPE} {VERSION}", "ABOUT_TEXT_BUILD_TIMESTAMP": "build timestamp: ", + "ABOUT_TEXT_PRO_BUILD": "Phoenix Pro Build: ", "ABOUT_RELEASE_CREDITS": "Release Credits:", "ABOUT_TEXT_LINE3": "Third Party Libraries that we use - Licences and Attributions . ", "ABOUT_TEXT_LINE4": "Documentation and source at https://github.com/phcode-dev/phoenix/", @@ -1132,7 +1146,6 @@ define({ "DESCRIPTION_INDENT_LINE_COMMENT": "true to enable indenting of line comments", "DESCRIPTION_RECENT_FILES_NAV": "Enable/disable navigation in recent files", "DESCRIPTION_LIVEDEV_WEBSOCKET_PORT": "Port on which WebSocket Server runs for Live Preview", - "DESCRIPTION_LIVE_DEV_HIGHLIGHT_SETTINGS": "Live Preview Highlight settings", "DESCRIPTION_LIVEDEV_ENABLE_REVERSE_INSPECT": "false to disable live preview reverse inspect", "DESCRIPTION_LIVEDEV_NO_PREVIEW": "Nothing to preview!", "DESCRIPTION_LIVEDEV_EXCLUDED": "Custom Server Cannot Serve This file", diff --git a/src/phoenix/shell.js b/src/phoenix/shell.js index bf018f2ed0..e4fdd6ac99 100644 --- a/src/phoenix/shell.js +++ b/src/phoenix/shell.js @@ -177,14 +177,6 @@ Phoenix.app = { window.__TAURI__.window.getCurrent().setFocus(); window.__TAURI__.window.getCurrent().setAlwaysOnTop(false); }, - clipboardReadText: function () { - if(Phoenix.isNativeApp){ - return window.__TAURI__.clipboard.readText(); - } else if(window.navigator && window.navigator.clipboard){ - return window.navigator.clipboard.readText(); - } - return Promise.reject(new Error("clipboardReadText: Not supported.")); - }, /** * Gets the commandline argument in desktop builds and null in browser builds. * Will always return CLI of the current process only. @@ -278,6 +270,14 @@ Phoenix.app = { }); } }, + clipboardReadText: function () { + if(Phoenix.isNativeApp){ + return window.__TAURI__.clipboard.readText(); + } else if(window.navigator && window.navigator.clipboard){ + return window.navigator.clipboard.readText(); + } + return Promise.reject(new Error("clipboardReadText: Not supported.")); + }, clipboardReadFiles: function () { return new Promise((resolve, reject)=>{ if(Phoenix.isNativeApp){ diff --git a/src/robots.txt b/src/robots.txt index 0996382567..5ffe8d9525 100644 --- a/src/robots.txt +++ b/src/robots.txt @@ -1,5 +1,5 @@ # The use of robots or other automated means to access the sites managed by core.ai -# without the express permission of Adobe is strictly prohibited. +# without the express permission of core.ai is strictly prohibited. # Notwithstanding the foregoing, core.ai may permit automated access to # access certain pages but solely for the limited purpose of # including content in publicly available search engines. Any other diff --git a/src/services/EntitlementsManager.js b/src/services/EntitlementsManager.js index 964b494072..f4ec73ff4c 100644 --- a/src/services/EntitlementsManager.js +++ b/src/services/EntitlementsManager.js @@ -75,8 +75,12 @@ define(function (require, exports, module) { }); } + let _entitlementFnForTests; let effectiveEntitlementsCached = undefined; // entitlements can be null and its valid if no login/trial async function _getEffectiveEntitlements() { + if(_entitlementFnForTests){ + return _entitlementFnForTests(); + } if(effectiveEntitlementsCached !== undefined){ return effectiveEntitlementsCached; } @@ -358,7 +362,11 @@ define(function (require, exports, module) { getRawEntitlements, getNotifications, getLiveEditEntitlement, - loginToAccount + loginToAccount, + simulateEntitlementForTests: (entitlementsFn) => { + _entitlementFnForTests = entitlementsFn; + EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED); + } }; } diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index ee4f57cb18..7a5bc286c7 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -2522,20 +2522,20 @@ code { height: 30px; padding: 5px; box-sizing: border-box; - margin-bottom: 8px; + margin-bottom: 10px; } #folder-suggestions { max-height: 150px; overflow-y: auto; overflow-x: hidden; - border: 1px solid @bc-btn-border; - border-radius: @bc-border-radius; - background-color: @bc-panel-bg-alt; + outline: 1px solid #f5f5f5; + border-radius: 3px; + background-color: #f5f5f5; .dark & { - border: 1px solid @dark-bc-btn-border; - background-color: @dark-bc-panel-bg-alt; + background-color: #1E1E1E; + outline: 1px solid #1E1E1E; } &:empty { @@ -2550,37 +2550,60 @@ code { .folder-suggestion-item { padding: 6px 10px; + display: flex; + align-items: center; cursor: pointer; - font-size: 12px; - color: @bc-text; - border-left: 3px solid transparent; + font-size: 0.875rem; + letter-spacing: 0.4px; + word-spacing: 0.75px; + color: #555; + background-color: #f1f1f1; + border-right: 1px solid rgba(0, 0, 0, 0.05); + position: relative; + user-select: none; .dark & { - color: @dark-bc-text; + color: #aaa; + background-color: #292929; + border-right: 1px solid rgba(255, 255, 255, 0.05); } &:hover { - background-color: rgba(0, 0, 0, 0.03); + background-color: #e0e0e0; .dark & { - background-color: rgba(255, 255, 255, 0.05); + background-color: #3b3a3a; } } &.selected { - background-color: rgba(40, 142, 223, 0.08); - border-left-color: #288edf; + background-color: #fff; + color: #333; .dark & { - background-color: rgba(40, 142, 223, 0.15); - border-left-color: #3da3ff; + background-color: #1D1F21; + color: #dedede; + } + + &::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 0.15rem; + background-color: #0078D7; + + .dark & { + background-color: #75BEFF; + } } &:hover { - background-color: rgba(40, 142, 223, 0.12); + background-color: #fff; .dark & { - background-color: rgba(40, 142, 223, 0.2); + background-color: #1D1F21; } } } @@ -2596,7 +2619,7 @@ code { } .folder-help-text { - margin-top: 8px; + margin-top: 10px; margin-bottom: 0; font-size: 11px; color: @bc-text-quiet; diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index b9b828dd66..0282b79436 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -142,6 +142,8 @@ define(function (require, exports, module) { require("spec/Extn-Git-integ-test"); // Node Tests require("spec/NodeConnection-test"); + // pro test suite optional components + require("./pro-test-suite"); // todo TEST_MODERN // require("spec/LanguageTools-test"); LSP tests. disabled for now // require("spec/Menu-native-integ-test"); evaluate after we have native menus in os installed builds diff --git a/test/spec/LiveDevelopmentCustomServer-test.js b/test/spec/LiveDevelopmentCustomServer-test.js index f54b56b0f5..76f494b244 100644 --- a/test/spec/LiveDevelopmentCustomServer-test.js +++ b/test/spec/LiveDevelopmentCustomServer-test.js @@ -109,11 +109,7 @@ define(function (require, exports, module) { WorkspaceManager = null; }, 30000); - async function _enableLiveHighlights(enable) { - PreferencesManager.setViewState("livedevHighlight", enable); - } async function endPreviewSession() { - await _enableLiveHighlights(true); LiveDevMultiBrowser.close(); await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), "closing all file"); @@ -136,6 +132,17 @@ define(function (require, exports, module) { await waitsForLiveDevelopmentFileSwitch(); } + async function waitForLivePreviewToContainTitle(title) { + await awaitsFor( + function isLiveDevelopmentActive() { + const currentTitle = testWindow.$("#panel-live-preview-title").attr("title"); + return currentTitle.indexOf(title) !== -1; + }, + `Liuve prview page title to be ${title}`, + 20000 + ); + } + it("should live preview settings work as expected", async function () { const testTempDir = await SpecRunnerUtils.getTempTestDirectory( "/spec/LiveDevelopment-MultiBrowser-test-files", true); @@ -1011,11 +1018,12 @@ define(function (require, exports, module) { expect(testWindow.$(".live-preview-status-overlay").is(":visible")).toBeFalse(); // now edit the settings + const serverURL = "http://localhost:8000"; testWindow.$(".live-preview-settings").click(); await SpecRunnerUtils.waitForModalDialog(); if(!testWindow.$("#enableCustomServerChk").is(":checked")){ testWindow.$("#enableCustomServerChk").click(); - testWindow.$("#livePreviewServerURL").val("http://localhost:8000"); + testWindow.$("#livePreviewServerURL").val(serverURL); } SpecRunnerUtils.clickDialogButton(Dialogs.DIALOG_BTN_OK); @@ -1032,7 +1040,7 @@ define(function (require, exports, module) { await SpecRunnerUtils.loadProjectInTestWindow(testPath); await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "open simple1.html"); - await waitsForLiveDevelopmentToOpen(); + await waitForLivePreviewToContainTitle(serverURL); await awaits(100);// give some time to see if the banner comes up expect(testWindow.$(".live-preview-settings").is(":visible")).toBeFalse(); diff --git a/test/spec/LiveDevelopmentMultiBrowser-test.js b/test/spec/LiveDevelopmentMultiBrowser-test.js index ef03e15d32..1a6a0b6948 100644 --- a/test/spec/LiveDevelopmentMultiBrowser-test.js +++ b/test/spec/LiveDevelopmentMultiBrowser-test.js @@ -27,6 +27,7 @@ define(function (require, exports, module) { const SpecRunnerUtils = require("spec/SpecRunnerUtils"), KeyEvent = require("utils/KeyEvent"), StringUtils = require("utils/StringUtils"), + CONSTANTS = require("LiveDevelopment/LivePreviewConstants"), Strings = require("strings"); describe("livepreview:MultiBrowser Live Preview", function () { @@ -113,17 +114,6 @@ define(function (require, exports, module) { await SpecRunnerUtils.loadProjectInTestWindow(testFolder); await SpecRunnerUtils.deletePathAsync(testFolder + "/.phcode.json", true); - // Disable edit mode features for core live preview tests - // This ensures tests focus on basic live preview functionality without - // edit mode interference (hover/click handlers) - if (LiveDevMultiBrowser && LiveDevMultiBrowser.config) { - LiveDevMultiBrowser.config.isProUser = false; - // Also update the remote browser configuration - if (LiveDevMultiBrowser.updateConfig) { - LiveDevMultiBrowser.updateConfig(JSON.stringify(LiveDevMultiBrowser.config)); - } - } - if (!WorkspaceManager.isPanelVisible('live-preview-panel')) { await awaitsForDone(CommandManager.execute(Commands.FILE_LIVE_FILE_PREVIEW)); } @@ -149,11 +139,11 @@ define(function (require, exports, module) { WorkspaceManager = null; }, 30000); - async function _enableLiveHighlights(enable) { - PreferencesManager.setViewState("livedevHighlight", enable); + async function _setLivePreviewMode(mode) { + PreferencesManager.set(CONSTANTS.PREFERENCE_LIVE_PREVIEW_MODE, mode); } async function endPreviewSession() { - await _enableLiveHighlights(true); + await _setLivePreviewMode(CONSTANTS.LIVE_HIGHLIGHT_MODE); LiveDevMultiBrowser.close(); await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), "closing all file"); @@ -172,14 +162,6 @@ define(function (require, exports, module) { } async function waitsForLiveDevelopmentToOpen() { - // Ensure edit mode is disabled before opening live preview - if (LiveDevMultiBrowser && LiveDevMultiBrowser.config) { - LiveDevMultiBrowser.config.isProUser = false; - // Update the remote browser configuration to sync the disabled state - if (LiveDevMultiBrowser.updateConfig) { - LiveDevMultiBrowser.updateConfig(JSON.stringify(LiveDevMultiBrowser.config)); - } - } LiveDevMultiBrowser.open(); await waitsForLiveDevelopmentFileSwitch(); } @@ -923,7 +905,6 @@ define(function (require, exports, module) { }, 30000); it("focus test: should html live previews never take focus from editor", async function () { - // this test may fail if the test window doesn't have focus await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html"); @@ -937,7 +918,7 @@ define(function (require, exports, module) { // delegate focus to editor explicitly in case of html files. expect(testWindow.document.activeElement).toEqual(iFrame); // for html, it can take focus, but clicking on any non- text elemnt will make it loose focus to editor - await forRemoteExec(`document.getElementById("testId2").click()`); + await forRemoteExec(`document.getElementById("testId").click()`); await awaits(500); const activeElement = testWindow.document.activeElement; const editorHolder = testWindow.document.getElementById("editor-holder"); @@ -1005,8 +986,6 @@ define(function (require, exports, module) { "SpecRunnerUtils.openProjectFiles simple1.html"); await waitsForLiveDevelopmentToOpen(); - await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ', - "testId", "Brackets is hello world awesome!"); let iFrame = testWindow.document.getElementById("panel-live-preview-frame"); expect(iFrame.src.endsWith("simple1.html")).toBeTrue(); @@ -1041,8 +1020,6 @@ define(function (require, exports, module) { "SpecRunnerUtils.openProjectFiles simple1.html"); await waitsForLiveDevelopmentToOpen(); - await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ', - "testId", "Brackets is hello world awesome!"); let iFrame = testWindow.document.getElementById("panel-live-preview-frame"); expect(iFrame.src.endsWith("simple1.html")).toBeTrue(); @@ -1552,7 +1529,7 @@ define(function (require, exports, module) { }, 30000); it("should reverse highlight be disabled if live highlight is disabled", async function () { - await _enableLiveHighlights(false); + await _setLivePreviewMode(CONSTANTS.LIVE_PREVIEW_MODE); await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html"); @@ -1569,7 +1546,7 @@ define(function (require, exports, module) { await awaits(500); expect(editor.getCursorPos()).toEql({ line: 0, ch: 0, sticky: null }); - await _enableLiveHighlights(true); + await _setLivePreviewMode(CONSTANTS.LIVE_HIGHLIGHT_MODE); await endPreviewSession(); }, 30000); @@ -1603,24 +1580,24 @@ define(function (require, exports, module) { }, 30000); it("should beautify and undo not corrupt live preview", async function () { - await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), - "SpecRunnerUtils.openProjectFiles simple1.html"); + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple2.html"]), + "SpecRunnerUtils.openProjectFiles simple2.html"); await waitsForLiveDevelopmentToOpen(); - await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ', - "testId", "Brackets is hello world awesome!"); + await _editFileAndVerifyLivePreview("simple2.html", { line: 11, ch: 45 }, 'hello world ', + "simpId", "Brackets is hello world awesome!"); let editor = EditorManager.getActiveEditor(); await BeautificationManager.beautifyEditor(editor); - await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 73 }, 'yo', - "testId", "Brackets is hello world awesome!yo"); + await _editFileAndVerifyLivePreview("simple2.html", { line: 11, ch: 73 }, 'yo', + "simpId", "Brackets is hello world awesome!yo"); await awaitsForDone(CommandManager.execute(Commands.EDIT_UNDO), "undo"); await awaitsForDone(CommandManager.execute(Commands.EDIT_UNDO), "undo"); await awaitsForDone(CommandManager.execute(Commands.EDIT_UNDO), "undo"); - await _editFileAndVerifyLivePreview("simple1.html", { line: 11, ch: 45 }, 'hello world ', - "testId", "Brackets is hello world awesome!"); + await _editFileAndVerifyLivePreview("simple2.html", { line: 11, ch: 45 }, 'hello world ', + "simpId", "Brackets is hello world awesome!"); await endPreviewSession(); }, 30000); @@ -1944,246 +1921,5 @@ define(function (require, exports, module) { testWindow.$("#pinURLButton").click(); await endPreviewSession(); }, 30000); - - describe("Edit Mode Tests", function () { - - async function waitsForLiveDevelopmentToOpenWithEditMode(elemHighlights = 'hover') { - // Enable edit mode before opening live preview - if (LiveDevMultiBrowser && LiveDevMultiBrowser.config) { - LiveDevMultiBrowser.config.isProUser = true; - LiveDevMultiBrowser.config.elemHighlights = elemHighlights; - // Update the remote browser configuration - if (LiveDevMultiBrowser.updateConfig) { - LiveDevMultiBrowser.updateConfig(JSON.stringify(LiveDevMultiBrowser.config)); - } - } - LiveDevMultiBrowser.open(); - await waitsForLiveDevelopmentFileSwitch(); - } - - async function endEditModePreviewSession() { - await _enableLiveHighlights(true); - LiveDevMultiBrowser.close(); - // Disable edit mode after session - if (LiveDevMultiBrowser && LiveDevMultiBrowser.config) { - LiveDevMultiBrowser.config.isProUser = false; - LiveDevMultiBrowser.config.elemHighlights = 'hover'; - } - await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), - "closing all file"); - } - - async function waitForInfoBox(shouldBeVisible = true, timeout = 5000) { - await forRemoteExec(` - const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot); - let hasInfoBox = false; - shadowHosts.forEach(host => { - if (host.shadowRoot && host.shadowRoot.innerHTML.includes('phoenix-node-info-box')) { - hasInfoBox = true; - } - }); - hasInfoBox; - `, (result) => { - return result === shouldBeVisible; - }); - } - - async function waitForMoreOptionsBox(shouldBeVisible = true, timeout = 5000) { - await forRemoteExec(` - const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot); - let hasMoreOptionsBox = false; - shadowHosts.forEach(host => { - if (host.shadowRoot && host.shadowRoot.innerHTML.includes('phoenix-more-options-box')) { - hasMoreOptionsBox = true; - } - }); - hasMoreOptionsBox; - `, (result) => { - return result === shouldBeVisible; - }); - } - - async function waitForClickedElement(shouldBeVisible = true, timeout = 5000) { - await forRemoteExec(` - const highlightedElements = document.getElementsByClassName("__brackets-ld-highlight"); - Array.from(highlightedElements).some(el => - el.style.backgroundColor && el.style.backgroundColor.includes('rgba(0, 162, 255') - ); - `, (result) => { - return result === shouldBeVisible; - }); - } - - async function waitForNoEditBoxes() { - // Wait for no shadow DOM boxes and no clicked element highlighting - await forRemoteExec(` - const shadowHosts = Array.from(document.body.children).filter(el => el.shadowRoot); - shadowHosts.length; - `, (result) => { - return result === 0; - }); - - await waitForClickedElement(false); - } - - it("should show info box on hover when elemHighlights is 'hover'", async function () { - await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), - "SpecRunnerUtils.openProjectFiles simple1.html"); - - await waitsForLiveDevelopmentToOpenWithEditMode('hover'); - - // Initially no boxes should be visible - await waitForNoEditBoxes(); - - // Hover over testId element - await forRemoteExec(` - const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true }); - document.getElementById('testId').dispatchEvent(event); - `); - - // Info box should appear on hover - await waitForInfoBox(true); - await waitForMoreOptionsBox(false); - - // Mouse out should hide the info box - await forRemoteExec(` - const event = new MouseEvent('mouseout', { bubbles: true, cancelable: true }); - document.getElementById('testId').dispatchEvent(event); - `); - - await waitForInfoBox(false); - await waitForNoEditBoxes(); - - await endEditModePreviewSession(); - }, 30000); - - it("should show more options box on click when elemHighlights is 'hover'", async function () { - await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), - "SpecRunnerUtils.openProjectFiles simple1.html"); - - await waitsForLiveDevelopmentToOpenWithEditMode('hover'); - - // Click on testId element - await forRemoteExec(`document.getElementById('testId').click()`); - - // More options box should appear on click - await waitForMoreOptionsBox(true); - await waitForClickedElement(true); - - // Clicking on a different element should move the box - await forRemoteExec(`document.getElementById('testId2').click()`); - - await waitForMoreOptionsBox(true); - await waitForClickedElement(true); - - await endEditModePreviewSession(); - }, 30000); - - it("should show more options box on click when elemHighlights is 'click'", async function () { - await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), - "SpecRunnerUtils.openProjectFiles simple1.html"); - - await waitsForLiveDevelopmentToOpenWithEditMode('click'); - - // Initially no boxes should be visible - await waitForNoEditBoxes(); - - // In click mode, hover should not show info box - await forRemoteExec(` - const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true }); - document.getElementById('testId').dispatchEvent(event); - `); - - // Should still be no boxes visible - await waitForInfoBox(false); - await waitForMoreOptionsBox(false); - - // Click should show more options box - await forRemoteExec(`document.getElementById('testId').click()`); - - await waitForMoreOptionsBox(true); - await waitForClickedElement(true); - - await endEditModePreviewSession(); - }, 30000); - - it("should handle multiple element interactions in hover mode", async function () { - await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple2.html"]), - "SpecRunnerUtils.openProjectFiles simple2.html"); - - await waitsForLiveDevelopmentToOpenWithEditMode('hover'); - - // Test hovering over multiple elements - const elementIds = ['simpId', 'simpId2', 'simpId3']; - - for (let elementId of elementIds) { - // Hover over element - await forRemoteExec(` - const event = new MouseEvent('mouseover', { bubbles: true, cancelable: true }); - document.getElementById('${elementId}').dispatchEvent(event); - `); - - // Info box should appear - await waitForInfoBox(true); - - // Mouse out - await forRemoteExec(` - const event = new MouseEvent('mouseout', { bubbles: true, cancelable: true }); - document.getElementById('${elementId}').dispatchEvent(event); - `); - - // Box should disappear - await waitForInfoBox(false); - } - - await endEditModePreviewSession(); - }, 30000); - - it("should handle multiple element clicks and box movement", async function () { - await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple2.html"]), - "SpecRunnerUtils.openProjectFiles simple2.html"); - - await waitsForLiveDevelopmentToOpenWithEditMode('hover'); - - const elementIds = ['simpId', 'simpId2', 'simpId3']; - - // Click on first element - await forRemoteExec(`document.getElementById('${elementIds[0]}').click()`); - - await waitForMoreOptionsBox(true); - await waitForClickedElement(true); - - // Click on subsequent elements - box should move - for (let i = 1; i < elementIds.length; i++) { - await forRemoteExec(`document.getElementById('${elementIds[i]}').click()`); - - await waitForMoreOptionsBox(true); - await waitForClickedElement(true); - } - - await endEditModePreviewSession(); - }, 30000); - - it("should dismiss boxes when clicking outside elements", async function () { - await awaitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), - "SpecRunnerUtils.openProjectFiles simple1.html"); - - await waitsForLiveDevelopmentToOpenWithEditMode('hover'); - - // Click on element to show more options box - await forRemoteExec(`document.getElementById('testId').click()`); - - await waitForMoreOptionsBox(true); - - // Click on body (outside any specific element) - await forRemoteExec(`document.body.click()`); - - // Boxes should be dismissed - await waitForMoreOptionsBox(false); - await waitForClickedElement(false); - - await endEditModePreviewSession(); - }, 30000); - }); }); }); diff --git a/tracking-repos.json b/tracking-repos.json new file mode 100644 index 0000000000..bbcc4bb864 --- /dev/null +++ b/tracking-repos.json @@ -0,0 +1,5 @@ +{ + "phoenixPro": { + "commitID": "d009de2b52cadd8fcb0256262635ccb453f6a631" + } +}