From 0092833232c2320b2d03906ce61f29cb74d42291 Mon Sep 17 00:00:00 2001 From: jpedroso <3560+jpedroso@users.noreply.github.com> Date: Wed, 13 Aug 2025 10:26:28 +0000 Subject: [PATCH] Update with develop --- .babelrc | 12 + .eslintrc | 6 +- .nvmrc | 2 +- .vscode/tasks.json | 106 ++++++- README.md | 58 +++- build.sh => Scripts/build.sh | 0 Scripts/build_sketch_api.sh | 41 +++ Scripts/test.sh | 53 ++++ SketchAPI.xcodeproj/project.pbxproj | 2 +- Source/dom/__tests__/export.test.js | 254 +++++++++++++++- Source/dom/__tests__/find.test.js | 191 +++++++++++- Source/dom/enums.js | 1 + Source/dom/export.js | 37 ++- Source/dom/find.js | 46 ++- Source/dom/index.js | 2 + Source/dom/layers/Artboard.js | 115 +------- Source/dom/layers/Group.js | 166 +++++++++++ Source/dom/layers/Layer.js | 67 ++++- Source/dom/layers/Page.js | 13 + Source/dom/layers/ShapePath.js | 12 + Source/dom/layers/StyledLayer.js | 33 +++ Source/dom/layers/SymbolInstance.js | 23 ++ Source/dom/layers/SymbolMaster.js | 27 +- Source/dom/layers/__tests__/Artboard.test.js | 57 +++- Source/dom/layers/__tests__/Group.test.js | 116 +++++++- Source/dom/layers/__tests__/Layer.test.js | 108 ++++++- Source/dom/layers/__tests__/Page.test.js | 37 +++ Source/dom/layers/__tests__/ShapePath.test.js | 14 + .../dom/layers/__tests__/StackLayout.test.js | 203 +++++++++++++ .../layers/__tests__/SymbolInstance.test.js | 11 +- .../dom/layers/__tests__/SymbolMaster.test.js | 37 ++- Source/dom/models/BlendingMode.js | 41 +++ Source/dom/models/Flow.js | 4 +- Source/dom/models/ImageData.js | 9 + Source/dom/models/Override.js | 61 +++- Source/dom/models/StackLayout.js | 278 ++++++++++++++++++ .../dom/models/__tests__/CurvePoint.test.js | 43 +-- Source/dom/models/__tests__/Flow.test.js | 1 + Source/dom/models/__tests__/ImageData.test.js | 23 ++ .../models/__tests__/ImportableObject.test.js | 2 +- Source/dom/models/__tests__/Library.test.js | 2 +- Source/dom/models/__tests__/Override.test.js | 191 +++++++++++- Source/dom/style/Blur.js | 71 ++++- Source/dom/style/Border.js | 31 +- Source/dom/style/Corners.js | 133 +++++++++ Source/dom/style/Fill.js | 31 +- Source/dom/style/GradientStop.js | 9 + Source/dom/style/Shadow.js | 26 ++ Source/dom/style/Style.js | 142 ++++++--- Source/dom/style/__tests__/Blur.test.js | 261 ++++++++++++++++ Source/dom/style/__tests__/Border.test.js | 61 ++++ Source/dom/style/__tests__/Corners.test.js | 204 +++++++++++++ Source/dom/style/__tests__/Fill.test.js | 112 +++++++ .../dom/style/__tests__/GradientStop.test.js | 25 ++ Source/dom/style/__tests__/Shadow.test.js | 37 +++ Source/dom/style/__tests__/Style.test.js | 27 ++ Source/test-utils.js | 5 +- package.json | 6 +- run_tests.py | 4 +- test.sh | 43 --- webpack.tests.config.js | 4 +- 61 files changed, 3353 insertions(+), 384 deletions(-) create mode 100644 .babelrc rename build.sh => Scripts/build.sh (100%) create mode 100755 Scripts/build_sketch_api.sh create mode 100755 Scripts/test.sh create mode 100644 Source/dom/layers/__tests__/StackLayout.test.js create mode 100644 Source/dom/models/BlendingMode.js create mode 100644 Source/dom/models/StackLayout.js create mode 100644 Source/dom/style/Corners.js create mode 100644 Source/dom/style/__tests__/Blur.test.js create mode 100644 Source/dom/style/__tests__/Corners.test.js delete mode 100755 test.sh diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..be4c131a5 --- /dev/null +++ b/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@babel/env", + { + "targets": { + "safari": 17 + } + } + ] + ] +} diff --git a/.eslintrc b/.eslintrc index c8ccfa13d..718831d47 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,7 +7,7 @@ ], "plugins": ["import"], "parserOptions": { - "ecmaVersion": 9, + "ecmaVersion": 2020, "sourceType": "module" }, "env": { @@ -43,6 +43,7 @@ "NSImage": "readonly", "NSTextField": "readonly", "NSSlider": "readonly", + "MSStyleBlur": "readonly", "MSStyleFill": "readonly", "MSStyleBorder": "readonly", "MSColor": "readonly", @@ -71,6 +72,7 @@ "NSString": "readonly", "MSForeignSymbolProvider": "readonly", "MSForeignObjectCollector": "readonly", + "MSStyleCorners": "readonly", "MSStyleShadow": "readonly", "MSGradientStop": "readonly", "MSImmutableGradientStop": "readonly", @@ -176,6 +178,8 @@ "NSMutableDictionary": "readonly", "NSDictionary": "readonly", "SmartLayout": "readonly", + "StackLayout": "readonly", + "MSFlexGroupLayout": "readonly", "MSInferredGroupLayout": "readonly", "MSFreeformGroupLayout": "readonly", "MSSwatch": "readonly", diff --git a/.nvmrc b/.nvmrc index 1a2f5bd20..fc37597bc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/* \ No newline at end of file +22.17.0 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b5457c964..7556f9530 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,59 +1,129 @@ { "version": "2.0.0", "tasks": [ + // + // Build & Lint + // { - "label": "Build & lint", + "label": "Build & Lint", "group": { "kind": "build", "isDefault": true }, "dependsOrder": "sequence", - "dependsOn": ["Lint", "Build"] + "dependsOn": ["Build", "Lint"] }, { "label": "Build", "type": "npm", + "hide": true, "script": "build", - "group": { - "kind": "build" + "presentation": { + "clear": true } }, { "label": "Lint", "type": "npm", - "script": "lint", - "group": { - "kind": "build" - } + "hide": true, + "script": "lint" }, + // + // Test + // { - "label": "Build and run tests", + "label": "Test", "dependsOrder": "sequence", - "dependsOn": ["Build", "Please choose target Xcode variant", "Run tests"], + "dependsOn": ["Build & Lint", "Test Default"], "group": { "kind": "test", "isDefault": true } }, { - "label": "Please choose target Xcode variant", + "label": "Test Default", + "type": "npm", + "hide": true, + "script": "test" + }, + // + // Test (single test suite) + // + { + "label": "Test [Single Suite]", + "dependsOrder": "sequence", + "dependsOn": [ + "[Show the test suite name prompt]", + "Build & Lint", + "Test Single Suite" + ], + "group": { + "kind": "test" + } + }, + { + "label": "[Show the test suite name prompt]", "type": "shell", - "command": "echo -e '\\033[0;34m=====================================================================\nSelect the Sketch variant to test against in the VSCode popup above ☝️\n(or just press Enter)\n=====================================================================\\033[0m'", + "hide": true, + "command": "echo", + "options": { + "env": { + "TEST_SUITE": "${input:testSuite}" + } + } + }, + { + "label": "Test Single Suite", + "type": "npm", + "hide": true, + "script": "test:suite", + "options": { + "env": { + "TEST_SUITE": "${input:testSuite}" + } + } + }, + // + // Test (custom Sketch target) + // + { + "label": "Test [Custom Sketch]", + "dependsOrder": "sequence", + "dependsOn": [ + "[Show target Sketch variant prompt]", + "Build & Lint", + "Test Sketch Variant" + ], "group": { "kind": "test" } }, { - "label": "Run tests", + "label": "[Show target Sketch variant prompt]", + "type": "shell", + "hide": true, + "command": "echo", + "options": { + "env": { + "SKETCH_VARIANT_BUNDLE_ID": "${input:sketchTarget}" + } + } + }, + { + "label": "Test Sketch Variant", "type": "npm", - "script": "test", + "script": "test:custom", + "hide": true, "options": { "env": { - "TARGET_SKETCH_VARIANT_BUNDLE_ID": "${input:sketchTarget}" + "SKETCH_VARIANT_BUNDLE_ID": "${input:sketchTarget}" } } } ], + // + // [INPUTS] + // "inputs": [ { "id": "sketchTarget", @@ -65,6 +135,12 @@ "com.bohemiancoding.sketch3" ], "default": "com.bohemiancoding.sketch3.xcode" + }, + { + "id": "testSuite", + "description": "Enter the test suite name:", + "type": "promptString", + "default": "" } ] } diff --git a/README.md b/README.md index aae79d57c..e4bbf581f 100755 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ sel.forEach(function (elem) { ### Prerequisits -- Have [Node](https://nodejs.org) installed +- Have [Node](https://nodejs.org) 22+ installed - [Visual Studio Code](https://code.visualstudio.com) (recommended) ### Overview @@ -62,6 +62,7 @@ The Sketch API is written in JavaScript/CocoaScript and gets bundled as part of In addition to the API, this project also defines core modules to be included in Sketch as part of the official release process. Both are written to the `build` output folder when the build script is run: ```sh +npm install ./build.sh ``` @@ -69,14 +70,25 @@ In addition to the API, this project also defines core modules to be included in The following npm scripts are available for development of the API. -| Script | Description | -| ----------------------------- | --------------------------------------- | -| `npm run build` | Build SketchAPI into the `build` folder | -| `npm run test:build` | Build integration test plugin | -| `npm run lint` | Lint the source code | -| `npm run format-check` | Check the format with Prettier | -| `npm run api-location:write` | Tell Sketch to use your local SketchAPI | -| `npm run api-location:delete` | Undo `npm run api-location:write` | +| Script | Description | +| ----------------------------- | ------------------------------------------- | +| `npm run build` | Build SketchAPI into the `build` folder | +| `npm run test [suite]` | Run integrations tests (or a single suite). | +| `npm run lint` | Lint the source code | +| `npm run format-check` | Check the format with Prettier | +| `npm run api-location:write` | Tell Sketch to use your local SketchAPI | +| `npm run api-location:delete` | Undo `npm run api-location:write` | + +In case you're using Visual Studio Code to work on SketchAPI, this project's `tasks.json` define the following shortcuts, available via _Command Palette > Run Task_: + +| Task | Description | +| --- | --- | +| `Build & Lint` | Build SketchAPI and lint the source code | +| `Test` | Build SketchAPI and run integration tests | +| `Test [Single Suite]` | Build SketchAPI and run a select test suite | +| `Test [Custom Sketch]` | Build SketchAPI and run tests against a selected Sketch variant (e.g. Sketch Beta) | + +The last two tasks will prompt you for a test suite name, or a Sketch bundleID respectively. ### Build and run @@ -111,6 +123,32 @@ The SketchAPI builds on top of macOS and internal Sketch APIs via CocoaScript. T These integration tests are compiled into a single test plugin using Webpack and can run by the `run_tests.py` Python script, or from the Sketch application menu. +> [!TIP] +> Install the `psutil` Python module, so that the test runner can terminate Sketch automatically upon test completion: +> +> ``` +> pip install psutil +> ``` +> +> Otherwise every new test run will leave a new instance of Sketch app hanging around. + +All the necessary plumbing is done automatically when you run: + +```sh +./test.sh +``` + +which optionally takes a name of the test suite to be run exclusively: + +```sh +./test.sh SymbolInstance +``` + +This will build SketchAPI and tests, prepare the host plugin, set up your environment for testing, launch Sketch, and run the test suite in it, collecting and reporting the results back to you. + +
+(Alternative) Manual steps to build and run tests + **Build test plugin** To build the plugin separately, e.g. to install and run it manually, run the following command. @@ -150,6 +188,8 @@ Tests can also be run manually from within Sketch: The test results are written to the specified output file or, if no dedicated path is provided, to a temporary file. Use macOS' _Console.app_ to view Sketch's logs containing information on the file location and test progress in general. +
+ ## Acknowledgements We would like to thank: diff --git a/build.sh b/Scripts/build.sh similarity index 100% rename from build.sh rename to Scripts/build.sh diff --git a/Scripts/build_sketch_api.sh b/Scripts/build_sketch_api.sh new file mode 100755 index 000000000..a9b991403 --- /dev/null +++ b/Scripts/build_sketch_api.sh @@ -0,0 +1,41 @@ +EXIT_VALUE=0 +LEVEL="warning" +if [[ "$BC_EXTERNAL_BUILD" == "YES" ]]; then + EXIT_VALUE=1 + LEVEL="error" +fi + +# First step is to to install node. +# We need to get a specific version of node installed and to do that we use nvm +# nvm is installed by Chef via brew. The default location is $HOME/.nvm +# Before we can run nvm, we need to source this profile in order to get its +# paths and envvars set up. + +# Set NVM_DIR to the standard installation location within the user's home directory. +export NVM_DIR="$HOME/.nvm" + +# Source NVM script to make 'nvm' commands available. +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +# Install the Node.js version specified by NVM (e.g., via .nvmrc or default) +nvm install +echo "note: Using node `node -v` from `which node`" + +echo "note: Running npm install." +npm ci +RETVAL=$? +if [[ $RETVAL -ne 0 ]]; then + echo "${FULL_PRODUCT_NAME}:${LINENO}:1: ${LEVEL}: npm ci failed. Aborting SketchAPI build." + exit $EXIT_VALUE +fi + +echo "note: Executing npm run build." +npm run build +RETVAL=$? +if [[ $RETVAL -ne 0 ]]; then + echo "${FULL_PRODUCT_NAME}:${LINENO}:1: ${LEVEL}: npm run build failed. Aborting SketchAPI build." + exit $EXIT_VALUE +fi + +echo "project dir: $PROJECT_DIR" +echo "source dir: $SOURCE_ROOT" diff --git a/Scripts/test.sh b/Scripts/test.sh new file mode 100755 index 000000000..e2da23cf9 --- /dev/null +++ b/Scripts/test.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +TEST_RUN_ID=$(uuidgen) + +# This script accepts one optional argument: +# - a name of a single test suite/spec, to only run this test suite and skip the rest +# e.g. "test.sh SymbolInstance". This option is also available as TEST_SUITE env variable +# +# There's also an optional SKETCH_VARIANT_BUNDLE_ID env variable indicating which Xcode +# variant to run tests against: +# - com.bohemiancoding.sketch3 (default if unset) +# - com.bohemiancoding.sketch3.beta +# - com.bohemiancoding.sketch3.xcode +if [[ -n "$1" ]]; then + TEST_SUITE_WEBPACK_ARG="--env spec=$1" +fi +if [ -n "${TEST_SUITE}" ]; then + TEST_SUITE_WEBPACK_ARG="--env spec=$TEST_SUITE" +fi + +if [ -z "${SKETCH_VARIANT_BUNDLE_ID}" ]; then + SKETCH_VARIANT_BUNDLE_ID="com.bohemiancoding.sketch3" +fi + +# Gracefully tear down the test env on error +tear_down() { + defaults delete "${SKETCH_VARIANT_BUNDLE_ID}" SketchAPILocation +} +trap 'tear_down' ERR + +# Set up test environment +defaults write "${SKETCH_VARIANT_BUNDLE_ID}" SketchAPILocation -string "$(pwd)/build" + +# Build tests +# shellcheck disable=SC2086 +npx webpack --config webpack.tests.config.js --env identifier="$TEST_RUN_ID" ${TEST_SUITE_WEBPACK_ARG} + +# Run tests +# shellcheck disable=SC2086 +SKETCH_TARGET_PATH="$(mdfind kMDItemCFBundleIdentifier == ${SKETCH_VARIANT_BUNDLE_ID} | sort --version-sort --reverse | head -n 1)" +echo "• Running tests in '${SKETCH_TARGET_PATH}'" + +python3 run_tests.py \ + -p "./build/SketchIntegrationTests-${TEST_RUN_ID}.sketchplugin" \ + -o "./build/${TEST_RUN_ID}_test_results.txt" \ + -s "${SKETCH_TARGET_PATH}" + +# Tear down test environment +tear_down + +# Clean up test artifacts +rm -rf "./build/SketchIntegrationTests-${TEST_RUN_ID}.sketchplugin" +rm -rf "./build/${TEST_RUN_ID}_test_results.txt" diff --git a/SketchAPI.xcodeproj/project.pbxproj b/SketchAPI.xcodeproj/project.pbxproj index 0bce8cc1c..76a08b3b0 100644 --- a/SketchAPI.xcodeproj/project.pbxproj +++ b/SketchAPI.xcodeproj/project.pbxproj @@ -165,7 +165,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/zsh; - shellScript = "EXIT_VALUE=0\nLEVEL=\"warning\"\nif [[ \"$BC_EXTERNAL_BUILD\" == \"YES\" ]]; then\n EXIT_VALUE=1\n LEVEL=\"error\"\nfi\n\n# First step is to to install node.\n# We need to get a specific version of node installed and to do that we use nvm\n# nvm is installed by bo, and it lives in /usr/local/share/bo/vault-nu/nodevm\n# nvm by default modifies a user's .profile but we customise this to write its\n# settings to /usr/local/share/bo/vault-nu/nodevm/profile\n# Before we can run nvm, we need to source this profile in order to get its\n# paths and envvars set up.\n. /usr/local/share/bo/vault-nu/nodevm/profile\nnvm install\necho \"Using node `node -v` from `which node`\"\n\necho \"Running npm install.\"\nnpm ci\nRETVAL=$?\nif [[ $RETVAL -ne 0 ]]; then\n echo \"npm ci failed. Aborting SketchAPI build.\"\n echo \"${FULL_PRODUCT_NAME}:22:1: ${LEVEL}: npm ci failed\"\n exit $EXIT_VALUE\nfi\n\necho \"Executing npm run build.\"\nnpm run build\nRETVAL=$?\nif [[ $RETVAL -ne 0 ]]; then\n echo \"npm run build failed. Aborting SketchAPI build.\"\n echo \"${FULL_PRODUCT_NAME}:31:1: ${LEVEL}: npm run build failed\"\n exit $EXIT_VALUE\nfi\n\necho \"project dir: $PROJECT_DIR\"\necho \"source dir: $SOURCE_ROOT\"\n"; + shellScript = "\"${PROJECT_DIR}/Scripts/build_sketch_api.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ diff --git a/Source/dom/__tests__/export.test.js b/Source/dom/__tests__/export.test.js index f06964d80..2a07e403d 100644 --- a/Source/dom/__tests__/export.test.js +++ b/Source/dom/__tests__/export.test.js @@ -3,7 +3,7 @@ import { Buffer } from 'buffer' import fs from '@skpm/fs' import sketch from '../..' -const { Shape } = sketch +const { Shape, ShapePath, Style, Image } = sketch import { outputPath } from '../../test-utils' @@ -154,7 +154,9 @@ test('Should fail when exporting a shape too large for WebP', (_context, documen }) expect(false).toBe(true) } catch (err) { - expect(err.message).toMatch('Failed to export WebP file. Exported image size for \'Shape\' exceeds maximum pixel dimensions supported by the WebP format (16383 x 16383): 100 x 16800.') + expect(err.message).toMatch( + "Failed to export WebP file. Exported image size for 'Shape' exceeds maximum pixel dimensions supported by the WebP format (16383 x 16383): 100 x 16800." + ) } }) @@ -175,3 +177,251 @@ test('Should export a shape to json file', (_context, document) => { }) expect(fs.existsSync(filePath)).toBe(true) }) + +test('should export to file according to existing exportFormats', (_context, document) => { + const testOutputPath = outputPath() + const filePath = `${testOutputPath}/prefix-Shape.jpg` + try { + fs.unlinkSync(filePath) + } catch (err) { + // just ignore + } + const object = new ShapePath({ + parent: document.selectedPage, + name: 'Shape', + style: { + fills: [ + { + color: '#c0ffee', + fillType: Style.FillType.Color, + }, + ], + }, + exportFormats: [ + { + size: '1x', + prefix: 'prefix-', + fileFormat: 'jpg', + }, + ], + }) + + const result = sketch.export(object, { + output: testOutputPath, + exportFormats: object.exportFormats, + }) + expect(result).toBe(true) + expect(fs.existsSync(filePath)).toBe(true) + + try { + fs.unlinkSync(filePath) + } finally { + // ignore + } +}) + +test('should export to file according to custom exportFormats', (_context, document) => { + const testOutputPath = outputPath() + const filePath = `${testOutputPath}/Shape-suffix.png` + try { + fs.unlinkSync(filePath) + } catch (err) { + // just ignore + } + const object = new ShapePath({ + parent: document.selectedPage, + name: 'Shape', + style: { + fills: [ + { + color: '#c0ffee', + fillType: Style.FillType.Color, + }, + ], + }, + }) + + const result = sketch.export(object, { + output: testOutputPath, + exportFormats: [ + { + size: '1x', + suffix: '-suffix', + fileFormat: 'png', + }, + ], + }) + expect(result).toBe(true) + expect(fs.existsSync(filePath)).toBe(true) + + try { + fs.unlinkSync(filePath) + } finally { + // ignore + } +}) + +test('should export to multiple files according to exportFormats', (_context, document) => { + const testOutputPath = outputPath() + const filePath1 = `${testOutputPath}/prefix-Shape.jpg` + const filePath2 = `${testOutputPath}/Shape-suffix.png` + try { + fs.unlinkSync(filePath1) + fs.unlinkSync(filePath2) + } catch (err) { + // just ignore + } + const object = new ShapePath({ + parent: document.selectedPage, + name: 'Shape', + style: { + fills: [ + { + color: '#c0ffee', + fillType: Style.FillType.Color, + }, + ], + }, + }) + + const result = sketch.export(object, { + output: testOutputPath, + exportFormats: [ + { + size: '1x', + prefix: 'prefix-', + fileFormat: 'jpg', + }, + { + size: '15h', + suffix: '-suffix', + fileFormat: 'png', + }, + ], + }) + expect(result).toBe(true) + expect(fs.existsSync(filePath1)).toBe(true) + expect(fs.existsSync(filePath2)).toBe(true) + + try { + fs.unlinkSync(filePath1) + fs.unlinkSync(filePath2) + } finally { + // ignore + } +}) + +test('should export to file with default options when exportFormats are empty', (_context, document) => { + const testOutputPath = outputPath() + const filePath = `${testOutputPath}/Shape.png` + try { + fs.unlinkSync(filePath) + } catch (err) { + // just ignore + } + const object = new ShapePath({ + parent: document.selectedPage, + name: 'Shape', + style: { + fills: [ + { + color: '#c0ffee', + fillType: Style.FillType.Color, + }, + ], + }, + }) + + const result = sketch.export(object, { + output: testOutputPath, + exportFormats: [], + }) + expect(result).toBe(true) + expect(fs.existsSync(filePath)).toBe(true) + + try { + fs.unlinkSync(filePath) + } finally { + // ignore + } +}) + +test('should export to buffer according to existing exportFormats', (_context, document) => { + const object = new ShapePath({ + parent: document.selectedPage, + name: 'Shape', + style: { + fills: [ + { + color: '#c0ffee', + fillType: Style.FillType.Color, + }, + ], + }, + exportFormats: [ + { + size: '2x', + fileFormat: 'png', + }, + ], + }) + + const buffer = sketch.export(object, { + output: false, + exportFormats: object.exportFormats, + }) + + expect(Buffer.isBuffer(buffer)).toBe(true) + expect(new Image({ image: buffer }).image.size.width).toBe(200) +}) + +test('should export to buffer according to new exportFormats', (_context, document) => { + const object = new ShapePath({ + parent: document.selectedPage, + name: 'Shape', + style: { + fills: [ + { + color: '#c0ffee', + fillType: Style.FillType.Color, + }, + ], + }, + }) + + const buffer = sketch.export(object, { + output: false, + exportFormats: [ + { + size: '2x', + fileFormat: 'png', + }, + ], + }) + + expect(Buffer.isBuffer(buffer)).toBe(true) + expect(new Image({ image: buffer }).image.size.width).toBe(200) +}) + +test('should export to buffer with default options when exportFormats are empty', (_context, document) => { + const object = new ShapePath({ + parent: document.selectedPage, + name: 'Shape', + style: { + fills: [ + { + color: '#c0ffee', + fillType: Style.FillType.Color, + }, + ], + }, + }) + + const buffer = sketch.export(object, { + output: false, + exportFormats: [], + }) + + expect(Buffer.isBuffer(buffer)).toBe(true) + expect(new Image({ image: buffer }).image.size.width).toBe(100) +}) diff --git a/Source/dom/__tests__/find.test.js b/Source/dom/__tests__/find.test.js index cfd7e1393..ee980c3ac 100644 --- a/Source/dom/__tests__/find.test.js +++ b/Source/dom/__tests__/find.test.js @@ -1,5 +1,5 @@ /* globals expect, test */ -import { find, Rectangle } from '..' +import { find, Rectangle, Group } from '..' test('should find Artboard by type', (_context, document) => { // eslint-disable-next-line no-param-reassign @@ -7,9 +7,9 @@ test('should find Artboard by type', (_context, document) => { { layers: [ { type: 'Artboard' }, - { type: 'Shape' }, - { type: 'Group', layers: { type: 'Text' } } - ] + { type: 'Shape' }, + { type: 'Group', layers: { type: 'Text' } }, + ], }, ] // expect to find only one artboard @@ -22,12 +22,13 @@ test('should find Group by type', (_context, document) => { document.pages = [ { layers: [ - { type: 'Artboard', layers: [ - { type: 'Group', layers: { type: 'Text' } } - ]}, - { type: 'Shape' }, - { type: 'Group', layers: { type: 'Text' } } - ] + { + type: 'Artboard', + layers: [{ type: 'Group', layers: { type: 'Text' } }], + }, + { type: 'Shape' }, + { type: 'Group', layers: { type: 'Text' } }, + ], }, ] // expect to find only multiple groups including nested @@ -250,3 +251,173 @@ test('should find within root progeny + root itself', (_context, document) => { find("[name='test']", root, { inclusive: true }).map((x) => x.id) ).toEqual([root.id, root.layers[0].id]) }) + +test('should find all canvas frames as Artboards', (_context, document) => { + document.pages = [ + { + name: 'PageWithFrames', + layers: [ + new Group.Frame({ + name: 'TopLevelFrame', + layers: [ + new Group.Graphic({ + name: 'TopLevelFrame->NestedGraphic', + }), + new Group({ + name: 'TopLevelFrame->NestedGroup', + }), + ], + }), + new Group.Graphic({ + name: 'TopLevelGraphic', + layers: [ + new Group.Frame({ + name: 'TopLevelGraphic->NestedFrame', + }), + ], + }), + new Group({ + name: 'TopLevelGroup', + layers: [ + new Group.Frame({ + name: 'TopLevelGroup->NestedFrame', + }), + ], + }), + ], + }, + ] + expect(find('Artboard', document).map((x) => x.name)).toEqual([ + 'TopLevelFrame', + 'TopLevelGraphic', + ]) +}) + +test('should find all Frames', (_context, document) => { + document.pages = [ + { + name: 'PageWithFrames', + layers: [ + new Group.Frame({ + name: 'TopLevelFrame', + layers: [ + new Group.Graphic({ + name: 'TopLevelFrame->NestedGraphic', + }), + new Group({ + name: 'TopLevelFrame->NestedGroup', + }), + ], + }), + new Group.Graphic({ + name: 'TopLevelGraphic', + layers: [ + new Group.Frame({ + name: 'TopLevelGraphic->NestedFrame', + }), + ], + }), + new Group({ + name: 'TopLevelGroup', + layers: [ + new Group.Frame({ + name: 'TopLevelGroup->NestedFrame', + }), + ], + }), + ], + }, + ] + expect(find('Frame', document).map((x) => x.name)).toEqual([ + 'TopLevelFrame', + 'TopLevelFrame->NestedGraphic', + 'TopLevelGraphic', + 'TopLevelGraphic->NestedFrame', + 'TopLevelGroup->NestedFrame', + ]) +}) + +test('should find all Graphics', (_context, document) => { + document.pages = [ + { + name: 'PageWithFrames', + layers: [ + new Group.Frame({ + name: 'TopLevelFrame', + layers: [ + new Group.Graphic({ + name: 'TopLevelFrame->NestedGraphic', + }), + new Group({ + name: 'TopLevelFrame->NestedGroup', + }), + ], + }), + new Group.Graphic({ + name: 'TopLevelGraphic', + layers: [ + new Group.Frame({ + name: 'TopLevelGraphic->NestedFrame', + }), + ], + }), + new Group({ + name: 'TopLevelGroup', + layers: [ + new Group.Frame({ + name: 'TopLevelGroup->NestedFrame', + }), + ], + }), + ], + }, + ] + expect(find('Graphic', document).map((x) => x.name)).toEqual([ + 'TopLevelFrame->NestedGraphic', + 'TopLevelGraphic', + ]) +}) + +test('should find all (top-level & nested) regular Groups together with nested Frames and Graphics', (_context, document) => { + document.pages = [ + { + name: 'PageWithFrames', + layers: [ + new Group.Frame({ + name: 'TopLevelFrame', + layers: [ + new Group.Graphic({ + name: 'TopLevelFrame->NestedGraphic', + }), + new Group({ + name: 'TopLevelFrame->NestedGroup', + }), + ], + }), + new Group.Graphic({ + name: 'TopLevelGraphic', + layers: [ + new Group.Frame({ + name: 'TopLevelGraphic->NestedFrame', + }), + ], + }), + new Group({ + name: 'TopLevelGroup', + layers: [ + new Group.Frame({ + name: 'TopLevelGroup->NestedFrame', + }), + ], + }), + ], + }, + ] + expect(find('Group', document).map((x) => x.name)).toEqual([ + 'TopLevelFrame->NestedGraphic', + 'TopLevelFrame->NestedGroup', + 'TopLevelGraphic->NestedFrame', + 'TopLevelGroup', + 'TopLevelGroup->NestedFrame', + ]) +}) diff --git a/Source/dom/enums.js b/Source/dom/enums.js index e8b736986..776f134cd 100644 --- a/Source/dom/enums.js +++ b/Source/dom/enums.js @@ -15,6 +15,7 @@ export const Types = { Text: 'Text', Document: 'Document', Library: 'Library', + StackLayout: `StackLayout`, SymbolMaster: 'SymbolMaster', SymbolInstance: 'SymbolInstance', Override: 'Override', diff --git a/Source/dom/export.js b/Source/dom/export.js index 53e03a011..388dc4c69 100644 --- a/Source/dom/export.js +++ b/Source/dom/export.js @@ -1,11 +1,7 @@ +import { ExportFormat } from './models/ExportFormat' import { isWrappedObject } from './utils' import { wrapNativeObject } from './wrapNativeObject' -import { - isArray, - toArray, - isObject, - toObject, -} from 'util' +import { isArray, toArray, isObject, toObject } from 'util' let Buffer @@ -98,21 +94,36 @@ function exportToImageFile(nativeObjects, options) { // export the layers if (layers.length) { - success = exporter.exportLayers(layers) && success + if (options.exportFormats?.length) { + const exportFormats = options.exportFormats.map((format) => + new ExportFormat(format).sketchObject.immutableModelObject() + ) + success = + layers.every((layer) => { + const requests = exporter.exportRequestsForLayer_inRect_exportFormats( + layer, + CGRectNull, + exportFormats + ) + return requests.every((request) => exporter.export(request)) + }) && success + } else { + success = exporter.exportLayers(layers) && success + } } if (!success) { - const errors = exporter.results()["errors"] + const errors = exporter.results()['errors'] if (isArray(errors) && errors.length) { const errorMessages = toArray(errors) .filter((error) => isObject(error)) - .map((error) => toObject(error)["error"]) + .map((error) => toObject(error)['error']) .filter((error) => error && error.isKindOfClass(NSError)) .map((error) => error.localizedDescription()) if (errorMessages && errorMessages.length) { - throw Error(errorMessages.join("\n")) + throw Error(errorMessages.join('\n')) } } } @@ -124,7 +135,11 @@ function exportToBuffer(nativeObject, options) { const exporter = MSSelfContainedHighLevelExporter.alloc().initWithOptions( options ) - const formats = exporter.formatsToExport() + const formats = options.exportFormats?.length + ? options.exportFormats.map((format) => + new ExportFormat(format).sketchObject.immutableModelObject() + ) + : exporter.formatsToExport() const rect = isPage ? exporter.rectToExportForPage(nativeObject) : CGRectNull diff --git a/Source/dom/find.js b/Source/dom/find.js index bb0a72bc8..8dc801c35 100644 --- a/Source/dom/find.js +++ b/Source/dom/find.js @@ -4,6 +4,7 @@ import { wrapObject } from './wrapNativeObject' import { Types } from './enums' import { Factory } from './Factory' import { colorFromString } from './style/Color' +import { Group } from './layers/Group' const simpleAttribute = (attribute, opposite) => ( operator, @@ -33,10 +34,16 @@ const attributesMap = { operator = '=' } const predicate = [] - const nativeClasses = - Factory._typeToNative[ - value in Factory._typeAliases ? Factory._typeAliases[value].type : value - ] + + if (['Artboard', 'Frame', 'Graphic'].includes(value)) { + // Artboards have been replaced by Frames and Graphics (which are + // ultimately just fancy names for Groups), but we still allow + // querying for Artboards for backwards compatibility. + // See `FilterStrategy` below for details + value = 'Group' + } + + const nativeClasses = Factory._typeToNative[value] if (!nativeClasses) { throw new Error(`Unknown layer type ${value}`) } @@ -144,13 +151,15 @@ export function find(predicate, root, options = {}) { }, } - const FilterStragegy = Object.freeze({ + const FilterStrategy = Object.freeze({ None: 'none', Artboard: 'artboard', Group: 'group', + Frame: 'frame', + Graphic: 'graphic', }) - let filterStragegy = FilterStragegy.None + let filterStrategy = FilterStrategy.None predicateParts.forEach((part) => { const matched = Object.keys(matchExpr).some((k) => { @@ -173,7 +182,9 @@ export function find(predicate, root, options = {}) { // groups with frame behaviour. case 'Artboard': case 'Group': - filterStragegy = FilterStragegy[match[1]] // set filter strategy and fallthrough + case 'Frame': + case 'Graphic': + filterStrategy = FilterStrategy[match[1]] // set filter strategy and fallthrough default: attributesMap.type('=', match[1], mutations) break @@ -234,7 +245,7 @@ export function find(predicate, root, options = {}) { // canvas or inside groups. Its contents scale based on the group size. var cb = (s) => { // By default, return all children - if (s == FilterStragegy.None) { + if (s == FilterStrategy.None) { return () => true } @@ -252,11 +263,20 @@ export function find(predicate, root, options = {}) { switch (s) { // Only include anything that is a canvas frame - case FilterStragegy.Artboard: - return (v) => canvasFrames.includes(v) + case FilterStrategy.Artboard: + return (group) => canvasFrames.includes(group) // Only include anything that is not a canvas frame, i.e. a regular group - case FilterStragegy.Group: - return (v) => !canvasFrames.includes(v) + case FilterStrategy.Group: + return (group) => !canvasFrames.includes(group) + // Include all Frames, including canvas frames and Graphics + case FilterStrategy.Frame: + return (group) => + group.isKindOfClass(MSLayerGroup) && Group.fromNative(group).isFrame + // Include all Graphics, including canvas frames + case FilterStrategy.Graphic: + return (group) => + group.isKindOfClass(MSLayerGroup) && + Group.fromNative(group).isGraphicFrame // Should never happen, caught earlier default: return () => true @@ -264,6 +284,6 @@ export function find(predicate, root, options = {}) { } return toArray(children.filteredArrayUsingPredicate(nativePredicate)) - .filter(cb(filterStragegy)) + .filter(cb(filterStrategy)) .map((x) => wrapObject(x)) } diff --git a/Source/dom/index.js b/Source/dom/index.js index 0c6bcf01b..762b5a8ed 100755 --- a/Source/dom/index.js +++ b/Source/dom/index.js @@ -14,6 +14,7 @@ const { Library, getLibraries } = require('./models/Library') const { SharedStyle } = require('./models/SharedStyle') const { Rectangle } = require('./models/Rectangle') const { SmartLayout } = require('./models/SmartLayout') +const { StackLayout } = require('./models/StackLayout') const { Style } = require('./style/Style') @@ -58,6 +59,7 @@ const DOM = { getLibraries, SharedStyle, SmartLayout, + StackLayout, Rectangle, Style, Layer, diff --git a/Source/dom/layers/Artboard.js b/Source/dom/layers/Artboard.js index c0c0023fc..6e3753722 100644 --- a/Source/dom/layers/Artboard.js +++ b/Source/dom/layers/Artboard.js @@ -1,39 +1,20 @@ import { DefinedPropertiesKey } from '../WrappedObject' import { Group, GroupBehavior } from './Group' -import { Rectangle } from '../models/Rectangle' import { Types } from '../enums' import { Factory } from '../Factory' -import { Color, colorToString } from '../style/Color' /** - * A Sketch artboard. + * A backfill for the legacy Artboards. Currently implemented as a Frame. + * Not to be created directly: use `Group.Frame` or `Group.Graphic` instead. */ export class Artboard extends Group { - /** - * Make a new artboard. - * - * @param [Object] properties - The properties to set on the object as a JSON object. - * If `sketchObject` is provided, will wrap it. - * Otherwise, creates a new native object. - */ constructor(artboard = {}) { - if (!artboard.sketchObject) { - // eslint-disable-next-line no-param-reassign - artboard.sketchObject = Factory.createNative(Group) - .alloc() - .initWithFrame_behavior( - new Rectangle(0, 0, 100, 100).asCGRect(), - GroupBehavior.Frame - ) - } - super(artboard) - // Mimics behaviour implemented at the controller level where they call - // `MSLayer.adjustAfterInsert()` which will apply the default styling. - this.background.enabled = true - // eslint-enable no-param-reassign + super({ + ...artboard, + groupBehavior: GroupBehavior.Frame, + }) } - // eslint-disable-next-line getParentArtboard() { return undefined } @@ -42,87 +23,3 @@ export class Artboard extends Group { Artboard.type = Types.Artboard Artboard[DefinedPropertiesKey] = { ...Group[DefinedPropertiesKey] } Factory.registerAlias(Artboard, Group) - -delete Artboard[DefinedPropertiesKey].flow -delete Artboard[DefinedPropertiesKey].locked -delete Artboard[DefinedPropertiesKey].hidden -delete Artboard[DefinedPropertiesKey].transform -delete Artboard[DefinedPropertiesKey].smartLayout - -Artboard.define('flowStartPoint', { - get() { - return !!this._object.isFlowHome() - }, - set(isFlowStartHome) { - if (this.isImmutable()) { - return - } - this._object.isFlowHome = isFlowStartHome - }, -}) - -Artboard.defineObject('background', { - enabled: { - get() { - return ( - this._object.style && - this._object.style().fills && - this._object.style().fills().length > 0 - ) - }, - set(enabled) { - if (this._parent.isImmutable()) { - return - } - const style = this._object.style ? this._object.style() : undefined - if (!style) { - return - } - if (enabled) { - const numFills = style.fills ? style.fills().length : 0 - if (numFills === 0) { - // Create a default fill if enabling and no fills exist - style.addStylePartOfType(0) // 0 is for fills - } - } else { - // Remove all fills if disabling - style.removeAllStyleFills() - } - }, - }, - includedInExport: { - get() { - return Boolean(Number(this._object.includeBackgroundColorInExport())) - }, - set(included) { - if (this._parent.isImmutable()) { - return - } - this._object.setIncludeBackgroundColorInExport(included) - }, - }, - color: { - get() { - const firstFill = this._object.style - ? this._object.style().firstEnabledFill() - : undefined - return firstFill ? colorToString(firstFill.color()) : '#00000000' - }, - set(color) { - if (this._parent.isImmutable()) { - return - } - if (!this._object.style) { - return - } - if ( - !this._object.style().fills || - this._object.style().fills().length === 0 - ) { - this._object.style().addStylePartOfType(0) // Add a fill if none exists - } - const firstFill = this._object.style().firstEnabledFill() - firstFill.color = Color.from(color).toMSColor() - }, - }, -}) diff --git a/Source/dom/layers/Group.js b/Source/dom/layers/Group.js index 177c7ffee..cf34aa0d9 100644 --- a/Source/dom/layers/Group.js +++ b/Source/dom/layers/Group.js @@ -6,6 +6,8 @@ import { Types } from '../enums' import { Factory } from '../Factory' import { wrapNativeObject, wrapObject } from '../wrapNativeObject' import { SmartLayout } from '../models/SmartLayout' +import { StackLayout } from '../models/StackLayout' +import { Color, colorToString } from '../style/Color' /** * Represents a group of layers. @@ -19,14 +21,28 @@ export class Group extends StyledLayer { * Otherwise, creates a new native object. */ constructor(group = {}) { + let createdNewNativeObject = false if (!group.sketchObject) { // eslint-disable-next-line no-param-reassign group.sketchObject = Factory.createNative(Group) .alloc() .initWithFrame(new Rectangle(0, 0, 100, 100).asCGRect()) + createdNewNativeObject = true } super(group) + + // Mimics behaviour implemented at the controller level where they call + // `MSLayer.adjustAfterInsert()` which will apply the default styling. + if (createdNewNativeObject && this.isFrame) { + const isCanvasFrame = + this._object.isCanvasFrame && this._object.isCanvasFrame() + const isEmptyNestedFrame = !isCanvasFrame && this.layers.length === 0 + const noBackgroundOverride = !group.background + if ((isCanvasFrame || isEmptyNestedFrame) && noBackgroundOverride) { + this.background.enabled = true + } + } } // @deprecated @@ -56,6 +72,24 @@ Group[DefinedPropertiesKey] = { ...StyledLayer[DefinedPropertiesKey] } Factory.registerClass(Group, MSLayerGroup) Factory.registerClass(Group, MSImmutableLayerGroup) +Group.Frame = class Frame extends Group { + constructor(group = {}) { + super({ + ...group, + groupBehavior: GroupBehavior.Frame, + }) + } +} + +Group.Graphic = class Graphic extends Group { + constructor(group = {}) { + super({ + ...group, + groupBehavior: GroupBehavior.Graphic, + }) + } +} + Group.define('groupBehavior', { get() { return this._object.groupBehavior() @@ -94,6 +128,7 @@ Group.define('layers', { }) this._object.addLayers(layers) + this.style.corners._applyConcentricCornersOnChildren() }, insertItem(item, index) { if (this.isImmutable()) { @@ -104,6 +139,7 @@ Group.define('layers', { layer._object.removeFromParent() } this._object.insertLayer_atIndex(layer._object, index) + this.style.corners._applyConcentricCornersOnChildren() return layer }, @@ -156,6 +192,136 @@ Group.define('smartLayout', { }, }) +Group.define('stackLayout', { + get() { + const groupLayout = this._object.groupLayout() || {} + if (!groupLayout.isKindOfClass(MSFlexGroupLayout)) { + return null + } + return StackLayout.fromNative(groupLayout) + }, + set(stackLayout) { + if (this.isImmutable()) { + return + } + if (stackLayout) { + const padding = stackLayout.padding + const layout = new StackLayout(stackLayout) + this._object.setGroupLayout(layout.sketchObject) + // We define `padding` on StackLayout itself as a convenient proxy for the corresponding + // property of its parent group. Because of that we postpone applying a padding value + // until the stack layout has actually been added to a parent group + if (padding) { + layout.update({ padding }) + } + // Match the app behavior that adjusts the group sizing upon applying a stack layout to it + layout.adjustParentGroupSizing() + } else { + this._object.setGroupLayout(MSFreeformGroupLayout.alloc().init()) + } + }, +}) + +Group.define('isFrame', { + importable: false, + exportable: false, + enumerable: false, + get() { + return ( + this.groupBehavior === GroupBehavior.Frame || + this.groupBehavior === GroupBehavior.Graphic + ) + }, +}) + +Group.define('isGraphicFrame', { + importable: false, + exportable: false, + enumerable: false, + get() { + return this.groupBehavior === GroupBehavior.Graphic + }, +}) + +Group.define('flowStartPoint', { + get() { + return !!this._object.isFlowHome() + }, + set(isFlowStartHome) { + if (this.isImmutable()) { + return + } + this._object.isFlowHome = isFlowStartHome + }, +}) + +Group.defineObject('background', { + enabled: { + get() { + return ( + this._parent.isFrame && + this._object.style && + this._object.style().fills && + this._object.style().fills().length > 0 + ) + }, + set(enabled) { + if (this._parent.isImmutable() || !this._parent.isFrame) { + return + } + const style = this._object.style ? this._object.style() : undefined + if (!style) { + return + } + if (enabled) { + const numFills = style.fills ? style.fills().length : 0 + if (numFills === 0) { + // Create a default fill if enabling and no fills exist + style.addStylePartOfType(0) // 0 is for fills + } + } else { + // Remove all fills if disabling + style.removeAllStyleFills() + } + }, + }, + includedInExport: { + get() { + return Boolean(Number(this._object.includeBackgroundColorInExport())) + }, + set(included) { + if (this._parent.isImmutable()) { + return + } + this._object.setIncludeBackgroundColorInExport(included) + }, + }, + color: { + get() { + const firstFill = this._object.style + ? this._object.style().firstEnabledFill() + : undefined + return firstFill ? colorToString(firstFill.color()) : '#00000000' + }, + set(color) { + if (this._parent.isImmutable()) { + return + } + if (!this._object.style) { + return + } + if ( + !this._object.style().fills || + this._object.style().fills().length === 0 + ) { + this._object.style().addStylePartOfType(0) // Add a fill if none exists + } + const firstFill = this._object.style().firstEnabledFill() + firstFill.color = Color.from(color).toMSColor() + }, + }, +}) + /** * Defines how a Group should behave. */ diff --git a/Source/dom/layers/Layer.js b/Source/dom/layers/Layer.js index d5cb2ee56..631d49857 100644 --- a/Source/dom/layers/Layer.js +++ b/Source/dom/layers/Layer.js @@ -6,6 +6,7 @@ import { wrapObject, wrapNativeObject } from '../wrapNativeObject' import { Flow } from '../models/Flow' import { ExportFormat } from '../models/ExportFormat' import { Types } from '../enums' +import { defineStackItemLayerProperties } from '../models/StackLayout' /** * Abstract class that represents a Sketch layer. @@ -355,7 +356,9 @@ Layer.defineObject('transform', { rotation: { get() { // `userVisibleRotation` is only defined on mutable objects. - let layer = this._parent.isImmutable() ? this._object.newMutableCounterpart() : this._object + let layer = this._parent.isImmutable() + ? this._object.newMutableCounterpart() + : this._object // Calling `userVisibleRotation` matches what users see in the inspector (which may be different from the raw `rotation` value). return layer.userVisibleRotation() @@ -394,7 +397,31 @@ Layer.defineObject('transform', { }, }) -/** +Layer.define('breaksMaskChain', { + get() { + return Boolean(this._object.shouldBreakMaskChain()) + }, + set(shouldIgnoreMask) { + if (this.isImmutable()) { + return + } + this._object.setShouldBreakMaskChain(Boolean(shouldIgnoreMask)) + }, +}) + +Layer.define('closestMaskingLayer', { + get() { + if (this.breaksMaskChain) { + return undefined + } + if (!this._object.closestClippingLayer) { + return undefined + } + return wrapObject(this._object.closestClippingLayer()) + }, +}) + +/** * ----------- * Flex Sizing * ----------- @@ -412,7 +439,7 @@ export const FlexSizing = { } /** - * Determines how the item is sized horizontally relative to its children or + * Determines how the item is sized horizontally relative to its children or * its parent group when their size changes. */ Layer.define('horizontalSizing', { @@ -420,8 +447,10 @@ Layer.define('horizontalSizing', { return this._object.horizontalSizing() }, set(value) { - if (this.isImmutable()) { return } - const sizingValue = typeof value === 'string' ? FlexSizing[value] : value; + if (this.isImmutable()) { + return + } + const sizingValue = typeof value === 'string' ? FlexSizing[value] : value if (Object.values(FlexSizing).includes(sizingValue)) { this._object.setHorizontalSizing(sizingValue) } @@ -429,7 +458,7 @@ Layer.define('horizontalSizing', { }) /** - * Determines how the item is sized vertically relative to its children or + * Determines how the item is sized vertically relative to its children or * its parent group when their size changes. */ Layer.define('verticalSizing', { @@ -437,8 +466,10 @@ Layer.define('verticalSizing', { return this._object.verticalSizing() }, set(value) { - if (this.isImmutable()) { return } - const sizingValue = typeof value === 'string' ? FlexSizing[value] : value; + if (this.isImmutable()) { + return + } + const sizingValue = typeof value === 'string' ? FlexSizing[value] : value if (Object.values(FlexSizing).includes(sizingValue)) { this._object.setVerticalSizing(sizingValue) } @@ -452,12 +483,10 @@ Layer.define('verticalSizing', { * @return {string} The name of the sizing */ export function getFlexSizing(value) { - return Object.keys(FlexSizing).find( - (key) => FlexSizing[key] === value - ) + return Object.keys(FlexSizing).find((key) => FlexSizing[key] === value) } -/** +/** * ----------- * Horizontal and Vertical Pinning * ----------- @@ -479,8 +508,10 @@ Layer.define('horizontalPins', { return this._object.horizontalPins() }, set(value) { - if (this.isImmutable()) { return } - const pinValue = typeof value === 'string' ? Pin[value] : value; + if (this.isImmutable()) { + return + } + const pinValue = typeof value === 'string' ? Pin[value] : value if (Object.values(Pin).includes(pinValue)) { this._object.setHorizontalPins(pinValue) } @@ -492,10 +523,14 @@ Layer.define('verticalPins', { return this._object.verticalPins() }, set(value) { - if (this.isImmutable()) { return } - const pinValue = typeof value === 'string' ? Pin[value] : value; + if (this.isImmutable()) { + return + } + const pinValue = typeof value === 'string' ? Pin[value] : value if (Object.values(Pin).includes(pinValue)) { this._object.setVerticalPins(pinValue) } }, }) + +defineStackItemLayerProperties(Layer) diff --git a/Source/dom/layers/Page.js b/Source/dom/layers/Page.js index 0e87a8034..54a01351c 100644 --- a/Source/dom/layers/Page.js +++ b/Source/dom/layers/Page.js @@ -4,6 +4,7 @@ import { Selection } from '../models/Selection' import { Types } from '../enums' import { Factory } from '../Factory' import { wrapNativeObject, wrapObject } from '../wrapNativeObject' +import { find } from '../find' /** * Represents a Page in a Sketch document. @@ -185,3 +186,15 @@ Page.define('selectedLayers', { return new Selection(this) }, }) + +Page.define('canvasLevelFrames', { + enumerable: false, + exportable: false, + importable: false, + get() { + // This should be faster than iterating through `layers` since + // `find()` operates on native objects directly and only drops + // to (much slower) JavaScript to wrap the results + return find('Artboard', this) + }, +}) diff --git a/Source/dom/layers/ShapePath.js b/Source/dom/layers/ShapePath.js index becda94f8..944a8a101 100644 --- a/Source/dom/layers/ShapePath.js +++ b/Source/dom/layers/ShapePath.js @@ -157,3 +157,15 @@ ShapePath.define('closed', { this._object.adjustFrameAfterEditIntegral(false) }, }) + +ShapePath.define('edited', { + get() { + return Boolean(Number(this._object.edited())) + }, + set(edited) { + if (this.isImmutable()) { + return + } + this._object.setEdited(edited) + }, +}) diff --git a/Source/dom/layers/StyledLayer.js b/Source/dom/layers/StyledLayer.js index f229fc2ea..f7f23d7e5 100644 --- a/Source/dom/layers/StyledLayer.js +++ b/Source/dom/layers/StyledLayer.js @@ -84,3 +84,36 @@ StyledLayer.define('sharedStyle', { this._object.setSharedStyleID(nativeSharedStyle.id) }, }) + +StyledLayer.define('masksSiblings', { + get() { + return Boolean(this._object.hasClippingMask()) + }, + set(shouldMaskSiblings) { + if (this.isImmutable()) { + return + } + this._object.setHasClippingMask(Boolean(shouldMaskSiblings)) + }, +}) + +Layer.MaskMode = Object.freeze({ + Outline: 0, + Alpha: 1, +}) + +StyledLayer.define('maskMode', { + get() { + return Number(this._object.clippingMaskMode()) + }, + set(newMaskMode) { + if (this.isImmutable()) { + return + } + if (Number.isInteger(newMaskMode)) { + this._object.setClippingMaskMode(newMaskMode) + } else { + throw new Error(`Invalid mask mode: ${newMaskMode}. Expected an integer.`) + } + }, +}) diff --git a/Source/dom/layers/SymbolInstance.js b/Source/dom/layers/SymbolInstance.js index 0f408f7d0..0c595a69c 100644 --- a/Source/dom/layers/SymbolInstance.js +++ b/Source/dom/layers/SymbolInstance.js @@ -8,6 +8,7 @@ import { wrapObject } from '../wrapNativeObject' import { Override } from '../models/Override' import { ImageData } from '../models/ImageData' import { getDocuments } from '../models/Document' +import { Color } from '../style/Color' /** * A Sketch symbol instance. @@ -60,9 +61,15 @@ export class SymbolInstance extends StyledLayer { ) } else if (wrappedOverride.property === 'stringValue') { this._object.setValue_forOverridePoint(String(value), overridePoint) + } else if (wrappedOverride.colorOverride) { + this._object.setValue_forOverridePoint( + Color.from(value).toMSImmutableColor(), + overridePoint + ) } else { this._object.setValue_forOverridePoint(value, overridePoint) } + this._object.ensureDetachHasUpdated() return this } @@ -171,3 +178,19 @@ SymbolInstance.define('overrides', { ) }, }) + +// An "override" for the `Layer.hidden` property so we can +// call `ensureDetachHasUpdated()` afterwards (SMAC-4904) +delete SymbolInstance[DefinedPropertiesKey].hidden +SymbolInstance.define('hidden', { + get() { + return !this._object.isVisible() + }, + set(hidden) { + if (this.isImmutable()) { + return + } + this._object.setIsVisible(!hidden) + this._object.ensureDetachHasUpdated() + }, +}) diff --git a/Source/dom/layers/SymbolMaster.js b/Source/dom/layers/SymbolMaster.js index ef90b1215..ba3b805f2 100644 --- a/Source/dom/layers/SymbolMaster.js +++ b/Source/dom/layers/SymbolMaster.js @@ -1,6 +1,7 @@ import { toArray } from 'util' import { DefinedPropertiesKey } from '../WrappedObject' import { Artboard } from './Artboard' +import { Group } from './Group' import { Layer } from './Layer' import { Rectangle } from '../models/Rectangle' import { Types } from '../enums' @@ -12,7 +13,7 @@ import { Document } from '../models/Document' /** * A Sketch symbol master. */ -export class SymbolMaster extends Artboard { +export class SymbolMaster extends Group { /** * Make a new symbol master. */ @@ -26,13 +27,20 @@ export class SymbolMaster extends Artboard { super(master) } - // Replace the artboard with a symbol master - static fromArtboard(artboard) { - const wrappedArtboard = wrapObject(artboard) + // Replace the frame with a symbol master + static fromFrame(frame) { + const wrappedFrame = wrapObject(frame) return SymbolMaster.fromNative( - MSSymbolMaster.convertFrameToSymbol(wrappedArtboard.sketchObject) + MSSymbolMaster.convertFrameToSymbol(wrappedFrame.sketchObject) + ) + } + + static fromArtboard(artboard) { + console.warn( + 'SymbolMaster.fromArtboard() is deprecated, use SymbolMaster.fromFrame() instead.' ) + return SymbolMaster.fromFrame(artboard) } // Replace the symbol with an artboard and detach all its instances converting them into groups. @@ -42,6 +50,10 @@ export class SymbolMaster extends Artboard { return Artboard.fromNative(artboard) } + getParentArtboard() { + return undefined + } + // Returns a new SymbolInstance linked to this Frame, ready for inserting in the document createNewInstance() { return wrapObject(this._object.newSymbolInstance()) @@ -128,7 +140,7 @@ export class SymbolMaster extends Artboard { } SymbolMaster.type = Types.SymbolMaster -SymbolMaster[DefinedPropertiesKey] = { ...Artboard[DefinedPropertiesKey] } +SymbolMaster[DefinedPropertiesKey] = { ...Group[DefinedPropertiesKey] } Factory.registerClass(SymbolMaster, MSSymbolMaster) Factory.registerClass(SymbolMaster, MSImmutableSymbolMaster) @@ -172,13 +184,14 @@ SymbolMaster.define('overrides', { this._object.overridePoints().forEach((o) => { dict[o.name()] = o }) - + overrides.forEach((o) => { const overridePoint = dict[o.id] if (overridePoint) { this._object.setOverridePoint_editable(overridePoint, o.editable) } }) + this._object.ensureDetachHasUpdated() }, }) diff --git a/Source/dom/layers/__tests__/Artboard.test.js b/Source/dom/layers/__tests__/Artboard.test.js index b8ecab8c8..54132a9de 100644 --- a/Source/dom/layers/__tests__/Artboard.test.js +++ b/Source/dom/layers/__tests__/Artboard.test.js @@ -1,11 +1,30 @@ /* globals expect, test */ import { canBeLogged } from '../../../test-utils' -import { Artboard, Document } from '../..' +import { Artboard, Document, Page, Group, GroupBehavior, find } from '../..' test('should create an artboard', () => { const artboard = new Artboard({ name: 'Test' }) expect(artboard.type).toBe('Artboard') + expect(artboard.groupBehavior).toBe(GroupBehavior.Frame) canBeLogged(artboard, Artboard) + + const pageWithImplicitArtboard = new Page({ + layers: [ + { + type: 'Artboard', + name: 'Test', + layers: [ + { + type: 'Shape', + name: 'TestShape', + frame: { x: 10, y: 10, width: 100, height: 100 }, + }, + ], + groupBehavior: GroupBehavior.Graphic, + }, + ], + }) + expect(pageWithImplicitArtboard.layers[0].type).toBe('Artboard') }) test('should set the artboard as a flow start point', () => { @@ -17,7 +36,7 @@ test('should set the artboard as a flow start point', () => { test('should set the background', () => { const document = new Document() const artboard = new Artboard({ - parent: document.selectedPage + parent: document.selectedPage, }) // defaults @@ -47,3 +66,37 @@ test('should set the background', () => { color: '#00000000', }) }) + +test('should only return Artboards from Page.selectedLayers, Document.selectedLayers, Group.layers(), and find()', (_context, document) => { + const page = document.selectedPage + const artboard = new Group({ + name: 'CanvasFrame', + parent: page, + groupBehavior: GroupBehavior.Frame, + layers: [ + new Group({ + groupBehavior: GroupBehavior.Graphic, + name: 'NestedGraphic', + }), + ], + }) + artboard.selected = true + + expect(page.layers[0].type).toBe('Artboard') + expect(document.selectedLayers.layers[0].type).toBe('Artboard') + expect(page.selectedLayers.layers[0].type).toBe('Artboard') + expect(page.layers[0].layers[0].type).toBe('Group') + + artboard.selected = false + artboard.layers[0].selected = true + expect(document.selectedLayers.layers[0].type).toBe('Group') + expect(page.selectedLayers.layers[0].type).toBe('Group') + + expect(find('Artboard', document)[0].type).toBe('Artboard') + expect(find('[name="CanvasFrame"]', document)[0].type).toBe('Artboard') + expect(find('Artboard', page)[0].type).toBe('Artboard') + expect(find('[name="CanvasFrame"]', page)[0].type).toBe('Artboard') + + expect(find('[name="NestedGraphic"]', document)[0].type).toBe('Group') + expect(find('[name="NestedGraphic"]', page)[0].type).toBe('Group') +}) diff --git a/Source/dom/layers/__tests__/Group.test.js b/Source/dom/layers/__tests__/Group.test.js index 7c3d72a5f..6681c8989 100644 --- a/Source/dom/layers/__tests__/Group.test.js +++ b/Source/dom/layers/__tests__/Group.test.js @@ -1,6 +1,13 @@ /* globals expect, test */ import { canBeLogged } from '../../../test-utils' -import { Group, Text, Rectangle, SmartLayout } from '../..' +import { + Artboard, + Group, + Text, + Rectangle, + SmartLayout, + GroupBehavior, +} from '../..' test('should return the layers and can iterate through them', (_context, document) => { const page = document.selectedPage @@ -90,17 +97,118 @@ test('should expose a smartLayout getter/setter', (_context, document) => { // returns null by default expect(group.smartLayout).toBe(null) - expect(group._object.groupLayout().isKindOfClass(MSFreeformGroupLayout)).toBeTruthy() + expect( + group._object.groupLayout().isKindOfClass(MSFreeformGroupLayout) + ).toBeTruthy() // can set to a value group.smartLayout = SmartLayout.TopToBottom expect(group.smartLayout).toBe(SmartLayout.TopToBottom) - expect(group._object.groupLayout().isKindOfClass(MSInferredGroupLayout)).toBeTruthy() + expect( + group._object.groupLayout().isKindOfClass(MSInferredGroupLayout) + ).toBeTruthy() expect(group._object.groupLayout().axis()).toBe(1) expect(group._object.groupLayout().layoutAnchor()).toBe(0) // can clear the value group.smartLayout = null - expect(group._object.groupLayout().isKindOfClass(MSFreeformGroupLayout)).toBeTruthy() + expect( + group._object.groupLayout().isKindOfClass(MSFreeformGroupLayout) + ).toBeTruthy() expect(group.smartLayout).toBe(null) }) + +test('should report isFrame and isGraphicFrame', () => { + const frame = new Group({ + groupBehavior: GroupBehavior.Frame, + }) + expect(frame.isFrame).toBe(true) + expect(frame.isGraphicFrame).toBe(false) + + const graphic = new Group({ + groupBehavior: GroupBehavior.Graphic, + }) + expect(graphic.isFrame).toBe(true) + expect(graphic.isGraphicFrame).toBe(true) + + const group = new Group() + expect(group.isFrame).toBe(false) + expect(group.isGraphicFrame).toBe(false) +}) + +test('should create Frames and Graphics via convenience constructors', (_context, document) => { + const frame = new Group.Frame({ + layers: [ + { + type: 'Text', + text: 'hello world', + }, + ], + }) + expect(frame.type).toBe('Group') + expect(frame.layers.length).toBe(1) + expect(frame.groupBehavior).toBe(GroupBehavior.Frame) + + const graphic = new Group.Graphic({ + layers: [ + { + type: 'Text', + text: 'hello world', + }, + ], + }) + expect(graphic.type).toBe('Group') + expect(graphic.layers.length).toBe(1) + expect(graphic.groupBehavior).toBe(GroupBehavior.Graphic) + + document.selectedPage.layers = [frame] + // once a frame is added to the page, it becomes a "canvas frame" + // and thus should be reported as an Artboard from now on + expect(document.selectedPage.layers[0].type).toBe('Artboard') +}) + +test('should enable background for new frames', () => { + const frame = new Group({ + groupBehavior: GroupBehavior.Frame, + }) + expect(frame.background.enabled).toBe(true) + + const implicitFrame = new Artboard() + expect(implicitFrame.background.enabled).toBe(true) + + const graphic = new Group({ + groupBehavior: GroupBehavior.Graphic, + }) + expect(graphic.background.enabled).toBe(true) + + const group = new Group({ + groupBehavior: GroupBehavior.Group, + }) + expect(group.background.enabled).toBe(false) + + const multilayerFrame = new Group({ + groupBehavior: GroupBehavior.Group, + layers: [ + { + type: 'Group', + groupBehavior: GroupBehavior.Graphic, + }, + ], + }) + expect(multilayerFrame.background.enabled).toBe(false) + expect(multilayerFrame.layers[0].background.enabled).toBe(true) + + const frameWithExplicitBackground = new Group({ + groupBehavior: GroupBehavior.Frame, + background: { + enabled: false, + }, + }) + expect(frameWithExplicitBackground.background.enabled).toBe(false) + + const frameAdoptedFromNative = new Group({ + sketchObject: MSLayerGroup.alloc().init(), + groupBehavior: GroupBehavior.Frame, + }) + expect(frameAdoptedFromNative.background.enabled).toBe(false) +}) diff --git a/Source/dom/layers/__tests__/Layer.test.js b/Source/dom/layers/__tests__/Layer.test.js index fcdcbf4f8..0556a946a 100644 --- a/Source/dom/layers/__tests__/Layer.test.js +++ b/Source/dom/layers/__tests__/Layer.test.js @@ -1,5 +1,13 @@ /* globals expect, test */ -import { Group, Rectangle, Artboard, SymbolMaster, Shape, ShapePath } from '../..' +import { + Group, + Rectangle, + Artboard, + SymbolMaster, + Shape, + ShapePath, + Layer, +} from '../..' test('should set the name of the layer', (_context, document) => { // setting an existing name @@ -273,7 +281,7 @@ test('should get the different parents', (_context, document) => { expect(group.getParentSymbolMaster()).toBe(undefined) expect(group.getParentShape()).toBe(undefined) - const symbolMaster = SymbolMaster.fromArtboard(artboard) + const symbolMaster = SymbolMaster.fromFrame(artboard) expect(symbolMaster.parent).toEqual(page) expect(symbolMaster.getParentPage()).toEqual(page) expect(symbolMaster.getParentArtboard()).toEqual(undefined) @@ -317,21 +325,23 @@ test('should return valid line rotation', () => { let path = MSPath.alloc().initWithLineFrom_to(start, end) let layer = MSShapePathLayer.layerWithPath(path) - let immutableShape = new ShapePath({sketchObject: layer.immutableModelObject()}) + let immutableShape = new ShapePath({ + sketchObject: layer.immutableModelObject(), + }) expect(Number(immutableShape.sketchObject.isLine())).toBe(1) expect(immutableShape.transform.rotation).toBe(45) - + immutableShape.transform.rotation = 80 // It should not be possible to modify an immutable object. expect(immutableShape.transform.rotation).toBe(45) - let shape = new ShapePath({sketchObject: layer}) + let shape = new ShapePath({ sketchObject: layer }) expect(Number(shape.sketchObject.isLine())).toBe(1) expect(shape.transform.rotation).toBe(45) shape.transform.rotation = 80 expect(shape.transform.rotation).toBe(80) - + shape.transform.rotation = 0 expect(shape.transform.rotation).toBeCloseTo(0) }) @@ -401,7 +411,7 @@ test('should not accept invalid layer sizing values', (_context, document) => { expect(layer.horizontalSizing).toBe(1) // Should remain at previous valid value layer.horizontalSizing = 999 expect(layer.horizontalSizing).toBe(1) // Should remain at previous valid value - layer.verticalSizing = 'Invalid' + layer.verticalSizing = 'Invalid' expect(layer.verticalSizing).toBe(1) // Should remain at previous valid value layer.verticalSizing = 999 expect(layer.verticalSizing).toBe(1) // Should remain at previous valid value @@ -410,11 +420,11 @@ test('should not accept invalid layer sizing values', (_context, document) => { test('should handle layer pin properties', (_context, document) => { const frame = new Artboard({ frame: { x: 0, y: 0, width: 10, height: 10 }, - parent: document.selectedPage + parent: document.selectedPage, }) const layer = new Shape({ frame: { x: 2, y: 2, width: 6, height: 6 }, - parent: frame + parent: frame, }) // Test horizontal pins expect(layer.horizontalPins).toBe(0) // Default should be None (0) @@ -424,20 +434,20 @@ test('should handle layer pin properties', (_context, document) => { expect(layer.horizontalPins).toBe(4) // Max is 1<<2 layer.horizontalPins = 'All' expect(layer.horizontalPins).toBe(5) // All is Min|Max (5) - + // Test setting numeric values layer.horizontalPins = 0 expect(layer.horizontalPins).toBe(0) - + // Test vertical pins expect(layer.verticalPins).toBe(0) // Default should be None (0) layer.verticalPins = 'Min' expect(layer.verticalPins).toBe(1) // Min is 1<<0 - layer.verticalPins = 'Max' + layer.verticalPins = 'Max' expect(layer.verticalPins).toBe(4) // Max is 1<<2 layer.verticalPins = 'All' expect(layer.verticalPins).toBe(5) // All is Min|Max (5) - + // Test setting numeric values layer.verticalPins = 0 expect(layer.verticalPins).toBe(0) @@ -459,10 +469,80 @@ test('should not accept invalid layer pin values', (_context, document) => { // Test invalid values don't change the pins layer.horizontalPins = 'Invalid' expect(layer.horizontalPins).toBe(1) // Should remain at previous valid value - layer.horizontalPins = 999 + layer.horizontalPins = 999 expect(layer.horizontalPins).toBe(1) // Should remain at previous valid value layer.verticalPins = 'Invalid' expect(layer.verticalPins).toBe(1) // Should remain at previous valid value layer.verticalPins = 999 expect(layer.verticalPins).toBe(1) // Should remain at previous valid value }) + +test('should mask siblings', (_context, document) => { + const mask = new Shape({ + frame: new Rectangle(20, 20, 40, 40), + masksSiblings: true, + maskMode: Layer.MaskMode.Outline, + }) + const sibling = new ShapePath({ + frame: new Rectangle(0, 0, 100, 100), + }) + + // eslint-disable-next-line no-unused-vars + const group = new Group({ + parent: document.selectedPage, + layers: [mask, sibling], + }) + + expect(mask.masksSiblings).toBe(true) + expect(mask.maskMode).toBe(Layer.MaskMode.Outline) + expect(sibling.masksSiblings).toBe(false) + expect(sibling.closestMaskingLayer).toEqual(mask) + + mask.masksSiblings = false + expect(sibling.closestMaskingLayer).toBeUndefined() +}) + +test('should break mask chain if needed', (_context, document) => { + const mask = new Shape({ + masksSiblings: true, + maskMode: Layer.MaskMode.Alpha, + }) + const sibling1 = new ShapePath() + const sibling2 = new ShapePath() + + // eslint-disable-next-line no-unused-vars + const group = new Group({ + parent: document.selectedPage, + layers: [mask, sibling1, sibling2], + }) + + expect(sibling1.closestMaskingLayer).toEqual(mask) + expect(sibling2.closestMaskingLayer).toEqual(mask) + + // Break the mask chain at sibling1 + sibling1.breaksMaskChain = true + + expect(sibling1.closestMaskingLayer).toBeUndefined() + expect(sibling2.closestMaskingLayer).toBeUndefined() +}) + +test('should navigate the entire mask chain', (_context, document) => { + const mask1 = new Shape({ + masksSiblings: true, + maskMode: Layer.MaskMode.Alpha, + }) + const mask2 = new Shape({ + masksSiblings: true, + maskMode: Layer.MaskMode.Alpha, + }) + const sibling = new ShapePath() + + // eslint-disable-next-line no-unused-vars + const group = new Group({ + parent: document.selectedPage, + layers: [mask1, mask2, sibling], + }) + + expect(sibling.closestMaskingLayer).toEqual(mask2) + expect(sibling.closestMaskingLayer.closestMaskingLayer).toEqual(mask1) +}) diff --git a/Source/dom/layers/__tests__/Page.test.js b/Source/dom/layers/__tests__/Page.test.js index be25ca1cd..3777566f7 100644 --- a/Source/dom/layers/__tests__/Page.test.js +++ b/Source/dom/layers/__tests__/Page.test.js @@ -77,3 +77,40 @@ test('should get the Symbols page', (_context, document) => { page.parent = document expect(Page.getSymbolsPage(document)).toEqual(page) }) + +test('should return canvas-level frames', (_context, document) => { + const page = new Page({ + parent: document, + name: 'PageWithFrames', + layers: [ + new Group.Frame({ + name: 'TopLevelFrame', + layers: [ + new Group.Graphic({ + name: 'TopLevelFrame->NestedGraphic', + }), + ], + }), + new Group.Graphic({ + name: 'TopLevelGraphic', + layers: [ + new Group.Frame({ + name: 'TopLevelGraphic->NestedFrame', + }), + ], + }), + new Group({ + name: 'TopLevelGroup', + layers: [ + new Group.Frame({ + name: 'TopLevelGroup->NestedFrame', + }), + ], + }), + ], + }) + + expect(page.canvasLevelFrames.length).toBe(2) + expect(page.canvasLevelFrames[0].name).toBe('TopLevelFrame') + expect(page.canvasLevelFrames[1].name).toBe('TopLevelGraphic') +}) diff --git a/Source/dom/layers/__tests__/ShapePath.test.js b/Source/dom/layers/__tests__/ShapePath.test.js index 55a13b721..cfe48d1b7 100644 --- a/Source/dom/layers/__tests__/ShapePath.test.js +++ b/Source/dom/layers/__tests__/ShapePath.test.js @@ -67,3 +67,17 @@ test('should create a shape path from an svg path', () => { const shapePath = ShapePath.fromSVGPath(svgPath) expect(shapePath.getSVGPath()).toBe(svgPath) }) + +test('should report its edited status', () => { + const shapePath = new ShapePath() + expect(shapePath.edited).toBe(false) + + shapePath.points[0].point = { x: 10, y: 10 } + expect(shapePath.edited).toBe(true) + + const shapePath2 = new ShapePath() + expect(shapePath2.edited).toBe(false) + + shapePath2.edited = true + expect(shapePath2.edited).toBe(true) +}) diff --git a/Source/dom/layers/__tests__/StackLayout.test.js b/Source/dom/layers/__tests__/StackLayout.test.js new file mode 100644 index 000000000..70141b91e --- /dev/null +++ b/Source/dom/layers/__tests__/StackLayout.test.js @@ -0,0 +1,203 @@ +/* globals expect, test */ +import { Group, StackLayout } from '../..' + +test('should expose configuration types', () => { + expect(StackLayout.Direction).toBeDefined() + expect(StackLayout.JustifyContent).toBeDefined() + expect(StackLayout.AlignItems).toBeDefined() +}) + +test('should set configuration options', () => { + const group = new Group({ + stackLayout: { + direction: StackLayout.Direction.Column, + justifyContent: StackLayout.JustifyContent.End, + alignItems: StackLayout.AlignItems.Center, + gap: 42, + padding: { vertical: 20 }, + }, + }) + + expect(group.stackLayout.direction).toBe(StackLayout.Direction.Column) + expect(group.stackLayout.justifyContent).toBe(StackLayout.JustifyContent.End) + expect(group.stackLayout.alignItems).toBe(StackLayout.AlignItems.Center) + expect(group.stackLayout.gap).toBe(42) + expect(group.stackLayout.padding).toEqual({ vertical: 20, horizontal: 0 }) + + group.stackLayout.direction = StackLayout.Direction.Row + group.stackLayout.justifyContent = StackLayout.JustifyContent.Start + group.stackLayout.alignItems = StackLayout.AlignItems.None + group.stackLayout.gap = 11 + group.stackLayout.padding = 42 + + expect(group.stackLayout.direction).toBe(StackLayout.Direction.Row) + expect(group.stackLayout.justifyContent).toBe( + StackLayout.JustifyContent.Start + ) + expect(group.stackLayout.alignItems).toBe(StackLayout.AlignItems.None) + expect(group.stackLayout.gap).toBe(11) + expect(group.stackLayout.padding).toEqual(42) +}) + +test('should set individual padding on parent group', () => { + // StackLayout with individual padding values for each edge + const group = new Group({ + stackLayout: { + padding: { top: 1, bottom: 2, left: 3, right: 4 }, + }, + }) + // Partial padding updates within the same mode (i.e individual/paired/uniform) should carry over other fields + group.stackLayout.padding = { top: 11 } + expect(group.stackLayout.padding).toEqual({ + top: 11, + bottom: 2, + left: 3, + right: 4, + }) + + group.stackLayout.padding = { top: 10 } + group.stackLayout.padding = { bottom: 20 } + group.stackLayout.padding = { left: 30 } + group.stackLayout.padding = { right: 40 } + + expect(group.stackLayout.padding).toEqual({ + top: 10, + bottom: 20, + left: 30, + right: 40, + }) + + group.stackLayout.padding = null + expect(group.stackLayout.padding).not.toEqual({ + top: 10, + bottom: 20, + left: 30, + right: 40, + }) +}) + +test('should set paired padding on parent group', () => { + // StackLayout with paired padding values for each axis + const group = new Group({ + stackLayout: { + padding: { vertical: 42, horizontal: 24 }, + }, + }) + // Partial padding updates of the same kind should be merged with the previous value + group.stackLayout.padding = { horizontal: 100 } + expect(group.stackLayout.padding).toEqual({ vertical: 42, horizontal: 100 }) + + group.stackLayout.padding = { vertical: 11 } + expect(group.stackLayout.padding).toEqual({ vertical: 11, horizontal: 100 }) + + group.stackLayout.padding = null + expect(group.stackLayout.padding).not.toEqual({ + vertical: 11, + horizontal: 100, + }) +}) + +test('should set paired uniform on parent group', () => { + // StackLayout with the same padding value for each edge + const group = new Group({ + stackLayout: { + padding: 42, + }, + }) + expect(group.stackLayout.padding).toEqual(42) + + group.stackLayout.padding = 1337 + expect(group.stackLayout.padding).toEqual(1337) + + group.stackLayout.padding = null + expect(group.stackLayout.padding).not.toBe(1337) +}) + +test('should switch between padding modes depending on input', () => { + // StackLayout with implicit default padding + const group = new Group({ stackLayout: {} }) + expect(group.stackLayout.padding).toBeDefined() + + // Explicit per-edge (individual) padding + group.stackLayout.padding = { top: 1, left: 2 } + expect(group.stackLayout.padding).toEqual({ + top: 1, + left: 2, + bottom: 0, + right: 0, + }) + // Appends partial values of the same padding kind to existing padding + group.stackLayout.padding = { top: 3, right: 4 } + expect(group.stackLayout.padding).toEqual({ + top: 3, + left: 2, + right: 4, + bottom: 0, + }) + + // Switch to per-axis padding. This should clear the previous value + group.stackLayout.padding = { vertical: 33 } + expect(group.stackLayout.padding).toEqual({ + vertical: 33, + horizontal: 4, // 4 <== max(oldLeft, oldRight) + }) + // Appends partial values of the same padding kind to existing padding + group.stackLayout.padding = { horizontal: 90 } + expect(group.stackLayout.padding).toEqual({ + vertical: 33, + horizontal: 90, + }) + + // Switch to uniform padding + group.stackLayout.padding = 100 + expect(group.stackLayout.padding).toBe(100) + + // Resets padding + group.stackLayout.padding = null + expect(group.stackLayout.padding).toBeDefined() +}) + +test('stack item can ignore stack layout', () => { + const group = new Group({ + stackLayout: {}, + layers: [ + { type: 'Text' }, + { type: 'Text', ignoresStackLayout: true }, + { type: 'Text' }, + ], + }) + expect(group.layers[0].ignoresStackLayout).toBe(false) + expect(group.layers[1].ignoresStackLayout).toBe(true) + expect(group.layers[2].ignoresStackLayout).toBe(false) + + group.layers[0].ignoresStackLayout = true + group.layers[1].ignoresStackLayout = false + group.layers[2].ignoresStackLayout = true + + expect(group.layers[0].ignoresStackLayout).toBe(true) + expect(group.layers[1].ignoresStackLayout).toBe(false) + expect(group.layers[2].ignoresStackLayout).toBe(true) +}) + +test('stack item can preserve space in stack when hidden', () => { + const group = new Group({ + stackLayout: {}, + layers: [ + { type: 'Text' }, + { type: 'Text', preservesSpaceInStackLayoutWhenHidden: true }, + { type: 'Text' }, + ], + }) + + expect(group.layers[0].preservesSpaceInStackLayoutWhenHidden).toBe(false) + expect(group.layers[1].preservesSpaceInStackLayoutWhenHidden).toBe(true) + expect(group.layers[2].preservesSpaceInStackLayoutWhenHidden).toBe(false) + + group.layers[0].preservesSpaceInStackLayoutWhenHidden = true + group.layers[1].preservesSpaceInStackLayoutWhenHidden = false + group.layers[2].preservesSpaceInStackLayoutWhenHidden = true + + expect(group.layers[0].preservesSpaceInStackLayoutWhenHidden).toBe(true) + expect(group.layers[1].preservesSpaceInStackLayoutWhenHidden).toBe(false) + expect(group.layers[2].preservesSpaceInStackLayoutWhenHidden).toBe(true) +}) diff --git a/Source/dom/layers/__tests__/SymbolInstance.test.js b/Source/dom/layers/__tests__/SymbolInstance.test.js index 06e25e9f1..702c9b156 100644 --- a/Source/dom/layers/__tests__/SymbolInstance.test.js +++ b/Source/dom/layers/__tests__/SymbolInstance.test.js @@ -36,13 +36,14 @@ test('should have overrides', (_context, document) => { expect(instance.overrides.length).toBe(10) - const override = instance.overrides.find(o => o.property === 'stringValue') + const override = instance.overrides.find((o) => o.property === 'stringValue') const result = { type: 'Override', id: `${text.id}_stringValue`, path: text.id, property: 'stringValue', symbolOverride: false, + colorOverride: false, value: 'Test value', isDefault: true, editable: true, @@ -50,9 +51,13 @@ test('should have overrides', (_context, document) => { selected: false, } delete result.affectedLayer.selected + delete result.affectedLayer.ignoresStackLayout + delete result.affectedLayer.preservesSpaceInStackLayoutWhenHidden result.affectedLayer.style = override.affectedLayer.style.toJSON() - const overrideAfter = instance.overrides.find(o => o.property === 'stringValue') + const overrideAfter = instance.overrides.find( + (o) => o.property === 'stringValue' + ) expect(overrideAfter.toJSON()).toEqual(result) }) @@ -60,7 +65,7 @@ test('should have overrides', (_context, document) => { // - fix #49472 didn't work after all. :sad-panda: // We NEED these tests, so for now we should disable this one until // we know why this isn't working correctly. - JLN, 6 Mar, 2023 -// +// //test('should detach an instance', (_context, document) => { // const { master } = createSymbolMaster(document) // const instance = new SymbolInstance({ diff --git a/Source/dom/layers/__tests__/SymbolMaster.test.js b/Source/dom/layers/__tests__/SymbolMaster.test.js index 83cc88f9b..186adc1f9 100644 --- a/Source/dom/layers/__tests__/SymbolMaster.test.js +++ b/Source/dom/layers/__tests__/SymbolMaster.test.js @@ -60,7 +60,7 @@ test('should create a symbol master with a nested symbol', (_context, document) const nestedInstance = nestedMaster.createNewInstance() artboard.layers = [nestedInstance, text2] - const master = SymbolMaster.fromArtboard(artboard) + const master = SymbolMaster.fromFrame(artboard) const instance = master.createNewInstance() @@ -72,9 +72,13 @@ test('should create a symbol master with a nested symbol', (_context, document) // Find the override points being tested here: // - Two string value override points from the top level text layer and the nested. // - One symbol override point from the the nested instance. - const stringValueOverrides = instance.overrides.filter(o => o.property === 'stringValue') + const stringValueOverrides = instance.overrides.filter( + (o) => o.property === 'stringValue' + ) expect(stringValueOverrides.length).toBe(2) - const symbolOverrides = instance.overrides.filter(o => o.property === 'symbolID') + const symbolOverrides = instance.overrides.filter( + (o) => o.property === 'symbolID' + ) expect(symbolOverrides.length).toBe(1) const result0 = { @@ -83,6 +87,7 @@ test('should create a symbol master with a nested symbol', (_context, document) path: text2.id, property: 'stringValue', symbolOverride: false, + colorOverride: false, value: 'Test value 2', isDefault: true, editable: true, @@ -91,6 +96,8 @@ test('should create a symbol master with a nested symbol', (_context, document) } delete result0.affectedLayer.overrides delete result0.affectedLayer.selected + delete result0.affectedLayer.ignoresStackLayout + delete result0.affectedLayer.preservesSpaceInStackLayoutWhenHidden const stringOverride0 = stringValueOverrides[0] result0.affectedLayer.style = stringOverride0.affectedLayer.style.toJSON() @@ -100,6 +107,7 @@ test('should create a symbol master with a nested symbol', (_context, document) path: nestedInstance.id, property: 'symbolID', symbolOverride: true, + colorOverride: false, value: nestedInstance.symbolId, isDefault: true, editable: true, @@ -108,6 +116,8 @@ test('should create a symbol master with a nested symbol', (_context, document) } delete result1.affectedLayer.overrides delete result1.affectedLayer.selected + delete result1.affectedLayer.ignoresStackLayout + delete result1.affectedLayer.preservesSpaceInStackLayoutWhenHidden const symbolOverride = symbolOverrides[0] result1.affectedLayer.style = symbolOverride.affectedLayer.style.toJSON() @@ -117,6 +127,7 @@ test('should create a symbol master with a nested symbol', (_context, document) path: `${nestedInstance.id}/${text.id}`, property: 'stringValue', symbolOverride: false, + colorOverride: false, value: 'Test value', isDefault: true, editable: true, @@ -124,14 +135,19 @@ test('should create a symbol master with a nested symbol', (_context, document) selected: false, } delete result2.affectedLayer.selected + delete result2.affectedLayer.ignoresStackLayout + delete result2.affectedLayer.preservesSpaceInStackLayoutWhenHidden const stringOverride1 = stringValueOverrides[1] result2.affectedLayer.style = stringOverride1.affectedLayer.style.toJSON() - // Find the same override points again from the source - const stringValueOverridesAfter = instance.overrides.filter(o => o.property === 'stringValue') + const stringValueOverridesAfter = instance.overrides.filter( + (o) => o.property === 'stringValue' + ) expect(stringValueOverridesAfter.length).toBe(2) - const symbolOverridesAfter = instance.overrides.filter(o => o.property === 'symbolID') + const symbolOverridesAfter = instance.overrides.filter( + (o) => o.property === 'symbolID' + ) expect(symbolOverridesAfter.length).toBe(1) expect(stringValueOverridesAfter[0].toJSON()).toEqual(result0) @@ -143,13 +159,14 @@ test('should have overrides', (_context, document) => { const { master, text } = createSymbolMaster(document) expect(master.overrides.length).toBe(10) - const override = master.overrides.find(o => o.property === 'stringValue') + const override = master.overrides.find((o) => o.property === 'stringValue') const result = { type: 'Override', id: `${text.id}_stringValue`, path: text.id, property: 'stringValue', symbolOverride: false, + colorOverride: false, value: 'Test value', isDefault: true, editable: true, @@ -157,7 +174,11 @@ test('should have overrides', (_context, document) => { selected: false, } delete result.affectedLayer.selected - const overrideAfter = master.overrides.find(o => o.property === 'stringValue') + delete result.affectedLayer.ignoresStackLayout + delete result.affectedLayer.preservesSpaceInStackLayoutWhenHidden + const overrideAfter = master.overrides.find( + (o) => o.property === 'stringValue' + ) result.affectedLayer.style = overrideAfter.affectedLayer.style.toJSON() expect(override.toJSON()).toEqual(result) }) diff --git a/Source/dom/models/BlendingMode.js b/Source/dom/models/BlendingMode.js new file mode 100644 index 000000000..98d76caea --- /dev/null +++ b/Source/dom/models/BlendingMode.js @@ -0,0 +1,41 @@ +export const BlendingModeMap = { + Normal: 0, + Darken: 1, + Multiply: 2, + ColorBurn: 3, + Lighten: 4, + Screen: 5, + ColorDodge: 6, + Overlay: 7, + SoftLight: 8, + HardLight: 9, + Difference: 10, + Exclusion: 11, + Hue: 12, + Saturation: 13, + Color: 14, + Luminosity: 15, + PlusDarker: 16, + PlusLighter: 17, +} + +export const BlendingMode = { + Normal: 'Normal', + Darken: 'Darken', + Multiply: 'Multiply', + ColorBurn: 'ColorBurn', + Lighten: 'Lighten', + Screen: 'Screen', + ColorDodge: 'ColorDodge', + Overlay: 'Overlay', + SoftLight: 'SoftLight', + HardLight: 'HardLight', + Difference: 'Difference', + Exclusion: 'Exclusion', + Hue: 'Hue', + Saturation: 'Saturation', + Color: 'Color', + Luminosity: 'Luminosity', + PlusDarker: 'PlusDarker', + PlusLighter: 'PlusLighter', +} diff --git a/Source/dom/models/Flow.js b/Source/dom/models/Flow.js index f6a54c3b3..f2b80b495 100644 --- a/Source/dom/models/Flow.js +++ b/Source/dom/models/Flow.js @@ -82,10 +82,10 @@ Flow.define('target', { enumerable: false, exportable: false, get() { - const target = this._object.destinationFrame() - if (target == BackTarget) { + if (this.targetId == BackTarget) { return BackTarget } + const target = this._object.destinationFrame() return wrapObject(target) }, set(target) { diff --git a/Source/dom/models/ImageData.js b/Source/dom/models/ImageData.js index 7df6d197d..2c0e78e1b 100644 --- a/Source/dom/models/ImageData.js +++ b/Source/dom/models/ImageData.js @@ -80,6 +80,15 @@ ImageData.define('nsdata', { }, }) +ImageData.define('base64', { + get() { + if (!this.nsdata) { + return null + } + return String(this.nsdata.base64EncodedStringWithOptions(0)) + }, +}) + ImageData.define('size', { /** * The size of the image. diff --git a/Source/dom/models/Override.js b/Source/dom/models/Override.js index 28a684817..b511d49c4 100644 --- a/Source/dom/models/Override.js +++ b/Source/dom/models/Override.js @@ -4,6 +4,7 @@ import { Factory } from '../Factory' import { ImageData } from './ImageData' import { wrapNativeObject } from '../wrapNativeObject' import { Rectangle } from './Rectangle' +import { Color } from '../style/Color' export class Override extends WrappedObject { // Returns any override directly set on the symbol instance or null if none is set or this is an override point on a symbol source @@ -26,9 +27,7 @@ export class Override extends WrappedObject { // out-of-date if the detached symbol hasn't yet updated. getResolvedValueOnDetachedSymbol() { var layer = this._object.layer() - return layer.valueForOverrideAttribute( - this.property - ) + return layer.valueForOverrideAttribute(this.property) } // Returns the value the override point will have if there is no override set on this instance. @@ -69,6 +68,27 @@ export class Override extends WrappedObject { getFrame() { return new Rectangle(this._object.layer().frame().rect()) } + + wrapNativeOverrideValue(value) { + if (this.property === 'image') { + return ImageData.fromNative(value) + } + if (this.colorOverride) { + if (typeof value === 'string') { + return value + } + return Color.from(value).toString() + } + if (value !== null && value.isKindOfClass_(NSDictionary.class())) { + // Map dictionary overrides into a javascript dictionary + var map = {} + Object.keys(value).forEach((name) => { + map[name] = value[name] + }) + return map + } + return String(value) + } } Override.type = Types.Override Override[DefinedPropertiesKey] = { ...WrappedObject[DefinedPropertiesKey] } @@ -110,24 +130,24 @@ Override.define('symbolOverride', { }, }) +Override.define('colorOverride', { + get() { + return ( + this.property && + (this.property === 'textColor' || + this.property === 'fillColor' || + this.property.startsWith('color:')) + ) + }, +}) + Override.define('value', { get() { var value = this.getValueSetOnInstance() if (!value) { value = this.getResolvedValueOnDetachedSymbol() } - if (this.property === 'image') { - return ImageData.fromNative(value) - } - if (value !== null && value.isKindOfClass_(NSDictionary.class())) { - // Map dictionary overrides into a javascript dictionary - var map = {} - Object.keys(value).forEach((name) => { - map[name] = value[name] - }) - return map - } - return String(value) + return this.wrapNativeOverrideValue(value) }, set(value) { // __symbolInstance is set when building the Override @@ -144,6 +164,15 @@ Override.define('isDefault', { }, }) +Override.define('defaultValue', { + exportable: false, + importable: false, + enumerable: false, + get() { + return this.wrapNativeOverrideValue(this.getDefaultValue()) + }, +}) + Override.define('editable', { get() { var master @@ -185,7 +214,7 @@ Override.define('selected', { get() { let page = this.getOwningPage() if (!page) { - return false + return false } let item = this.selectionItem() if (page.selection().isItemSelected(item)) { diff --git a/Source/dom/models/StackLayout.js b/Source/dom/models/StackLayout.js new file mode 100644 index 000000000..887063613 --- /dev/null +++ b/Source/dom/models/StackLayout.js @@ -0,0 +1,278 @@ +import { DefinedPropertiesKey, WrappedObject } from '../WrappedObject' +import { wrapNativeObject } from '../wrapNativeObject' +import { Types } from '../enums' +import { Factory } from '../Factory' +import { FlexSizing } from '../layers/Layer' + +export const StackLayoutDirection = Object.freeze({ + // Lay out the stack horizontally. + Row: 0, + // Lay out the stack vertically. + Column: 1, +}) + +export const StackLayoutJustifyContent = Object.freeze({ + // Lay out from the start of the stack (e.g. left or top). + Start: 0, + // Center items within the stack. + Center: 1, + // Lay out from the start of the stack (e.g. right or bottom). + End: 2, + // Add spacing *between* the items so the stack is filled. `space-between` in CSS. + Between: 3, + // Add spacing either side of every item so the stack is filled. Visually the spacing at start + // and end of the stack is half of the other spaces, because there's only a single item to add + // spacing to. + // `space-around` in CSS. + Around: 4, + // Add spacing between every item *and* at the start and end of the stack. `space-evenly` in CSS. + Evenly: 5, +}) + +export const StackLayoutAlignItems = Object.freeze({ + // Align to the start of the cross-axis (e.g. left or top). + Start: 0, + // Align to the center of the cross-axis. + Center: 1, + // Align to the end of the cross-axis (e.g. right or bottom). + End: 2, + /// For an individual stack item, use the default alignment inherited from the stack. + None: 5, +}) + +export class StackLayout extends WrappedObject { + constructor(stackLayout = {}) { + if (!stackLayout.sketchObject) { + stackLayout.sketchObject = MSFlexGroupLayout.new() + } + super(stackLayout) + } + + adjustParentGroupSizing() { + if (!this._object.parentObject()) { + return + } + const parent = wrapNativeObject(this._object.parentObject()) + switch (this.direction) { + case StackLayout.Direction.Row: + parent.horizontalSizing = this.isAutomaticallySpaced + ? FlexSizing.Fixed + : FlexSizing.Fit + break + case StackLayout.Direction.Column: + parent.verticalSizing = this.isAutomaticallySpaced + ? FlexSizing.Fixed + : FlexSizing.Fit + break + } + } +} + +StackLayout.define('direction', { + get() { + return Number(this._object.flexDirection()) + }, + set(direction) { + if (Number.isInteger(direction)) { + this._object.setFlexDirection(direction) + } + }, +}) + +StackLayout.define('justifyContent', { + get() { + return Number(this._object.justifyContent()) + }, + set(justifyContent) { + if (Number.isInteger(justifyContent)) { + this._object.setJustifyContent(justifyContent) + } + }, +}) + +StackLayout.define('isAutomaticallySpaced', { + exportable: false, + importable: false, + enumerable: false, + get() { + return [ + StackLayout.JustifyContent.Around, + StackLayout.JustifyContent.Between, + StackLayout.JustifyContent.Evenly, + ].includes(this.justifyContent) + }, +}) + +StackLayout.define('alignItems', { + get() { + return Number(this._object.alignItems()) + }, + set(alignItems) { + if (Number.isInteger(alignItems)) { + this._object.setAlignItems(alignItems) + } + }, +}) + +StackLayout.define('gap', { + get() { + return Number(this._object.allGuttersGap()) + }, + set(gap) { + if (Number.isInteger(gap)) { + this._object.setAllGuttersGap(gap) + } + }, +}) + +const PaddingSelection = Object.freeze({ + // Uniform padding: same value for all edges + Uniform: 0, + // Paired padding: same value for each axis (vertical/horizontal) + Paired: 1, + // Individual padding values for each edge + Individual: 2, +}) +const inferPaddingSelection = (padding) => { + if (!padding) { + return undefined + } + if (typeof padding === 'number') { + return PaddingSelection.Uniform + } + if (padding.vertical || padding.horizontal) { + return PaddingSelection.Paired + } + if (padding.top || padding.bottom || padding.left || padding.right) { + return PaddingSelection.Individual + } + return undefined +} + +StackLayout.define('padding', { + get() { + const host = this._object.parentObject() + if (!host) { + return undefined + } + const nativePaddingSelection = Number(host.paddingSelection()) + switch (nativePaddingSelection) { + case PaddingSelection.Uniform: + return Number(host.topPadding()) + case PaddingSelection.Paired: + return { + vertical: Number(host.topPadding()), + horizontal: Number(host.leftPadding()), + } + case PaddingSelection.Individual: + return { + top: Number(host.topPadding()), + bottom: Number(host.bottomPadding()), + left: Number(host.leftPadding()), + right: Number(host.rightPadding()), + } + default: + return undefined + } + }, + set(padding) { + const host = this._object.parentObject() + if (!host) { + return + } + + switch (inferPaddingSelection(padding)) { + case PaddingSelection.Uniform: + host.setPaddingSelection(PaddingSelection.Uniform) + host.setTopPadding(padding) + break + case PaddingSelection.Paired: + host.setPaddingSelection(PaddingSelection.Paired) + if (typeof padding.vertical === 'number') { + host.setTopPadding(padding.vertical) + } + if (typeof padding.horizontal === 'number') { + host.setLeftPadding(padding.horizontal) + } + break + case PaddingSelection.Individual: + host.setPaddingSelection(PaddingSelection.Individual) + if (typeof padding.top === 'number') { + host.setTopPadding(padding.top) + } + if (typeof padding.bottom === 'number') { + host.setBottomPadding(padding.bottom) + } + if (typeof padding.left === 'number') { + host.setLeftPadding(padding.left) + } + if (typeof padding.right === 'number') { + host.setRightPadding(padding.right) + } + break + default: + // Reset the padding + host.setPaddingSelection(PaddingSelection.Uniform) + host.setTopPadding(0) + break + } + }, +}) + +StackLayout.Direction = StackLayoutDirection +StackLayout.JustifyContent = StackLayoutJustifyContent +StackLayout.AlignItems = StackLayoutAlignItems + +StackLayout.type = Types.StackLayout +StackLayout[DefinedPropertiesKey] = { ...WrappedObject[DefinedPropertiesKey] } +Factory.registerClass(StackLayout, MSFlexGroupLayout) + +delete StackLayout[DefinedPropertiesKey].id + +export function defineStackItemLayerProperties(Layer) { + Layer.define('ignoresStackLayout', { + get() { + if (!this._object.flexItemCreatedIfNeeded) { + return undefined + } + const flexItem = this._object.flexItemCreatedIfNeeded() + if (!flexItem) { + return undefined + } + return Boolean(flexItem.ignoreLayout()) + }, + set(newValue) { + if (!this._object || !this._object.flexItemCreatedIfNeeded) { + return + } + const flexItem = this._object.flexItemCreatedIfNeeded() + if (!flexItem) { + return + } + flexItem.setIgnoreLayout(newValue) + }, + }) + + Layer.define('preservesSpaceInStackLayoutWhenHidden', { + get() { + if (!this._object.flexItemCreatedIfNeeded) { + return undefined + } + const flexItem = this._object.flexItemCreatedIfNeeded() + if (!flexItem) { + return undefined + } + return Boolean(flexItem.preserveSpaceWhenHidden()) + }, + set(newValue) { + if (!this._object || !this._object.flexItemCreatedIfNeeded) { + return + } + const flexItem = this._object.flexItemCreatedIfNeeded() + if (!flexItem) { + return + } + flexItem.setPreserveSpaceWhenHidden(newValue) + }, + }) +} diff --git a/Source/dom/models/__tests__/CurvePoint.test.js b/Source/dom/models/__tests__/CurvePoint.test.js index 71780c2c7..b28656d11 100644 --- a/Source/dom/models/__tests__/CurvePoint.test.js +++ b/Source/dom/models/__tests__/CurvePoint.test.js @@ -3,7 +3,6 @@ // Use ShapePath to indirectly instantiate CurvePoint as there's no public, // direct API. import { Document, ShapePath } from '../..' -import { toArray } from 'util' test('should be able to log an CurvePoint', () => { const p = new ShapePath().points[0] @@ -34,34 +33,42 @@ test('should be able to modify a CurvePoint', () => { expect(p.point.toJSON()).toEqual({ x: 0.3, y: 0.4 }) }) -test('should be able to modify the corner radius of a rectangle\'s CurvePoint', () => { +test("should be able to modify the corner radius of a rectangle's CurvePoint", () => { const cornerRadius = 42 const rectangle = new ShapePath() rectangle.points[1].cornerRadius = cornerRadius - expect(toArray(rectangle.sketchObject.CSSAttributes()).join('')) - .toEqual('border-radius: 0 ' + cornerRadius + 'px 0 0;') + expect(rectangle.sketchObject.CSSAttributeString().split('\n')).toEqual([ + 'width: 100px;', + 'height: 100px;', + `border-radius: 0 ${cornerRadius}px 0 0;`, + ]) }) -test('should be able to modify the corner radius of a rectangle\'s first CurvePoint', () => { +test("should be able to modify the corner radius of a rectangle's first CurvePoint", () => { const cornerRadius = 42 const rectangle = new ShapePath() rectangle.points[0].cornerRadius = cornerRadius - expect(toArray(rectangle.sketchObject.CSSAttributes()).join('')) - .toEqual('border-radius: ' + cornerRadius + 'px 0 0 0;') + expect(rectangle.sketchObject.CSSAttributeString().split('\n')).toEqual([ + 'width: 100px;', + 'height: 100px;', + `border-radius: ${cornerRadius}px 0 0;`, + ]) }) // sketch-hq/SketchAPI#775, #39183. -test('should be able to modify the corner radius of every rectangle\'s CurvePoint', () => { +test("should be able to modify the corner radius of every rectangle's CurvePoint", () => { const cornerRadius = 42 const rectangle = new ShapePath() - rectangle.points.forEach(point => point.cornerRadius = cornerRadius) - expect(toArray(rectangle.sketchObject.CSSAttributes()).join('')) - .toEqual('border-radius: ' + cornerRadius + 'px;') - expect(rectangle.sketchObject.cornerRadius()) - .toEqual(cornerRadius) + rectangle.points.forEach((point) => (point.cornerRadius = cornerRadius)) + expect(rectangle.sketchObject.CSSAttributeString().split('\n')).toEqual([ + 'width: 100px;', + 'height: 100px;', + `border-radius: ${cornerRadius}px;`, + ]) + expect(rectangle.sketchObject.cornerRadius()).toEqual(cornerRadius) }) test('should be able to tell if a point is selected)', () => { @@ -69,7 +76,7 @@ test('should be able to tell if a point is selected)', () => { const shape = new ShapePath({ parent: document.pages[0], }) - + // no point selected expect(shape.points[0].isSelected()).toBe(false) @@ -83,15 +90,15 @@ test('should be able to tell if a point is selected)', () => { //still no selection expect(shape.points[0].isSelected()).toBe(false) - //select the first point - document.sketchObject + //select the first point + document.sketchObject .eventHandlerManager() .currentHandler() .pathController() .selectNext(null) - //now selected true + //now selected true expect(shape.points[0].isSelected()).toBe(true) - + document.close() }) diff --git a/Source/dom/models/__tests__/Flow.test.js b/Source/dom/models/__tests__/Flow.test.js index d502c453f..a08a9627f 100644 --- a/Source/dom/models/__tests__/Flow.test.js +++ b/Source/dom/models/__tests__/Flow.test.js @@ -146,6 +146,7 @@ test('should create a back action', () => { animationType: 'slideFromRight', maintainScrollPosition: false, }) + expect(rect.flow.target).toBe(Flow.BackTarget) expect(rect.flow.isBackAction()).toBe(true) }) diff --git a/Source/dom/models/__tests__/ImageData.test.js b/Source/dom/models/__tests__/ImageData.test.js index c1ce7f3c6..cfcdfce7d 100644 --- a/Source/dom/models/__tests__/ImageData.test.js +++ b/Source/dom/models/__tests__/ImageData.test.js @@ -40,3 +40,26 @@ test('should return image size regardless of scale', (_context, document) => { expect(image.image.size.width).toBe(50) expect(image.image.size.height).toBe(50) }) + +test('should return base64-encoded image data', (_context, document) => { + const page = document.selectedPage + + const firstImage = new Image({ + parent: page, + image: { + base64: base64Image, + }, + }) + + const exportedBase64String = firstImage.image.base64 + expect(typeof exportedBase64String).toBe('string') + + const secondImage = new Image({ + parent: page, + image: { + base64: exportedBase64String, + }, + }) + expect(secondImage.image.size.width).toBe(firstImage.image.size.width) + expect(secondImage.image.size.height).toBe(firstImage.image.size.height) +}) diff --git a/Source/dom/models/__tests__/ImportableObject.test.js b/Source/dom/models/__tests__/ImportableObject.test.js index 8db41ec32..0dfc7985e 100644 --- a/Source/dom/models/__tests__/ImportableObject.test.js +++ b/Source/dom/models/__tests__/ImportableObject.test.js @@ -24,7 +24,7 @@ test('should import a symbol from a lib', () => { parent: artboard, }) // eslint-disable-next-line - const master = SymbolMaster.fromArtboard(artboard) + const master = SymbolMaster.fromFrame(artboard) return new Promise((resolve, reject) => { document.save( `${testOutputPath}/sketch-api-unit-tests-importable-objects.sketch`, diff --git a/Source/dom/models/__tests__/Library.test.js b/Source/dom/models/__tests__/Library.test.js index 040629666..262f915b4 100644 --- a/Source/dom/models/__tests__/Library.test.js +++ b/Source/dom/models/__tests__/Library.test.js @@ -21,7 +21,7 @@ function createLibrary(testOutputPath = outputPath()) { text: 'Test value', parent: artboard, }) - SymbolMaster.fromArtboard(artboard) + SymbolMaster.fromFrame(artboard) document.save( `${testOutputPath}/sketch-api-unit-tests-library.sketch`, diff --git a/Source/dom/models/__tests__/Override.test.js b/Source/dom/models/__tests__/Override.test.js index 53c2e56cf..f0a246b08 100644 --- a/Source/dom/models/__tests__/Override.test.js +++ b/Source/dom/models/__tests__/Override.test.js @@ -1,6 +1,6 @@ /* globals expect, test */ /* eslint-disable no-param-reassign */ -import { SymbolMaster, Text, Artboard, Image } from '../..' +import { SymbolMaster, Text, Artboard, Image, ShapePath } from '../..' // using a base64 image cause I'm not sure where and how to keep assets that would work with both local and jenkins tests const base64Image = @@ -21,7 +21,7 @@ function createSymbolMaster(document) { // build the symbol master return { - master: SymbolMaster.fromArtboard(artboard), + master: SymbolMaster.fromFrame(artboard), text, artboard, } @@ -35,7 +35,7 @@ test('should be able to set overrides', (_context, document) => { expect(instance.overrides.length).toBe(10) // find the override point for the text layer's string value - const override = instance.overrides.find(o => o.property === 'stringValue') + const override = instance.overrides.find((o) => o.property === 'stringValue') expect(override.isDefault).toBe(true) // check that an override can be logged log(override) @@ -50,6 +50,7 @@ test('should be able to set overrides', (_context, document) => { path: text.id, property: 'stringValue', symbolOverride: false, + colorOverride: false, value: 'overridden', isDefault: false, editable: true, @@ -58,7 +59,9 @@ test('should be able to set overrides', (_context, document) => { } // find the same override after being modified - const overrideAfter = instance.overrides.find(o => o.property === 'stringValue') + const overrideAfter = instance.overrides.find( + (o) => o.property === 'stringValue' + ) result.affectedLayer = overrideAfter.affectedLayer.toJSON() expect(overrideAfter.toJSON()).toEqual(result) }) @@ -78,7 +81,7 @@ test('should change a nested symbol', (_context, document) => { const nestedInstance = nestedMaster.createNewInstance() artboard.layers = [nestedInstance, text2] - const master = SymbolMaster.fromArtboard(artboard) + const master = SymbolMaster.fromFrame(artboard) const instance = master.createNewInstance() @@ -87,7 +90,9 @@ test('should change a nested symbol', (_context, document) => { expect(instance.overrides.length).toBe(25) // find the symbol override point - const symbolOverrides = instance.overrides.filter(o => o.property === 'symbolID') + const symbolOverrides = instance.overrides.filter( + (o) => o.property === 'symbolID' + ) expect(symbolOverrides.length).toBe(1) const override = symbolOverrides[0] @@ -100,6 +105,7 @@ test('should change a nested symbol', (_context, document) => { property: 'symbolID', affectedLayer: nestedInstance.toJSON(), symbolOverride: true, + colorOverride: false, value: nestedMaster2.symbolId, isDefault: false, editable: true, @@ -107,8 +113,12 @@ test('should change a nested symbol', (_context, document) => { } delete result.affectedLayer.overrides delete result.affectedLayer.selected + delete result.affectedLayer.ignoresStackLayout + delete result.affectedLayer.preservesSpaceInStackLayoutWhenHidden - const symbolOverridesAfter = instance.overrides.filter(o => o.property === 'symbolID') + const symbolOverridesAfter = instance.overrides.filter( + (o) => o.property === 'symbolID' + ) expect(symbolOverridesAfter.length).toBe(1) const overrideAfter = symbolOverridesAfter[0] @@ -130,7 +140,7 @@ test('should handle image override', (_context, document) => { }) // build the symbol master - const master = SymbolMaster.fromArtboard(artboard) + const master = SymbolMaster.fromFrame(artboard) const instance = master.createNewInstance() // add the instance to the page @@ -138,16 +148,20 @@ test('should handle image override', (_context, document) => { expect(instance.overrides.length).toBe(6) // check image resize behavior - const imageResizeOverride = instance.overrides.find(o => o.property === 'imageResizeBehavior') + const imageResizeOverride = instance.overrides.find( + (o) => o.property === 'imageResizeBehavior' + ) expect(imageResizeOverride.isDefault).toBe(true) expect(imageResizeOverride.value).toBe('Original') imageResizeOverride.value = 1 - const imageResizeOverrideAfter = instance.overrides.find(o => o.property === 'imageResizeBehavior') + const imageResizeOverrideAfter = instance.overrides.find( + (o) => o.property === 'imageResizeBehavior' + ) expect(imageResizeOverrideAfter.isDefault).toBe(false) expect(imageResizeOverrideAfter.value).toBe('1') // check image - const imageOverride = instance.overrides.find(o => o.property === 'image') + const imageOverride = instance.overrides.find((o) => o.property === 'image') expect(imageOverride.isDefault).toBe(true) expect(imageOverride.value.type).toBe('ImageData') @@ -155,7 +169,9 @@ test('should handle image override', (_context, document) => { base64: base64Image2, } - const imageOverrideAfter = instance.overrides.find(o => o.property === 'image') + const imageOverrideAfter = instance.overrides.find( + (o) => o.property === 'image' + ) expect(imageOverrideAfter.property).toBe('image') expect(imageOverrideAfter.isDefault).toBe(false) expect(imageOverrideAfter.value.type).toBe('ImageData') @@ -177,7 +193,7 @@ test('should be able to select an override', (_context, document) => { document.selectedPage.layers = document.selectedPage.layers.concat(instance) // find the override point for the text layer's string value - const override = instance.overrides.find(o => o.property === 'stringValue') + const override = instance.overrides.find((o) => o.property === 'stringValue') expect(override.selected).toBe(false) expect(instance.selected).toBe(false) @@ -185,7 +201,9 @@ test('should be able to select an override', (_context, document) => { // toggle selected on the text layer override.selected = true - const overrideAfter = instance.overrides.find(o => o.property === 'stringValue') + const overrideAfter = instance.overrides.find( + (o) => o.property === 'stringValue' + ) expect(overrideAfter.selected).toBe(true) expect(instance.selected).toBe(false) @@ -193,7 +211,9 @@ test('should be able to select an override', (_context, document) => { // toggle selected again overrideAfter.selected = false - const overrideAfter2 = instance.overrides.find(o => o.property === 'stringValue') + const overrideAfter2 = instance.overrides.find( + (o) => o.property === 'stringValue' + ) expect(overrideAfter2.selected).toBe(false) expect(instance.selected).toBe(false) }) @@ -204,7 +224,7 @@ test('should be able to access the frame of an override', (_context, document) = document.selectedPage.layers = document.selectedPage.layers.concat(instance) // find the override point for the text layer's string value - const override = instance.overrides.find(o => o.property === 'stringValue') + const override = instance.overrides.find((o) => o.property === 'stringValue') // expects to be able to access the frame of the affected text layer expect(override.getFrame().toJSON()).toEqual({ @@ -214,3 +234,142 @@ test('should be able to access the frame of an override', (_context, document) = height: 14, }) }) + +test('should handle style color overrides', (_context, document) => { + const artboard = new Artboard({ + name: 'Test', + parent: document.selectedPage, + }) + // eslint-disable-next-line + const shape = new ShapePath({ + style: { + fills: [ + { + color: '#00aa00ff', + fillType: 'Color', + }, + ], + }, + parent: artboard, + }) + // build the symbol master + const master = SymbolMaster.fromFrame(artboard) + const instance = master.createNewInstance() + // add the instance to the page + document.selectedPage.layers.push(instance) + // find the shape fill color override point + const fillColorOverride = instance.overrides.find((o) => { + return ( + o.affectedLayer.id === shape.id && o.property.startsWith('color:fill') + ) + }) + + expect(fillColorOverride.colorOverride).toBe(true) + expect(fillColorOverride.isDefault).toBe(true) + expect(fillColorOverride.value).toBe('#00aa00ff') + + fillColorOverride.value = '#cafebabe' + + expect(fillColorOverride.isDefault).toBe(false) + expect(fillColorOverride.value).toBe('#cafebabe') +}) + +test('should handle text color overrides', (_context, document) => { + const artboard = new Artboard({ + name: 'Test', + parent: document.selectedPage, + }) + // eslint-disable-next-line + const text = new Text({ + text: 'Test value', + style: { + textColor: '#00aa00ff', + }, + parent: artboard, + }) + // build the symbol master + const master = SymbolMaster.fromFrame(artboard) + const instance = master.createNewInstance() + // add the instance to the page + document.selectedPage.layers.push(instance) + // find the text color override point + const textColorOverride = instance.overrides.find((o) => { + return o.property === 'textColor' + }) + + expect(textColorOverride.colorOverride).toBe(true) + expect(textColorOverride.isDefault).toBe(true) + expect(textColorOverride.value).toBe('#00aa00ff') + + textColorOverride.value = '#cafebabe' + + expect(textColorOverride.isDefault).toBe(false) + expect(textColorOverride.value).toBe('#cafebabe') +}) + +test('should handle tint color overrides', (_context, document) => { + const artboard = new Artboard({ + name: 'Test', + parent: document.selectedPage, + }) + // eslint-disable-next-line + const shape = new ShapePath({ + style: { + fills: [ + { + color: '#00aa00ff', + fillType: 'Color', + }, + ], + }, + parent: artboard, + }) + // build the symbol master + const master = SymbolMaster.fromFrame(artboard) + const instance = master.createNewInstance() + // add the instance to the page + document.selectedPage.layers.push(instance) + // enable a tint if the API is there + instance.style.fills = [ + { + color: '#deadbeef', + fillType: 'Color', + }, + ] + if (!instance.style.fills[0].sketchObject.setLayeringType) { + return + } + instance.style.fills[0].sketchObject.setLayeringType( + /* BCFillLayeringTypeTint */ 1 + ) + // find the tint color override point + const tintColorOverride = instance.overrides.find((o) => { + return o.property === 'fillColor' + }) + + expect(tintColorOverride.colorOverride).toBe(true) + expect(tintColorOverride.value).toBe('#deadbeef') + + tintColorOverride.value = '#cafebabe' + + expect(tintColorOverride.value).toBe('#cafebabe') +}) + +test('should provide the default value', (_context, document) => { + const { master } = createSymbolMaster(document) + const instance = master.createNewInstance() + document.selectedPage.layers = document.selectedPage.layers.concat(instance) + + // find the override point for the text layer's string value + const override = instance.overrides.find((o) => o.property === 'stringValue') + + expect(override.isDefault).toBe(true) + expect(override.defaultValue).toBe('Test value') + expect(override.value).toBe('Test value') + + override.value = 'overridden value' + + expect(override.isDefault).toBe(false) + expect(override.defaultValue).toBe('Test value') + expect(override.value).toBe('overridden value') +}) diff --git a/Source/dom/style/Blur.js b/Source/dom/style/Blur.js index 76fc6f8c2..06c9fab2c 100644 --- a/Source/dom/style/Blur.js +++ b/Source/dom/style/Blur.js @@ -1,6 +1,9 @@ import { WrappedObject, DefinedPropertiesKey } from '../WrappedObject' +import { isWrappedObject } from '../utils' +import { isNativeObject } from 'util' import { Point } from '../models/Point' import { Types } from '../enums' +import { Gradient } from './Gradient' const BlurTypeMap = { Gaussian: 0, @@ -22,30 +25,54 @@ const DEFAULT_BLUR = { radius: 10, enabled: false, blurType: BlurType.Gaussian, + saturation: 1, } export class Blur extends WrappedObject { - static updateNative(s, blur) { + static toNative(value) { + if (isNativeObject(value)) { + return value + } + if (isWrappedObject(value)) { + return value.sketchObject + } + const nativeBlur = MSStyleBlur.new() + this.updateNative(nativeBlur, value) + return nativeBlur + } + + static updateNative(nativeBlur, blur) { const blurWithDefault = Object.assign({}, DEFAULT_BLUR, blur) if (typeof blurWithDefault.center !== 'undefined') { - s.setCenter( + nativeBlur.setCenter( CGPointMake(blurWithDefault.center.x, blurWithDefault.center.y) ) } if (typeof blurWithDefault.motionAngle !== 'undefined') { - s.setMotionAngle(blurWithDefault.motionAngle) + nativeBlur.setMotionAngle(blurWithDefault.motionAngle) } if (typeof blurWithDefault.radius !== 'undefined') { - s.setRadius(blurWithDefault.radius) + nativeBlur.setRadius(blurWithDefault.radius) + } + if (typeof blurWithDefault.saturation !== 'undefined') { + nativeBlur.setSaturation(blurWithDefault.saturation) } if (typeof blurWithDefault.blurType !== 'undefined') { const blurType = BlurTypeMap[blurWithDefault.blurType] - s.setType( + nativeBlur.setType( typeof blurType !== 'undefined' ? blurType : blurWithDefault.blurType ) } if (typeof blurWithDefault.enabled !== 'undefined') { - s.isEnabled = blurWithDefault.enabled // eslint-disable-line + nativeBlur.isEnabled = blurWithDefault.enabled // eslint-disable-line + } + if (typeof blurWithDefault.progressive !== 'undefined') { + nativeBlur.setIsProgressive(blurWithDefault.progressive) + } + if (typeof blurWithDefault.gradient !== 'undefined') { + nativeBlur.setGradient( + Gradient.from(blurWithDefault.gradient).sketchObject + ) } } } @@ -92,6 +119,16 @@ Blur.define('radius', { }, }) +Blur.define('saturation', { + get() { + return Number(this._object.saturation()) + }, + set(saturation) { + saturation = Math.max(0, Math.min(2, Number(saturation))) // Clamp to [0, 2] + this._object.setSaturation(saturation) + }, +}) + Blur.define('enabled', { get() { return !!this._object.isEnabled() @@ -114,3 +151,25 @@ Blur.define('blurType', { this._object.setType(typeof blurType !== 'undefined' ? blurType : type) }, }) + +Blur.define('progressive', { + get() { + return Boolean(this._object.isProgressive()) + }, + set(progressive) { + this._object.setIsProgressive(progressive) + }, +}) + +Blur.define('gradient', { + get() { + const gradient = this._object.gradient() + if (gradient) { + return Gradient.from(gradient) + } + return undefined + }, + set(gradient) { + this._object.setGradient(Gradient.from(gradient).sketchObject) + }, +}) diff --git a/Source/dom/style/Border.js b/Source/dom/style/Border.js index 623d68ac1..05c44a3b1 100644 --- a/Source/dom/style/Border.js +++ b/Source/dom/style/Border.js @@ -5,6 +5,7 @@ import { Gradient } from './Gradient' import { FillTypeMap } from './Fill' import { Types } from '../enums' import { isWrappedObject } from '../utils' +import { BlendingModeMap } from '../models/BlendingMode' const BorderPositionMap = { Center: 0, @@ -62,6 +63,14 @@ export class Border extends WrappedObject { } else { border.isEnabled = value.enabled } + + if (value.blendingMode) { + const blendingMode = BlendingModeMap[value.blendingMode] + if (typeof blendingMode !== 'undefined') { + border.contextSettings().setBlendMode(blendingMode) + } + } + return border } } @@ -124,9 +133,8 @@ Border.define('gradient', { get() { return Gradient.from(this._object.gradient()) }, - set(_gradient) { - const gradient = Gradient.from(_gradient) - this._object.gradient = gradient + set(gradient) { + this._object.setGradient(Gradient.from(gradient).sketchObject) }, }) @@ -147,3 +155,20 @@ Border.define('enabled', { this._object.isEnabled = enabled }, }) + +Border.define('blendingMode', { + get() { + const mode = this._object.contextSettings().blendMode() + return ( + Object.keys(BlendingModeMap).find( + (key) => BlendingModeMap[key] === mode + ) || mode + ) + }, + set(mode) { + const blendingMode = BlendingModeMap[mode] + this._object + .contextSettings() + .setBlendMode(typeof blendingMode !== 'undefined' ? blendingMode : mode) + }, +}) diff --git a/Source/dom/style/Corners.js b/Source/dom/style/Corners.js new file mode 100644 index 000000000..84849f354 --- /dev/null +++ b/Source/dom/style/Corners.js @@ -0,0 +1,133 @@ +import { DefinedPropertiesKey, WrappedObject } from '../WrappedObject' +import { Factory } from '../Factory' +import { toArray } from 'util' + +export const CornerStyle = Object.freeze({ + Auto: -1, + Rounded: 0, + Smooth: 1, + Angled: 2, + InsideSquare: 3, + InsideArc: 4, +}) + +export class Corners extends WrappedObject { + constructor(corners = {}) { + if (!corners.sketchObject) { + corners.sketchObject = MSStyleCorners.alloc().init() + } + super(corners) + } + + radiusAt(idx) { + const radii = this.radii + if (radii.length == 0 || idx < 0) { + return undefined + } + return Number(radii[idx % radii.length]) + } + + _applyConcentricity() { + const parentStyle = this._object?.parentObject?.() + const parentLayer = parentStyle?.parentObject?.() + parentLayer?.applyConcentricity?.() + } + + _applyConcentricCornersOnChildren() { + const parentStyle = this._object?.parentObject?.() + const parentLayer = parentStyle?.parentObject?.() + parentLayer?.applyConcentricCornersOnChildren?.() + } +} + +Corners.define('style', { + get() { + if (this.concentric) { + return Corners.Style.Auto + } + return Number(this._object.style()) + }, + set(newCornerStyle) { + if (this.isImmutable()) { + return + } + if (Number.isInteger(newCornerStyle)) { + if (newCornerStyle === Corners.Style.Auto) { + this.concentric = true + } else { + this.concentric = false + this._object.setStyle(newCornerStyle) + } + } else { + console.warn( + 'Invalid value for Corners.style. Expected one of Style.CornerStyle values.' + ) + } + }, +}) + +Corners.define('radii', { + get() { + return toArray(this._object.radii()).map((r) => { + if (r.doubleValue) { + return Number(r.doubleValue()) + } + return Number(r) + }) + }, + set(newRadii = []) { + if (this.isImmutable()) { + return + } + if (Array.isArray(newRadii)) { + const numericRadii = newRadii.flatMap((r) => { + if (typeof r === 'number') { + return [r] + } + if (typeof r === 'string') { + return [parseFloat(r)] + } + return [] + }) + this._object.setRadii(numericRadii) + this.concentric = false + } else if (typeof newRadii === 'number') { + this.concentric = false + this._object.setRadii([newRadii]) + } else { + console.warn( + 'Invalid value for Corners.radii. Expected an array of numbers or a single number.' + ) + } + this._applyConcentricCornersOnChildren() + }, +}) + +Corners.define('hasRadii', { + get() { + return this.radii.some((r) => r !== 0) + }, +}) + +Corners.define('concentric', { + get() { + return !!this._object.prefersConcentric?.() + }, + set(prefersConcentric) { + if (this.isImmutable()) { + return + } + if (typeof prefersConcentric === 'boolean') { + this._object.setPrefersConcentric?.(Number(prefersConcentric)) + this._applyConcentricity() + } else { + console.warn('Invalid value for Corners.concentric. Expected a boolean.') + } + }, +}) + +Corners.Style = CornerStyle +Corners[DefinedPropertiesKey] = { ...WrappedObject[DefinedPropertiesKey] } +Factory.registerClass(Corners, MSStyleCorners) + +delete Corners[DefinedPropertiesKey].id diff --git a/Source/dom/style/Fill.js b/Source/dom/style/Fill.js index c7efe1eeb..8759a4945 100644 --- a/Source/dom/style/Fill.js +++ b/Source/dom/style/Fill.js @@ -5,6 +5,7 @@ import { Gradient } from './Gradient' import { Types } from '../enums' import { ImageData } from '../models/ImageData' import { isWrappedObject } from '../utils' +import { BlendingModeMap } from '../models/BlendingMode' export const FillTypeMap = { Color: 0, // A solid fill/border. @@ -86,6 +87,14 @@ export class Fill extends WrappedObject { } else { fill.isEnabled = value.enabled } + + if (value.blendingMode) { + const blendingMode = BlendingModeMap[value.blendingMode] + if (typeof blendingMode !== 'undefined') { + fill.contextSettings().setBlendMode(blendingMode) + } + } + return fill } } @@ -146,9 +155,8 @@ Fill.define('gradient', { get() { return Gradient.from(this._object.gradient()) }, - set(_gradient) { - const gradient = Gradient.from(_gradient) - this._object.gradient = gradient + set(gradient) { + this._object.setGradient(Gradient.from(gradient).sketchObject) }, }) @@ -196,3 +204,20 @@ Fill.define('enabled', { this._object.isEnabled = enabled }, }) + +Fill.define('blendingMode', { + get() { + const mode = this._object.contextSettings().blendMode() + return ( + Object.keys(BlendingModeMap).find( + (key) => BlendingModeMap[key] === mode + ) || mode + ) + }, + set(mode) { + const blendingMode = BlendingModeMap[mode] + this._object + .contextSettings() + .setBlendMode(typeof blendingMode !== 'undefined' ? blendingMode : mode) + }, +}) diff --git a/Source/dom/style/GradientStop.js b/Source/dom/style/GradientStop.js index 0e66b7006..363615a32 100644 --- a/Source/dom/style/GradientStop.js +++ b/Source/dom/style/GradientStop.js @@ -61,3 +61,12 @@ GradientStop.define('color', { this._object.color = color.toMSColor() }, }) + +GradientStop.define('alpha', { + exportable: false, + enumerable: false, + importable: false, + get() { + return this._object.color().alpha() + }, +}) diff --git a/Source/dom/style/Shadow.js b/Source/dom/style/Shadow.js index b35b656b3..db10ad0e0 100644 --- a/Source/dom/style/Shadow.js +++ b/Source/dom/style/Shadow.js @@ -3,6 +3,7 @@ import { Color, colorToString } from './Color' import { WrappedObject, DefinedPropertiesKey } from '../WrappedObject' import { Types } from '../enums' import { isWrappedObject } from '../utils' +import { BlendingModeMap } from '../models/BlendingMode' export class Shadow extends WrappedObject { static toNative(nativeClass, value) { @@ -38,6 +39,14 @@ export class Shadow extends WrappedObject { if (typeof value.isInnerShadow !== 'undefined') { shadow.isInnerShadow = Boolean(value.isInnerShadow) } + + if (value.blendingMode) { + const blendingMode = BlendingModeMap[value.blendingMode] + if (typeof blendingMode !== 'undefined') { + shadow.contextSettings().setBlendMode(blendingMode) + } + } + return shadow } } @@ -117,3 +126,20 @@ Shadow.define('isInnerShadow', { this._object.setIsInnerShadow(value) }, }) + +Shadow.define('blendingMode', { + get() { + const mode = this._object.contextSettings().blendMode() + return ( + Object.keys(BlendingModeMap).find( + (key) => BlendingModeMap[key] === mode + ) || mode + ) + }, + set(mode) { + const blendingMode = BlendingModeMap[mode] + this._object + .contextSettings() + .setBlendMode(typeof blendingMode !== 'undefined' ? blendingMode : mode) + }, +}) diff --git a/Source/dom/style/Style.js b/Source/dom/style/Style.js index 65c18046d..109a47189 100644 --- a/Source/dom/style/Style.js +++ b/Source/dom/style/Style.js @@ -3,7 +3,7 @@ import { WrappedObject, DefinedPropertiesKey } from '../WrappedObject' import { Factory } from '../Factory' import { wrapObject } from '../wrapNativeObject' import { Types } from '../enums' -import { GradientType } from './Gradient' +import { Gradient, GradientType } from './Gradient' import { colorFromString, colorToString } from './Color' import { Shadow } from './Shadow' import { BorderOptions, Arrowhead, LineEnd, LineJoin } from './BorderOptions' @@ -11,44 +11,8 @@ import { Blur, BlurType } from './Blur' import { Fill, FillType, PatternFillType } from './Fill' import { Border, BorderPosition } from './Border' import { defineTextStyleProperties } from './Text' - -const BlendingModeMap = { - Normal: 0, - Darken: 1, - Multiply: 2, - ColorBurn: 3, - Lighten: 4, - Screen: 5, - ColorDodge: 6, - Overlay: 7, - SoftLight: 8, - HardLight: 9, - Difference: 10, - Exclusion: 11, - Hue: 12, - Saturation: 13, - Color: 14, - Luminosity: 15, -} - -const BlendingMode = { - Normal: 'Normal', - Darken: 'Darken', - Multiply: 'Multiply', - ColorBurn: 'ColorBurn', - Lighten: 'Lighten', - Screen: 'Screen', - ColorDodge: 'ColorDodge', - Overlay: 'Overlay', - SoftLight: 'SoftLight', - HardLight: 'HardLight', - Difference: 'Difference', - Exclusion: 'Exclusion', - Hue: 'Hue', - Saturation: 'Saturation', - Color: 'Color', - Luminosity: 'Luminosity', -} +import { BlendingMode, BlendingModeMap } from '../models/BlendingMode' +import { Corners } from './Corners' const DEFAULT_STYLE = { fills: [], @@ -160,6 +124,30 @@ Style.define('opacity', { }, }) +Style.define('progressiveAlpha', { + get() { + if (!this._object.contextSettings().isProgressive()) { + return undefined + } + let nativeGradient = this._object.contextSettings().gradient() + if (!nativeGradient) { + return undefined + } + return Gradient.from(nativeGradient) + }, + set(newGradient) { + if (newGradient) { + this._object.contextSettings().setIsProgressive(true) + this._object + .contextSettings() + .setGradient(Gradient.from(newGradient).sketchObject) + } else { + this._object.contextSettings().setIsProgressive(false) + this._object.contextSettings().setGradient(null) + } + }, +}) + Style.BlendingMode = BlendingMode Style.define('blendingMode', { get() { @@ -215,13 +203,64 @@ Style.define('blurs', { }, }) +const FillLayeringType = Object.freeze({ + // Shape fills, text color and group/artboard backgrounds + Regular: 0, + // Group tints specifically + Tint: 1, + // Frames as overlay can draw a backdrop _around_ themselves + Backdrop: 2, +}) + +Style.define('tint', { + get() { + const fills = toArray(this._object.fills()) + // There could be at most one group tint fill + return fills.map(Fill.fromNative.bind(Fill)).find((fill) => { + return fill.sketchObject.layeringType() === FillLayeringType.Tint + }) + }, + set(newTint) { + if (this.isImmutable()) { + return + } + // See if we have a tint fill we can update or remove + const existingTint = this.tint + if (existingTint) { + if (newTint) { + existingTint.update(newTint) + } else { + const nativeFillsWithoutTint = toArray( + this._object.fills() ?? [] + ).filter((nativeFill) => { + return nativeFill !== existingTint.sketchObject + }) + this._object.setFills(nativeFillsWithoutTint) + } + return + } + + const tint = Fill.fromNative( + Fill.toNative({ + ...newTint, + fillType: FillType.Color, + }) + ) + tint.sketchObject.setLayeringType(FillLayeringType.Tint) + this.fills.push(tint) + }, +}) + Style.FillType = FillType Style.PatternFillType = PatternFillType Style.define('fills', { array: true, get() { const fills = toArray(this._object.fills()) - return fills.map(Fill.fromNative.bind(Fill)) + return fills.map(Fill.fromNative.bind(Fill)).filter((fill) => { + // Only return items that are regular fills. See `Style.tint` + return fill.sketchObject.layeringType() === FillLayeringType.Regular + }) }, set(values) { const objects = values.map(Fill.toNative.bind(Fill)) @@ -334,6 +373,29 @@ Style.define('styleType', { }, }) +Style.CornerStyle = Corners.Style +Style.define('corners', { + get() { + const nativeCorners = this._object.corners() + if (nativeCorners) { + return Corners.fromNative(nativeCorners) + } + + const corners = new Corners() + if (!this.isImmutable()) { + this._object.setCorners(corners.sketchObject) + } + return corners + }, + set(newCorners) { + if (this.isImmutable()) { + return + } + // Calling the getter to force a corners object to be created if needed + const existingCorners = this.corners + existingCorners.update(newCorners) + }, +}) // Map to values from the `MSStylePartType` enum in SketchModel. // Other values ommitted because they are not currently used. diff --git a/Source/dom/style/__tests__/Blur.test.js b/Source/dom/style/__tests__/Blur.test.js new file mode 100644 index 000000000..96025d8e0 --- /dev/null +++ b/Source/dom/style/__tests__/Blur.test.js @@ -0,0 +1,261 @@ +/* globals expect, test */ +import { Style } from '../..' + +test('should set blurs', () => { + const style = new Style() + expect(style.blurs.length).toBe(0) + + style.blurs = [ + { + radius: 10, + motionAngle: 45, + enabled: true, + blurType: Style.BlurType.Gaussian, + }, + ] + expect(style.blurs.length).toBe(1) + + const style2 = new Style({ + blurs: [ + { + radius: 20, + motionAngle: 90, + enabled: false, + blurType: Style.BlurType.Motion, + }, + ], + }) + expect(style2.blurs.length).toBe(1) +}) + +test('should get blurs', () => { + const style = new Style() + expect(style.blurs.length).toBe(0) + + style.blurs = [ + { + radius: 10, + center: { x: 0.5, y: 0.5 }, + enabled: true, + blurType: Style.BlurType.Gaussian, + }, + ] + + expect(style.blurs.length).toBe(1) + expect(style.blurs[0].radius).toBe(10) + expect(style.blurs[0].center).toEqual({ x: 0.5, y: 0.5 }) + expect(style.blurs[0].enabled).toBe(true) + expect(style.blurs[0].blurType).toBe(Style.BlurType.Gaussian) + + style.blurs = [] + expect(style.blurs.length).toBe(0) + + style.blurs.push({ + radius: 5, + motionAngle: 90, + blurType: Style.BlurType.Motion, + enabled: false, + }) + expect(style.blurs.length).toBe(1) + expect(style.blurs[0].radius).toBe(5) + expect(style.blurs[0].motionAngle).toBe(90) + expect(style.blurs[0].enabled).toBe(false) + expect(style.blurs[0].blurType).toBe(Style.BlurType.Motion) + + const style2 = new Style({ + blurs: [ + { + radius: 15, + motionAngle: 30, + enabled: true, + blurType: Style.BlurType.Zoom, + }, + ], + }) + expect(style2.blurs.length).toBe(1) + expect(style2.blurs[0].radius).toBe(15) + expect(style2.blurs[0].motionAngle).toBe(30) + expect(style2.blurs[0].enabled).toBe(true) + expect(style2.blurs[0].blurType).toBe(Style.BlurType.Zoom) +}) + +test('should get and set saturation', () => { + const style = new Style({ + blurs: [ + { + enabled: true, + blurType: Style.BlurType.Background, + saturation: 0.65, + }, + ], + }) + expect(style.blurs[0].saturation).toBe(0.65) + + style.blurs[0].saturation = 1.5 + expect(style.blurs[0].saturation).toBe(1.5) + + style.blurs[0].saturation = -12 + expect(style.blurs[0].saturation).toBe(0) // clamped + + style.blurs[0].saturation = 100 + expect(style.blurs[0].saturation).toBe(2) // clamped +}) + +test('should set default saturation value', () => { + const style = new Style({ + blurs: [ + { + enabled: true, + blurType: Style.BlurType.Background, + }, + ], + }) + expect(style.blurs[0].saturation).toBe(1) +}) + +test('should set progressive', () => { + const style = new Style({ + blurs: [ + { + progressive: true, + }, + ], + }) + expect(style.blurs[0].progressive).toBe(true) + + style.blurs[0].progressive = false + expect(style.blurs[0].progressive).toBe(false) + + style.blurs[0].progressive = true + expect(style.blurs[0].progressive).toBe(true) +}) + +test('should set progressive linear gradient via constructor', () => { + const style = new Style({ + blurs: [ + { + progressive: true, + gradient: { + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 }, + stops: [ + { position: 0, color: '#FF00007F' }, + { position: 1, color: '#00FF00FF' }, + ], + }, + }, + ], + }) + expect(style.blurs[0].progressive).toBe(true) + expect(style.blurs[0].gradient.gradientType).toBe(Style.GradientType.Linear) + expect(style.blurs[0].gradient.from.toJSON()).toEqual({ x: 0, y: 0 }) + expect(style.blurs[0].gradient.to.toJSON()).toEqual({ x: 1, y: 1 }) + expect(style.blurs[0].gradient.stops.map((x) => x.toJSON())).toEqual([ + { position: 0, color: '#ff00007f' }, + { position: 1, color: '#00ff00ff' }, + ]) + expect( + style.blurs[0].gradient.stops.map((x) => Math.round(x.alpha * 255)) + ).toEqual([127, 255]) +}) + +test('should set progressive linear gradient via property', () => { + const style = new Style({ + blurs: [ + { + progressive: false, + }, + ], + }) + expect(style.blurs[0].progressive).toBe(false) + expect(style.blurs[0].gradient).toBeUndefined() + + style.blurs[0].progressive = true + style.blurs[0].gradient = { + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 }, + stops: [ + { position: 0, color: '#FF00007F' }, + { position: 1, color: '#00FF00FF' }, + ], + } + + expect(style.blurs[0].progressive).toBe(true) + expect(style.blurs[0].gradient).toBeDefined() + expect(style.blurs[0].gradient.gradientType).toBe(Style.GradientType.Linear) + expect(style.blurs[0].gradient.from.toJSON()).toEqual({ x: 0, y: 0 }) + expect(style.blurs[0].gradient.to.toJSON()).toEqual({ x: 1, y: 1 }) + expect(style.blurs[0].gradient.stops.map((x) => x.toJSON())).toEqual([ + { position: 0, color: '#ff00007f' }, + { position: 1, color: '#00ff00ff' }, + ]) + expect( + style.blurs[0].gradient.stops.map((x) => Math.round(x.alpha * 255)) + ).toEqual([127, 255]) +}) + +test('should set progressive radial gradient via constructor', () => { + const style = new Style({ + blurs: [ + { + progressive: true, + gradient: { + gradientType: Style.GradientType.Radial, + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 }, + stops: [ + { position: 0, color: '#FF00007F' }, + { position: 1, color: '#00FF00FF' }, + ], + }, + }, + ], + }) + expect(style.blurs[0].progressive).toBe(true) + expect(style.blurs[0].gradient.gradientType).toBe(Style.GradientType.Radial) + expect(style.blurs[0].gradient.from.toJSON()).toEqual({ x: 0, y: 0 }) + expect(style.blurs[0].gradient.to.toJSON()).toEqual({ x: 1, y: 1 }) + expect(style.blurs[0].gradient.stops.map((x) => x.toJSON())).toEqual([ + { position: 0, color: '#ff00007f' }, + { position: 1, color: '#00ff00ff' }, + ]) + expect( + style.blurs[0].gradient.stops.map((x) => Math.round(x.alpha * 255)) + ).toEqual([127, 255]) +}) + +test('should set progressive radial gradient via property', () => { + const style = new Style({ + blurs: [ + { + progressive: false, + }, + ], + }) + expect(style.blurs[0].progressive).toBe(false) + expect(style.blurs[0].gradient).toBeUndefined() + + style.blurs[0].progressive = true + style.blurs[0].gradient = { + gradientType: Style.GradientType.Radial, + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 }, + stops: [ + { position: 0, color: '#FF00007F' }, + { position: 1, color: '#00FF00FF' }, + ], + } + + expect(style.blurs[0].progressive).toBe(true) + expect(style.blurs[0].gradient).toBeDefined() + expect(style.blurs[0].gradient.gradientType).toBe(Style.GradientType.Radial) + expect(style.blurs[0].gradient.from.toJSON()).toEqual({ x: 0, y: 0 }) + expect(style.blurs[0].gradient.to.toJSON()).toEqual({ x: 1, y: 1 }) + expect(style.blurs[0].gradient.stops.map((x) => x.toJSON())).toEqual([ + { position: 0, color: '#ff00007f' }, + { position: 1, color: '#00ff00ff' }, + ]) + expect( + style.blurs[0].gradient.stops.map((x) => Math.round(x.alpha * 255)) + ).toEqual([127, 255]) +}) diff --git a/Source/dom/style/__tests__/Border.test.js b/Source/dom/style/__tests__/Border.test.js index 0d7f2e230..be50ffbf9 100644 --- a/Source/dom/style/__tests__/Border.test.js +++ b/Source/dom/style/__tests__/Border.test.js @@ -68,6 +68,7 @@ test('should get the borders', () => { position: 'Center', thickness: 30, enabled: true, + blendingMode: 'Normal', gradient: { gradientType: 'Linear', from: { x: 0.5, y: 0 }, @@ -85,6 +86,7 @@ test('should get the borders', () => { position: 'Outside', thickness: 1, enabled: true, + blendingMode: 'Normal', gradient: { gradientType: 'Linear', from: { x: 0.5, y: 0 }, @@ -121,3 +123,62 @@ test('should set the borders with 0s', () => { }) expect(style.borders[0].thickness).toBe(0) }) + +test('should set and get blending mode', () => { + const style = new Style({ + blendingMode: Style.BlendingMode.Darken, + borders: [ + { + color: '##aabbccff', + blendingMode: Style.BlendingMode.ColorBurn, + }, + { + color: 'black', + blendingMode: Style.BlendingMode.ColorDodge, + }, + ], + }) + expect(style.borders[0].blendingMode).toBe(Style.BlendingMode.ColorBurn) + expect(style.borders[1].blendingMode).toBe(Style.BlendingMode.ColorDodge) + + style.borders[0].blendingMode = Style.BlendingMode.Lighten + style.borders[1].blendingMode = Style.BlendingMode.Screen + style.blendingMode = Style.BlendingMode.Multiply + + expect(style.borders[0].blendingMode).toBe(Style.BlendingMode.Lighten) + expect(style.borders[1].blendingMode).toBe(Style.BlendingMode.Screen) +}) + +test('should set and get gradient property', () => { + const style = new Style({ + borders: [ + { + fillType: Style.FillType.Color, + color: '#000000ff', + }, + ], + }) + expect(style.borders[0].fillType).toBe(Style.FillType.Color) + + style.borders[0].fillType = Style.FillType.Gradient + style.borders[0].gradient = { + gradientType: Style.GradientType.Linear, + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 }, + stops: [ + { position: 0, color: '#ff00007f' }, + { position: 1, color: '#00ff00ff' }, + ], + } + + expect(style.borders[0].gradient.toJSON()).toEqual({ + gradientType: Style.GradientType.Linear, + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 }, + aspectRatio: 0, + stops: [ + { position: 0, color: '#ff00007f' }, + { position: 1, color: '#00ff00ff' }, + ], + }) +}) diff --git a/Source/dom/style/__tests__/Corners.test.js b/Source/dom/style/__tests__/Corners.test.js new file mode 100644 index 000000000..8aabcc7e6 --- /dev/null +++ b/Source/dom/style/__tests__/Corners.test.js @@ -0,0 +1,204 @@ +/* globals expect, test */ +import { + Style, + ShapePath, + Group, + GroupBehavior, + Rectangle, + FlexSizing, +} from '../..' + +test('should get and set corner style', () => { + const style = new Style() + expect(style.corners).toBeDefined() // the corners property is created on demand, always available + expect(style.corners.style).toBe(Style.CornerStyle.Rounded) // default style + + style.corners = { + style: Style.CornerStyle.Smooth, + } + expect(style.corners.style).toBe(Style.CornerStyle.Smooth) + + style.corners.style = Style.CornerStyle.InsideSquare + expect(style.corners.style).toBe(Style.CornerStyle.InsideSquare) + + const style2 = new Style({ + corners: { + style: Style.CornerStyle.Angled, + }, + }) + expect(style2.corners.style).toBe(Style.CornerStyle.Angled) +}) + +test('should get and set corner radii', () => { + const style = new Style() + expect(style.corners.hasRadii).toBe(false) + expect(style.corners.radii).toEqual([]) + + style.corners.radii = 10 + expect(style.corners.hasRadii).toBe(true) + expect(style.corners.radii).toEqual([10]) + + style.corners.radii = [20] + expect(style.corners.hasRadii).toBe(true) + expect(style.corners.radii).toEqual([20]) + + style.corners.radii = [10, 20, 30, 40] + expect(style.corners.hasRadii).toBe(true) + expect(style.corners.radii).toEqual([10, 20, 30, 40]) +}) + +test('should get corner radius at index', () => { + const style = new Style({ + corners: { + radii: [10, 20, 30, 40], + }, + }) + expect(style.corners.radiusAt(0)).toBe(10) + expect(style.corners.radiusAt(1)).toBe(20) + expect(style.corners.radiusAt(2)).toBe(30) + expect(style.corners.radiusAt(3)).toBe(40) + expect(style.corners.radiusAt(4)).toBe(10) // wraps around + expect(style.corners.radiusAt(5)).toBe(20) // wraps around + expect(style.corners.radiusAt(6)).toBe(30) // wraps around + expect(style.corners.radiusAt(7)).toBe(40) // wraps around + expect(style.corners.radiusAt(8)).toBe(10) // wraps around +}) + +test('should update corner radii when set to concentric (#1)', () => { + const layer = new ShapePath({ + style: { fills: [{ color: '#ffaa00' }] }, + }) + const parent = new Group({ + groupBehavior: GroupBehavior.Frame, + stackLayout: { padding: 10 }, + frame: new Rectangle(100, 100, 100, 100), + style: { + corners: { radii: 20 }, + }, + }) + parent.layers = [layer] + + expect(layer.style.corners.concentric).toBe(false) + expect(layer.style.corners.radii).toEqual([]) + + layer.style.corners.concentric = true + + expect(layer.style.corners.concentric).toBe(true) + expect(layer.style.corners.radii).toEqual([10]) // based on the parent Frame's corner radius and padding +}) + +test('should update corner radii when set to concentric (#2)', () => { + const layer = new ShapePath({ + style: { + fills: [{ color: '#ffaa00' }], + corners: { concentric: true }, + }, + }) + const parent = new Group({ + groupBehavior: GroupBehavior.Frame, + stackLayout: { padding: 10 }, + frame: new Rectangle(100, 100, 100, 100), + style: { + corners: { radii: 20 }, + }, + }) + parent.layers = [layer] + + expect(layer.style.corners.concentric).toBe(true) + expect(layer.style.corners.radii).toEqual([10]) +}) + +test('should update corner radii when set to concentric (#3)', () => { + const layer = new ShapePath({ + style: { + fills: [{ color: '#ffaa00' }], + }, + }) + const parent = new Group({ + groupBehavior: GroupBehavior.Frame, + stackLayout: { padding: 10 }, + frame: new Rectangle(100, 100, 100, 100), + style: { + corners: { radii: 20 }, + }, + }) + layer.style.corners.concentric = true + parent.layers.push(layer) + + expect(layer.style.corners.concentric).toBe(true) + expect(layer.style.corners.radii).toEqual([10]) +}) + +test('should update corner radii when set to concentric (#4)', () => { + const layer = new ShapePath({ + style: { + fills: [{ color: '#ffaa00' }], + }, + }) + const parent = new Group({ + groupBehavior: GroupBehavior.Frame, + stackLayout: { padding: 10 }, + frame: new Rectangle(100, 100, 100, 100), + style: { + corners: { radii: 20 }, + }, + }) + + layer.style.corners.style = Style.CornerStyle.Auto + parent.layers.push(layer) + + expect(layer.style.corners.concentric).toBe(true) + expect(layer.style.corners.radii).toEqual([10]) +}) + +test('should update concentric corners when parent corner radius changes', () => { + const layer = new ShapePath({ + style: { + fills: [{ color: '#ffaa00' }], + corners: { concentric: true }, + }, + }) + const parent = new Group({ + groupBehavior: GroupBehavior.Frame, + stackLayout: { padding: 10 }, + frame: new Rectangle(100, 100, 100, 100), + style: { + corners: { radii: 20 }, + }, + layers: [layer], + }) + + expect(layer.style.corners.concentric).toBe(true) + expect(layer.style.corners.radii).toEqual([10]) + + parent.style.corners.radii = 40 + expect(layer.style.corners.radii).toEqual([30]) +}) + +test('should update concentric corners when parent padding changes', () => { + const layer = new ShapePath({ + style: { + fills: [{ color: '#ffaa00' }], + corners: { concentric: true }, + }, + frame: new Rectangle(10, 10, 80, 80), + verticalSizing: FlexSizing.Relative, + horizontalSizing: FlexSizing.Relative, + }) + const parent = new Group({ + groupBehavior: GroupBehavior.Frame, + frame: new Rectangle(100, 100, 100, 100), + style: { + corners: { radii: 20 }, + }, + layers: [layer], + verticalSizing: FlexSizing.Fixed, + horizontalSizing: FlexSizing.Fixed, + }) + + expect(layer.style.corners.concentric).toBe(true) + expect(layer.style.corners.radii).toEqual([10]) + + parent.frame = new Rectangle(100, 100, 80, 80) // shrink the parent and the child corner radius should follow + expect(layer.style.corners.radii).toEqual([12]) +}) diff --git a/Source/dom/style/__tests__/Fill.test.js b/Source/dom/style/__tests__/Fill.test.js index 1385a60b4..2c5092ec7 100644 --- a/Source/dom/style/__tests__/Fill.test.js +++ b/Source/dom/style/__tests__/Fill.test.js @@ -48,6 +48,7 @@ test('should get the fills', () => { { color: '#11223344', fillType: 'Color', + blendingMode: 'Normal', enabled: true, gradient: { gradientType: 'Linear', @@ -64,6 +65,7 @@ test('should get the fills', () => { { color: '#11223344', fillType: 'Color', + blendingMode: 'Normal', enabled: true, gradient: { gradientType: 'Linear', @@ -99,3 +101,113 @@ test('should set the pattern', () => { expect(style.fills[0].pattern.tileScale).toBe(2) expect(style.fills[0].pattern.image.type).toBe('ImageData') }) + +test('should set and get blending mode', () => { + const style = new Style({ blendingMode: Style.BlendingMode.Multiply }) + style.fills = [ + { + fillType: Style.FillType.Color, + color: '#aabbccff', + blendingMode: Style.BlendingMode.HardLight, + }, + { + fillType: Style.FillType.Color, + color: '#ffccbbaa', + blendingMode: Style.BlendingMode.Exclusion, + }, + ] + expect(style.fills[0].blendingMode).toBe(Style.BlendingMode.HardLight) + expect(style.fills[1].blendingMode).toBe(Style.BlendingMode.Exclusion) + + style.fills[0].blendingMode = Style.BlendingMode.Difference + style.fills[1].blendingMode = Style.BlendingMode.Luminosity + style.blendingMode = Style.BlendingMode.Hue + + expect(style.fills[0].blendingMode).toBe(Style.BlendingMode.Difference) + expect(style.fills[1].blendingMode).toBe(Style.BlendingMode.Luminosity) +}) + +test('should set and get gradient property', () => { + const style = new Style({ + fills: [ + { + fillType: Style.FillType.Color, + color: '#000000ff', + }, + ], + }) + expect(style.fills[0].fillType).toBe(Style.FillType.Color) + + style.fills[0].fillType = Style.FillType.Gradient + style.fills[0].gradient = { + gradientType: Style.GradientType.Linear, + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 }, + stops: [ + { position: 0, color: '#ff00007f' }, + { position: 1, color: '#00ff00ff' }, + ], + } + + expect(style.fills[0].gradient.toJSON()).toEqual({ + gradientType: Style.GradientType.Linear, + from: { x: 0, y: 0 }, + to: { x: 1, y: 1 }, + aspectRatio: 0, + stops: [ + { position: 0, color: '#ff00007f' }, + { position: 1, color: '#00ff00ff' }, + ], + }) +}) + +test('should only return regular fills', () => { + const style = new Style({ + fills: [ + { + fillType: Style.FillType.Color, + color: '#aa00ccff', + }, + { + fillType: Style.FillType.Color, + color: '#00bb00ff', + }, + ], + }) + + expect(style.fills.length).toBe(2) + expect(style.tint).toBeUndefined() + + style.fills[0].sketchObject.setLayeringType(1) // Make it a tint fill + + expect(style.fills.length).toBe(1) + expect(style.tint).toBeDefined() +}) + +test('should set, get, and remove tint', () => { + const style = new Style({ + fills: [ + { + fillType: Style.FillType.Color, + color: '#aa00ccff', + }, + ], + }) + + expect(style.fills.length).toBe(1) + expect(style.tint).toBeUndefined() + + style.tint = { + fillType: Style.FillType.Gradient, // will be ignored + color: '#00bb00ff', + } + + expect(style.fills.length).toBe(1) + expect(style.tint.color).toBe('#00bb00ff') + expect(style.tint.fillType).toBe(Style.FillType.Color) + + style.tint = null + + expect(style.fills.length).toBe(1) + expect(style.tint).toBeUndefined() +}) diff --git a/Source/dom/style/__tests__/GradientStop.test.js b/Source/dom/style/__tests__/GradientStop.test.js index f07f29b6e..94604bb15 100644 --- a/Source/dom/style/__tests__/GradientStop.test.js +++ b/Source/dom/style/__tests__/GradientStop.test.js @@ -44,3 +44,28 @@ test('should create a gradient with some stops', () => { ], }) }) + +test('should report alpha of gradient stops', () => { + const s = new Style({ + fills: [ + { + fillType: FillType.Gradient, + gradient: { + gradientType: GradientType.Linear, + stops: [ + { + position: 0, + color: '#1234567F', + }, + { + position: 1, + color: '#123456FF', + }, + ], + }, + }, + ], + }) + expect(Math.round(s.fills[0].gradient.stops[0].alpha * 255)).toEqual(127) + expect(Math.round(s.fills[0].gradient.stops[1].alpha * 255)).toEqual(255) +}) diff --git a/Source/dom/style/__tests__/Shadow.test.js b/Source/dom/style/__tests__/Shadow.test.js index c3460aee6..caafb524f 100644 --- a/Source/dom/style/__tests__/Shadow.test.js +++ b/Source/dom/style/__tests__/Shadow.test.js @@ -56,6 +56,7 @@ test('should get the shadows', () => { spread: 20, enabled: false, isInnerShadow: false, + blendingMode: 'Normal', }) expect(style.innerShadows[0].toJSON()).toEqual({ color: '#11223344', @@ -65,6 +66,7 @@ test('should get the shadows', () => { spread: 10, enabled: true, isInnerShadow: true, + blendingMode: 'Normal', }) }) @@ -93,3 +95,38 @@ test('should set the shadows with 0 values', () => { expect(style.innerShadows[0].blur).toBe(0) expect(style.innerShadows[0].y).toBe(0) }) + +test('should set and get blending mode', () => { + const style = new Style({ + blendingMode: Style.BlendingMode.ColorBurn, + }) + style.shadows = [ + { + spread: 0, + blur: 0, + x: 1, + y: 0, + color: '#ebc100', + blendingMode: Style.BlendingMode.Darken, + }, + { + spread: 0, + blur: 0, + x: 1, + y: 0, + color: '#ebc100', + isInnerShadow: true, + blendingMode: Style.BlendingMode.Difference, + }, + ] + + expect(style.shadows[0].blendingMode).toBe(Style.BlendingMode.Darken) + expect(style.innerShadows[0].blendingMode).toBe(Style.BlendingMode.Difference) + + style.shadows[0].blendingMode = Style.BlendingMode.HardLight + style.innerShadows[0].blendingMode = Style.BlendingMode.Hue + style.blendingMode = Style.BlendingMode.Lighten + + expect(style.shadows[0].blendingMode).toBe(Style.BlendingMode.HardLight) + expect(style.innerShadows[0].blendingMode).toBe(Style.BlendingMode.Hue) +}) diff --git a/Source/dom/style/__tests__/Style.test.js b/Source/dom/style/__tests__/Style.test.js index 585c8396a..bffedcc80 100644 --- a/Source/dom/style/__tests__/Style.test.js +++ b/Source/dom/style/__tests__/Style.test.js @@ -58,3 +58,30 @@ test('should be in and out of sync with its shared style', (_context, document) expect(style.isOutOfSyncWithSharedStyle(sharedStyle)).toBe(false) expect(sharedStyle.style.opacity).toBe(1) }) + +test('should get and set progressive alpha gradient', () => { + const style = new Style() + expect(style.progressiveAlpha).toBeUndefined() + + style.progressiveAlpha = { + stops: [ + { color: '#00000000', position: 0 }, + { color: '#0000007f', position: 0.5 }, + { color: '#000000ff', position: 1 }, + ], + } + expect(style.progressiveAlpha.toJSON()).toEqual({ + gradientType: 'Linear', + from: { x: 0.5, y: 0 }, + to: { x: 0.5, y: 1 }, + aspectRatio: 0, + stops: [ + { color: '#00000000', position: 0 }, + { color: '#0000007f', position: 0.5 }, + { color: '#000000ff', position: 1 }, + ], + }) + + style.progressiveAlpha = null + expect(style.progressiveAlpha).toBeUndefined() +}) diff --git a/Source/test-utils.js b/Source/test-utils.js index 41927be22..4f756e7ce 100644 --- a/Source/test-utils.js +++ b/Source/test-utils.js @@ -21,7 +21,7 @@ export function createSymbolMaster(document) { }) // build the symbol master - const master = SymbolMaster.fromArtboard(artboard) + const master = SymbolMaster.fromFrame(artboard) master.sketchObject.ensureDetachHasUpdated() return { @@ -60,8 +60,7 @@ export function createSharedStyle(document, Primitive, style) { export function outputPath() { const uuid = NSUUID.UUID().UUIDString() - const path = NSTemporaryDirectory() - .stringByAppendingPathComponent(uuid) + const path = NSTemporaryDirectory().stringByAppendingPathComponent(uuid) fs.mkdirSync(path, { recursive: true }) return path diff --git a/package.json b/package.json index 8c13919ff..aa33a867d 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,11 @@ "description": "JavaScript API for Sketch", "homepage": "https://github.com/sketch-hq/SketchAPI", "scripts": { - "build": "./build.sh", + "build": "./Scripts/build.sh", "test:build": "webpack --config webpack.tests.config.js --env identifier=$npm_config_identifier --env spec=$npm_config_spec", - "test": "./test.sh $npm_config_target", + "test": "./Scripts/test.sh $npm_config_spec", + "test:suite": "./Scripts/test.sh", + "test:custom": "./Scripts/test.sh", "lint": "eslint \"Source/**\"", "format-check": "prettier --check \"**/*.{js,json}\"", "api-location:write": "defaults write com.bohemiancoding.sketch3 SketchAPILocation \"$(pwd)/build\"", diff --git a/run_tests.py b/run_tests.py index 4d21a008e..ed022bfcd 100755 --- a/run_tests.py +++ b/run_tests.py @@ -332,9 +332,9 @@ def main(argv): print_results(results) if (failed): - raise Exception('Some tests failed') + raise Exception('🛑 Some tests failed') - print('All test suites passed') + print('✅ All test suites passed') sys.exit(0) except Exception as e: diff --git a/test.sh b/test.sh deleted file mode 100755 index e3dd47788..000000000 --- a/test.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash - -TEST_RUN_ID=$(uuidgen) - -# This script accepts a single optional argument - a Sketch variant identifier: -# - beta -# - xcode -# There's also an optional TARGET_SKETCH_VARIANT_BUNDLE_ID env var set by VSCode's tasks.json: -# - com.bohemiancoding.sketch3.beta -# - com.bohemiancoding.sketch3.xcode -# - com.bohemiancoding.sketch3 -if [[ -n "$1" ]]; then - TARGET_SKETCH_VARIANT_BUNDLE_ID="com.bohemiancoding.sketch3.$1" -else - if [ -z "${TARGET_SKETCH_VARIANT_BUNDLE_ID}" ]; then - TARGET_SKETCH_VARIANT_BUNDLE_ID="com.bohemiancoding.sketch3" - fi -fi - -# Gracefully tear down the test env on error -tear_down() { - defaults delete "${TARGET_SKETCH_VARIANT_BUNDLE_ID}" SketchAPILocation -} -trap 'tear_down' ERR - -# Set up test environment -defaults write "${TARGET_SKETCH_VARIANT_BUNDLE_ID}" SketchAPILocation -string "$(pwd)/build" - -# Build tests -webpack --config webpack.tests.config.js --env identifier="$TEST_RUN_ID" - -# Run tests -python3 run_tests.py \ - -p "./build/SketchIntegrationTests-${TEST_RUN_ID}.sketchplugin" \ - -o "./build/${TEST_RUN_ID}_test_results.txt" \ - -s "$(mdfind kMDItemCFBundleIdentifier == ${TARGET_SKETCH_VARIANT_BUNDLE_ID})" - -# Tear down test environment -tear_down - -# Clean up test artifacts -rm -rf "./build/SketchIntegrationTests-${TEST_RUN_ID}.sketchplugin" -rm -rf "./build/${TEST_RUN_ID}_test_results.txt" diff --git a/webpack.tests.config.js b/webpack.tests.config.js index 0e42c2c74..374d3eec2 100644 --- a/webpack.tests.config.js +++ b/webpack.tests.config.js @@ -317,8 +317,8 @@ function source(identifier, tests) { prepareStackTrace }) .finally(() => { - console.log('✅ Test results saved to: ' + output) - sketch.UI.message('✅ Test results saved to disk.') + console.log('Test results saved to: ' + output) + sketch.UI.message('Test results saved to disk.') fiber.cleanup() }) }