diff --git a/.codesandbox/Dockerfile b/.codesandbox/Dockerfile new file mode 100644 index 000000000..fd5b38d1e --- /dev/null +++ b/.codesandbox/Dockerfile @@ -0,0 +1,5 @@ +FROM node:18-bullseye + +# Vite wants to open the browser using `open`, so we +# need to install those utils. +RUN apt update -y && apt install -y xdg-utils diff --git a/.codesandbox/tasks.json b/.codesandbox/tasks.json index 360636c4c..51c6e4e16 100644 --- a/.codesandbox/tasks.json +++ b/.codesandbox/tasks.json @@ -27,7 +27,10 @@ "start": { "name": "Start Excalidraw", "command": "yarn start", - "runAtStart": true + "runAtStart": true, + "preview": { + "port": 3000 + } }, "test": { "name": "Run Tests", @@ -37,7 +40,11 @@ "install-deps": { "name": "Install Dependencies", "command": "yarn install", - "restartOn": { "files": ["yarn.lock"] } + "restartOn": { + "files": ["yarn.lock"], + "branch": false, + "resume": false + } } } } diff --git a/.env.production b/.env.production index e3ece6df3..b0570f2a0 100644 --- a/.env.production +++ b/.env.production @@ -1,5 +1,5 @@ -REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ -REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ +VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ +VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml new file mode 100644 index 000000000..76c818298 --- /dev/null +++ b/.github/workflows/test-coverage-pr.yml @@ -0,0 +1,26 @@ +name: Test Coverage PR +on: + pull_request: + +jobs: + coverage: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v2 + - name: "Install Node" + uses: actions/setup-node@v2 + with: + node-version: "18.x" + - name: "Install Deps" + run: yarn --frozen-lockfile + - name: "Test Coverage" + run: yarn test:coverage + - name: "Report Coverage" + if: always() # Also generate the report if tests are failing + uses: davelosert/vitest-coverage-report-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.nvmrc b/.nvmrc index 8351c1939..3c032078a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14 +18 diff --git a/dev-docs/docs/introduction/contributing.mdx b/dev-docs/docs/introduction/contributing.mdx index 821355e0e..169aa5355 100644 --- a/dev-docs/docs/introduction/contributing.mdx +++ b/dev-docs/docs/introduction/contributing.mdx @@ -69,6 +69,10 @@ It's also a good idea to consider if your change should include additional tests Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well. +:::note +Some checks, such as the `lint` and `test`, require approval from the maintainers to run. +They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval. +::: ## Translating diff --git a/dev-docs/yarn.lock b/dev-docs/yarn.lock index 6206a60e9..194a38e75 100644 --- a/dev-docs/yarn.lock +++ b/dev-docs/yarn.lock @@ -6611,19 +6611,19 @@ semver@7.0.0: integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== semver@^5.4.1: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7: - version "7.3.7" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" - integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== dependencies: lru-cache "^6.0.0" diff --git a/package.json b/package.json index a4bd5142a..a2a66b5c1 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@types/resize-observer-browser": "0.1.7", "@types/socket.io-client": "1.4.36", "@vitejs/plugin-react": "3.1.0", + "@vitest/coverage-v8": "0.33.0", "@vitest/ui": "0.32.2", "chai": "4.3.6", "dotenv": "16.0.1", @@ -101,8 +102,8 @@ "private": true, "scripts": { "build-node": "node ./scripts/build-node.js", - "build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true vite build", - "build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", + "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", + "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", "build:version": "node ./scripts/build-version.js", "build": "yarn build:app && yarn build:version", "fix:code": "yarn test:code --fix", @@ -114,14 +115,15 @@ "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "start": "vite", "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", - "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", + "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:app": "vitest --config vitest.config.ts", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:other": "yarn prettier --list-different", "test:typecheck": "tsc", "test:update": "yarn test:app --update --watch=false", "test": "yarn test:app", - "test:coverage": "vitest --coverage --watchAll", + "test:coverage": "vitest --coverage", + "test:coverage:watch": "vitest --coverage --watch", "test:ui": "yarn test --ui", "autorelease": "node scripts/autorelease.js", "prerelease": "node scripts/prerelease.js", diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 000000000..55afd7e21 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,20 @@ +// Since we migrated to Vite, the service worker strategy changed, in CRA it was a custom service worker named service-worker.js and in Vite its sw.js handled by vite-plugin-pwa +// Due to this the existing CRA users were not able to migrate to Vite or any new changes post Vite unless browser is hard refreshed +// Hence adding a self destroying worker so all CRA service workers are destroyed and migrated to Vite +// We should remove this code after sometime when we are confident that +// all users have migrated to Vite + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", () => { + self.registration + .unregister() + .then(() => { + return self.clients.matchAll(); + }) + .then((clients) => { + clients.forEach((client) => client.navigate(client.url)); + }); +}); diff --git a/src/components/App.tsx b/src/components/App.tsx index 8665a6c91..309236313 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -297,7 +297,6 @@ import { getApproxMinLineWidth, getBoundTextElement, getContainerCenter, - getContainerDims, getContainerElement, getDefaultLineHeight, getLineHeightInPx, @@ -3464,9 +3463,8 @@ class App extends React.Component { lineHeight, ); const minHeight = getApproxMinLineHeight(fontSize, lineHeight); - const containerDims = getContainerDims(container); - const newHeight = Math.max(containerDims.height, minHeight); - const newWidth = Math.max(containerDims.width, minWidth); + const newHeight = Math.max(container.height, minHeight); + const newWidth = Math.max(container.width, minWidth); mutateElement(container, { height: newHeight, width: newWidth }); sceneX = container.x + newWidth / 2; sceneY = container.y + newHeight / 2; @@ -5309,7 +5307,7 @@ class App extends React.Component { width: embedLink.aspectRatio.w, height: embedLink.aspectRatio.h, link, - validated: undefined, + validated: null, }); this.scene.replaceAllElements([ @@ -5497,7 +5495,7 @@ class App extends React.Component { } private createGenericElementOnPointerDown = ( - elementType: ExcalidrawGenericElement["type"], + elementType: ExcalidrawGenericElement["type"] | "embeddable", pointerDownState: PointerDownState, ): void => { const [gridX, gridY] = getGridPoint( @@ -5511,8 +5509,7 @@ class App extends React.Component { y: gridY, }); - const element = newElement({ - type: elementType, + const baseElementAttributes = { x: gridX, y: gridY, strokeColor: this.state.currentItemStrokeColor, @@ -5525,8 +5522,21 @@ class App extends React.Component { roundness: this.getCurrentItemRoundness(elementType), locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, - ...(elementType === "embeddable" ? { validated: false } : {}), - }); + } as const; + + let element; + if (elementType === "embeddable") { + element = newEmbeddableElement({ + type: "embeddable", + validated: null, + ...baseElementAttributes, + }); + } else { + element = newElement({ + type: elementType, + ...baseElementAttributes, + }); + } if (element.type === "selection") { this.setState({ diff --git a/src/components/EyeDropper.tsx b/src/components/EyeDropper.tsx index 9e452ce88..8e3e21ece 100644 --- a/src/components/EyeDropper.tsx +++ b/src/components/EyeDropper.tsx @@ -77,8 +77,8 @@ export const EyeDropper: React.FC<{ colorPreviewDiv.style.left = `${clientX + 20}px`; const pixel = ctx.getImageData( - clientX * window.devicePixelRatio - appState.offsetLeft, - clientY * window.devicePixelRatio - appState.offsetTop, + (clientX - appState.offsetLeft) * window.devicePixelRatio, + (clientY - appState.offsetTop) * window.devicePixelRatio, 1, 1, ).data; diff --git a/src/constants.ts b/src/constants.ts index 4c6b67e2c..6ef98af17 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -117,6 +117,7 @@ export const FRAME_STYLE = { export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; +export const MIN_FONT_SIZE = 1; export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_TEXT_ALIGN = "left"; @@ -239,6 +240,8 @@ export const VERSIONS = { } as const; export const BOUND_TEXT_PADDING = 5; +export const ARROW_LABEL_WIDTH_FRACTION = 0.7; +export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11; export const VERTICAL_ALIGN = { TOP: "top", diff --git a/src/data/restore.ts b/src/data/restore.ts index 08fbe0930..63f1e33c0 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -286,7 +286,7 @@ const restoreElement = ( return restoreElementWithProperties(element, {}); case "embeddable": return restoreElementWithProperties(element, { - validated: undefined, + validated: null, }); case "frame": return restoreElementWithProperties(element, { diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts index 94bde1f2a..094a6d6f5 100644 --- a/src/element/linearElementEditor.ts +++ b/src/element/linearElementEditor.ts @@ -269,11 +269,11 @@ export class LinearElementEditor { }; }), ); + } - const boundTextElement = getBoundTextElement(element); - if (boundTextElement) { - handleBindTextResize(element, false); - } + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + handleBindTextResize(element, false); } // suggest bindings for first and last point if selected diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 012725bc4..cb5657f1d 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -134,7 +134,7 @@ export const newElement = ( export const newEmbeddableElement = ( opts: { type: "embeddable"; - validated: boolean | undefined; + validated: ExcalidrawEmbeddableElement["validated"]; } & ElementConstructorOpts, ): NonDeleted => { return { diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index b9fd63287..136c70fb8 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -1,4 +1,4 @@ -import { SHIFT_LOCKING_ANGLE } from "../constants"; +import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; import { rescalePoints } from "../points"; import { @@ -204,8 +204,6 @@ const rescalePointsInElement = ( } : {}; -const MIN_FONT_SIZE = 1; - const measureFontSizeFromWidth = ( element: NonDeleted, nextWidth: number, @@ -589,24 +587,42 @@ export const resizeSingleElement = ( }); } + if ( + isArrowElement(element) && + boundTextElement && + shouldMaintainAspectRatio + ) { + const fontSize = + (resizedElement.width / element.width) * boundTextElement.fontSize; + if (fontSize < MIN_FONT_SIZE) { + return; + } + boundTextFont.fontSize = fontSize; + } + if ( resizedElement.width !== 0 && resizedElement.height !== 0 && Number.isFinite(resizedElement.x) && Number.isFinite(resizedElement.y) ) { + mutateElement(element, resizedElement); + updateBoundElements(element, { newSize: { width: resizedElement.width, height: resizedElement.height }, }); - mutateElement(element, resizedElement); if (boundTextElement && boundTextFont != null) { mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize, baseline: boundTextFont.baseline, }); } - handleBindTextResize(element, transformHandleDirection); + handleBindTextResize( + element, + transformHandleDirection, + shouldMaintainAspectRatio, + ); } }; @@ -722,12 +738,8 @@ export const resizeMultipleElements = ( fontSize?: ExcalidrawTextElement["fontSize"]; baseline?: ExcalidrawTextElement["baseline"]; scale?: ExcalidrawImageElement["scale"]; + boundTextFontSize?: ExcalidrawTextElement["fontSize"]; }; - boundText: { - element: ExcalidrawTextElementWithContainer; - fontSize: ExcalidrawTextElement["fontSize"]; - baseline: ExcalidrawTextElement["baseline"]; - } | null; }[] = []; for (const { orig, latest } of targetElements) { @@ -798,50 +810,39 @@ export const resizeMultipleElements = ( } } - let boundText: typeof elementsAndUpdates[0]["boundText"] = null; - - const boundTextElement = getBoundTextElement(latest); - - if (boundTextElement || isTextElement(orig)) { - const updatedElement = { - ...latest, - width, - height, - }; - const metrics = measureFontSizeFromWidth( - boundTextElement ?? (orig as ExcalidrawTextElement), - boundTextElement - ? getBoundTextMaxWidth(updatedElement) - : updatedElement.width, - boundTextElement - ? getBoundTextMaxHeight(updatedElement, boundTextElement) - : updatedElement.height, - ); - + if (isTextElement(orig)) { + const metrics = measureFontSizeFromWidth(orig, width, height); if (!metrics) { return; } - - if (isTextElement(orig)) { - update.fontSize = metrics.size; - update.baseline = metrics.baseline; - } - - if (boundTextElement) { - boundText = { - element: boundTextElement, - fontSize: metrics.size, - baseline: metrics.baseline, - }; - } + update.fontSize = metrics.size; + update.baseline = metrics.baseline; } - elementsAndUpdates.push({ element: latest, update, boundText }); + const boundTextElement = pointerDownState.originalElements.get( + getBoundTextElementId(orig) ?? "", + ) as ExcalidrawTextElementWithContainer | undefined; + + if (boundTextElement) { + const newFontSize = boundTextElement.fontSize * scale; + if (newFontSize < MIN_FONT_SIZE) { + return; + } + update.boundTextFontSize = newFontSize; + } + + elementsAndUpdates.push({ + element: latest, + update, + }); } const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); - for (const { element, update, boundText } of elementsAndUpdates) { + for (const { + element, + update: { boundTextFontSize, ...update }, + } of elementsAndUpdates) { const { width, height, angle } = update; mutateElement(element, update, false); @@ -851,17 +852,17 @@ export const resizeMultipleElements = ( newSize: { width, height }, }); - if (boundText) { - const { element: boundTextElement, ...boundTextUpdates } = boundText; + const boundTextElement = getBoundTextElement(element); + if (boundTextElement && boundTextFontSize) { mutateElement( boundTextElement, { - ...boundTextUpdates, + fontSize: boundTextFontSize, angle: isLinearElement(element) ? undefined : angle, }, false, ); - handleBindTextResize(element, transformHandleType); + handleBindTextResize(element, transformHandleType, true); } } diff --git a/src/element/textElement.ts b/src/element/textElement.ts index 76de2c886..61813d2b4 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -10,6 +10,8 @@ import { } from "./types"; import { mutateElement } from "./mutateElement"; import { + ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO, + ARROW_LABEL_WIDTH_FRACTION, BOUND_TEXT_PADDING, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, @@ -65,7 +67,7 @@ export const redrawTextBoundingBox = ( boundTextUpdates.text = textElement.text; if (container) { - maxWidth = getBoundTextMaxWidth(container); + maxWidth = getBoundTextMaxWidth(container, textElement); boundTextUpdates.text = wrapText( textElement.originalText, getFontString(textElement), @@ -83,13 +85,12 @@ export const redrawTextBoundingBox = ( boundTextUpdates.baseline = metrics.baseline; if (container) { - const containerDims = getContainerDims(container); const maxContainerHeight = getBoundTextMaxHeight( container, textElement as ExcalidrawTextElementWithContainer, ); - let nextHeight = containerDims.height; + let nextHeight = container.height; if (metrics.height > maxContainerHeight) { nextHeight = computeContainerDimensionForBoundText( metrics.height, @@ -155,6 +156,7 @@ export const bindTextToShapeAfterDuplication = ( export const handleBindTextResize = ( container: NonDeletedExcalidrawElement, transformHandleType: MaybeTransformHandleType, + shouldMaintainAspectRatio = false, ) => { const boundTextElementId = getBoundTextElementId(container); if (!boundTextElementId) { @@ -175,15 +177,17 @@ export const handleBindTextResize = ( let text = textElement.text; let nextHeight = textElement.height; let nextWidth = textElement.width; - const containerDims = getContainerDims(container); const maxWidth = getBoundTextMaxWidth(container); const maxHeight = getBoundTextMaxHeight( container, textElement as ExcalidrawTextElementWithContainer, ); - let containerHeight = containerDims.height; + let containerHeight = container.height; let nextBaseLine = textElement.baseline; - if (transformHandleType !== "n" && transformHandleType !== "s") { + if ( + shouldMaintainAspectRatio || + (transformHandleType !== "n" && transformHandleType !== "s") + ) { if (text) { text = wrapText( textElement.originalText, @@ -207,7 +211,7 @@ export const handleBindTextResize = ( container.type, ); - const diff = containerHeight - containerDims.height; + const diff = containerHeight - container.height; // fix the y coord when resizing from ne/nw/n const updatedY = !isArrowElement(container) && @@ -687,16 +691,6 @@ export const getContainerElement = ( return null; }; -export const getContainerDims = (element: ExcalidrawElement) => { - const MIN_WIDTH = 300; - if (isArrowElement(element)) { - const width = Math.max(element.width, MIN_WIDTH); - const height = element.height; - return { width, height }; - } - return { width: element.width, height: element.height }; -}; - export const getContainerCenter = ( container: ExcalidrawElement, appState: AppState, @@ -887,12 +881,19 @@ export const computeContainerDimensionForBoundText = ( return dimension + padding; }; -export const getBoundTextMaxWidth = (container: ExcalidrawElement) => { - const width = getContainerDims(container).width; +export const getBoundTextMaxWidth = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElement | null = getBoundTextElement( + container, + ), +) => { + const { width } = container; if (isArrowElement(container)) { - return width - BOUND_TEXT_PADDING * 8 * 2; + const minWidth = + (boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) * + ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO; + return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth); } - if (container.type === "ellipse") { // The width of the largest rectangle inscribed inside an ellipse is // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from @@ -911,7 +912,7 @@ export const getBoundTextMaxHeight = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, ) => { - const height = getContainerDims(container).height; + const { height } = container; if (isArrowElement(container)) { const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; if (containerHeight <= 0) { diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 9105ba700..a5bf70143 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -23,7 +23,6 @@ import { AppState } from "../types"; import { mutateElement } from "./mutateElement"; import { getBoundTextElementId, - getContainerDims, getContainerElement, getTextElementAngle, getTextWidth, @@ -177,20 +176,19 @@ export const textWysiwyg = ({ updatedTextElement, editable, ); - const containerDims = getContainerDims(container); let originalContainerData; if (propertiesUpdated) { originalContainerData = updateOriginalContainerCache( container.id, - containerDims.height, + container.height, ); } else { originalContainerData = originalContainerCache[container.id]; if (!originalContainerData) { originalContainerData = updateOriginalContainerCache( container.id, - containerDims.height, + container.height, ); } } @@ -214,7 +212,7 @@ export const textWysiwyg = ({ // autoshrink container height until original container height // is reached when text is removed !isArrowElement(container) && - containerDims.height > originalContainerData.height && + container.height > originalContainerData.height && textElementHeight < maxHeight ) { const targetContainerHeight = computeContainerDimensionForBoundText( diff --git a/src/element/types.ts b/src/element/types.ts index 7d799f234..e8d71cae5 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -86,15 +86,15 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & { export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase & Readonly<{ + type: "embeddable"; /** * indicates whether the embeddable src (url) has been validated for rendering. - * nullish value indicates that the validation is pending. We reset the + * null value indicates that the validation is pending. We reset the * value on each restore (or url change) so that we can guarantee * the validation came from a trusted source (the editor). Also because we * may not have access to host-app supplied url validator during restore. */ - validated?: boolean; - type: "embeddable"; + validated: boolean | null; }>; export type ExcalidrawImageElement = _ExcalidrawElementBase & @@ -123,7 +123,6 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & { export type ExcalidrawGenericElement = | ExcalidrawSelectionElement | ExcalidrawRectangleElement - | ExcalidrawEmbeddableElement | ExcalidrawDiamondElement | ExcalidrawEllipseElement; @@ -138,7 +137,8 @@ export type ExcalidrawElement = | ExcalidrawLinearElement | ExcalidrawFreeDrawElement | ExcalidrawImageElement - | ExcalidrawFrameElement; + | ExcalidrawFrameElement + | ExcalidrawEmbeddableElement; export type NonDeleted = TElement & { isDeleted: boolean; diff --git a/src/excalidraw-app/sentry.ts b/src/excalidraw-app/sentry.ts index 6ad0d49f8..d224a266a 100644 --- a/src/excalidraw-app/sentry.ts +++ b/src/excalidraw-app/sentry.ts @@ -6,12 +6,11 @@ const SentryEnvHostnameMap: { [key: string]: string } = { "vercel.app": "staging", }; -const REACT_APP_DISABLE_SENTRY = - import.meta.env.VITE_APP_DISABLE_SENTRY === "true"; +const SENTRY_DISABLED = import.meta.env.VITE_APP_DISABLE_SENTRY === "true"; // Disable Sentry locally or inside the Docker to avoid noise/respect privacy const onlineEnv = - !REACT_APP_DISABLE_SENTRY && + !SENTRY_DISABLED && Object.keys(SentryEnvHostnameMap).find( (item) => window.location.hostname.indexOf(item) >= 0, ); diff --git a/src/packages/excalidraw/webpack.dev.config.js b/src/packages/excalidraw/webpack.dev.config.js index 281307388..332d80281 100644 --- a/src/packages/excalidraw/webpack.dev.config.js +++ b/src/packages/excalidraw/webpack.dev.config.js @@ -2,8 +2,8 @@ const path = require("path"); const webpack = require("webpack"); const autoprefixer = require("autoprefixer"); const { parseEnvVariables } = require("./env"); - const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist"; + module.exports = { mode: "development", devtool: false, @@ -17,7 +17,6 @@ module.exports = { filename: "[name].js", chunkFilename: "excalidraw-assets-dev/[name]-[contenthash].js", assetModuleFilename: "excalidraw-assets-dev/[name][ext]", - publicPath: "", }, resolve: { @@ -45,7 +44,7 @@ module.exports = { { test: /\.(ts|tsx|js|jsx|mjs)$/, exclude: - /node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/, + /node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/, use: [ { loader: "import-meta-loader", diff --git a/src/packages/excalidraw/webpack.prod.config.js b/src/packages/excalidraw/webpack.prod.config.js index c9f8d56a5..8f8c72eef 100644 --- a/src/packages/excalidraw/webpack.prod.config.js +++ b/src/packages/excalidraw/webpack.prod.config.js @@ -1,10 +1,10 @@ const path = require("path"); +const webpack = require("webpack"); +const autoprefixer = require("autoprefixer"); +const { parseEnvVariables } = require("./env"); const TerserPlugin = require("terser-webpack-plugin"); const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin; -const autoprefixer = require("autoprefixer"); -const webpack = require("webpack"); -const { parseEnvVariables } = require("./env"); module.exports = { mode: "production", @@ -47,8 +47,7 @@ module.exports = { { test: /\.(ts|tsx|js|jsx|mjs)$/, exclude: - /node_modules\/(?!(browser-fs-access|canvas-roundrect-polyfill))/, - + /node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/, use: [ { loader: "import-meta-loader", diff --git a/src/tests/fixtures/elementFixture.ts b/src/tests/fixtures/elementFixture.ts index ddd7b8b9d..7f1231a83 100644 --- a/src/tests/fixtures/elementFixture.ts +++ b/src/tests/fixtures/elementFixture.ts @@ -34,6 +34,7 @@ export const rectangleFixture: ExcalidrawElement = { export const embeddableFixture: ExcalidrawElement = { ...elementBase, type: "embeddable", + validated: null, }; export const ellipseFixture: ExcalidrawElement = { ...elementBase, diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 09d312188..46361cf38 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -15,7 +15,11 @@ import fs from "fs"; import util from "util"; import path from "path"; import { getMimeType } from "../../data/blob"; -import { newFreeDrawElement, newImageElement } from "../../element/newElement"; +import { + newEmbeddableElement, + newFreeDrawElement, + newImageElement, +} from "../../element/newElement"; import { Point } from "../../types"; import { getSelectedElements } from "../../scene/selection"; import { isLinearElementType } from "../../element/typeChecks"; @@ -178,14 +182,20 @@ export class API { case "rectangle": case "diamond": case "ellipse": - case "embeddable": element = newElement({ - type: type as "rectangle" | "diamond" | "ellipse" | "embeddable", + type: type as "rectangle" | "diamond" | "ellipse", width, height, ...base, }); break; + case "embeddable": + element = newEmbeddableElement({ + type: "embeddable", + ...base, + validated: null, + }); + break; case "text": const fontSize = rest.fontSize ?? appState.currentItemFontSize; const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily; diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index 0a156e1cf..ac2707945 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -1143,7 +1143,7 @@ describe("Test Linear Elements", () => { height: 500, }); const arrow = UI.createElement("arrow", { - x: 210, + x: -10, y: 250, width: 400, height: 1, @@ -1167,8 +1167,8 @@ describe("Test Linear Elements", () => { expect( wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), ).toMatchInlineSnapshot(` - "Online whiteboard collaboration - made easy" + "Online whiteboard + collaboration made easy" `); const handleBindTextResizeSpy = vi.spyOn( textElementUtils, @@ -1180,7 +1180,7 @@ describe("Test Linear Elements", () => { mouse.moveTo(200, 0); mouse.upAt(200, 0); - expect(arrow.width).toBe(170); + expect(arrow.width).toBe(200); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/vite.config.ts b/vite.config.ts index 4a4d72e3d..c7a75cd25 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -61,7 +61,7 @@ export default defineConfig({ workbox: { // Don't push fonts and locales to app precache - globIgnores: ["fonts.css", "**/locales/**"], + globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"], runtimeCaching: [ { urlPattern: new RegExp("/.+.(ttf|woff2|otf)"), diff --git a/vitest.config.ts b/vitest.config.ts index e7d08704d..51a4df95f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,12 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], globals: true, environment: "jsdom", + coverage: { + reporter: ["text", "json-summary", "json"], + lines: 70, + branches: 70, + functions: 68, + statements: 70, + }, }, }); diff --git a/yarn.lock b/yarn.lock index b553ea932..a6c986fdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@ampproject/remapping@^2.2.0": +"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== @@ -1260,6 +1260,11 @@ "@babel/helper-validator-identifier" "^7.22.5" to-fast-properties "^2.0.0" +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + "@braintree/sanitize-url@6.0.2": version "6.0.2" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f" @@ -1812,6 +1817,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@istanbuljs/schema@^0.1.2": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + "@jest/expect-utils@^29.5.0": version "29.5.0" resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.5.0.tgz#f74fad6b6e20f924582dc8ecbf2cb800fe43a036" @@ -1875,7 +1885,7 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== -"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": version "0.3.18" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz#25783b2086daf6ff1dcb53c9249ae480e4dd4cd6" integrity sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA== @@ -2500,7 +2510,7 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194" integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA== -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== @@ -2786,6 +2796,23 @@ magic-string "^0.27.0" react-refresh "^0.14.0" +"@vitest/coverage-v8@0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-0.33.0.tgz#dfcb36cf51624a89d33ab0962a6eec8a41346ef2" + integrity sha512-Rj5IzoLF7FLj6yR7TmqsfRDSeaFki6NAJ/cQexqhbWkHEV2htlVGrmuOde3xzvFsCbLCagf4omhcIaVmfU8Okg== + dependencies: + "@ampproject/remapping" "^2.2.1" + "@bcoe/v8-coverage" "^0.2.3" + istanbul-lib-coverage "^3.2.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.1" + istanbul-reports "^3.1.5" + magic-string "^0.30.1" + picocolors "^1.0.0" + std-env "^3.3.3" + test-exclude "^6.0.0" + v8-to-istanbul "^9.1.0" + "@vitest/expect@0.34.1": version "0.34.1" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.34.1.tgz#2ba6cb96695f4b4388c6d955423a81afc79b8da0" @@ -3491,7 +3518,7 @@ confusing-browser-globals@^1.0.11: resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA== -convert-source-map@^1.7.0: +convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== @@ -4536,7 +4563,7 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@^7.1.3, glob@^7.1.6: +glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -4671,6 +4698,11 @@ html-encoding-sniffer@^3.0.0: dependencies: whatwg-encoding "^2.0.0" +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + http-parser-js@>=0.5.1: version "0.5.8" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" @@ -5060,6 +5092,37 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" + integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== + +istanbul-lib-report@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" + integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-reports@^3.1.5: + version "3.1.6" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a" + integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + jake@^10.8.5: version "10.8.5" resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" @@ -5477,6 +5540,13 @@ magic-string@^0.30.1: dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -6420,7 +6490,7 @@ semver@^7.2.1, semver@^7.3.7: dependencies: lru-cache "^6.0.0" -semver@^7.3.4, semver@^7.5.0: +semver@^7.3.4, semver@^7.5.0, semver@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -6799,6 +6869,15 @@ terser@^5.0.0: commander "^2.20.0" source-map-support "~0.5.20" +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -7096,6 +7175,15 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +v8-to-istanbul@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265" + integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^1.6.0" + vite-node@0.34.1: version "0.34.1" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.34.1.tgz#144900ca4bd54cc419c501d671350bcbc07eb1ee"