├── .nojekyll ├── .npmignore ├── .nvmrc ├── types ├── jsx.js ├── css.d.ts └── test │ ├── uc-form-input.test-d.tsx │ ├── uc-cloud-image-editor.test-d.tsx │ └── public-upload-api.test-d.tsx ├── .stylelintignore ├── scripts └── build-jsx-types.ts ├── index.html ├── src ├── blocks │ ├── CloudImageEditor │ │ ├── index.css │ │ ├── index.ts │ │ └── src │ │ │ ├── css │ │ │ ├── index.css │ │ │ └── icons.css │ │ │ ├── icons │ │ │ ├── aspect-ratio.svg │ │ │ ├── arrow-dropdown.svg │ │ │ ├── original.svg │ │ │ ├── done.svg │ │ │ ├── slider.svg │ │ │ ├── closeMax.svg │ │ │ ├── contrast.svg │ │ │ ├── filters.svg │ │ │ ├── gamma.svg │ │ │ ├── sad.svg │ │ │ ├── tuning.svg │ │ │ ├── rotate.svg │ │ │ ├── exposure.svg │ │ │ ├── crop.svg │ │ │ ├── brightness.svg │ │ │ ├── flip.svg │ │ │ ├── mirror.svg │ │ │ ├── edit-file.svg │ │ │ ├── enhance.svg │ │ │ ├── saturation.svg │ │ │ └── warmth.svg │ │ │ ├── lib │ │ │ ├── linspace.ts │ │ │ ├── pick.ts │ │ │ ├── parseTabs.ts │ │ │ ├── linspace.test.ts │ │ │ ├── classNames.ts │ │ │ ├── pick.test.ts │ │ │ ├── classNames.test.ts │ │ │ └── parseCropPreset.test.ts │ │ │ ├── cropper-constants.ts │ │ │ ├── utils │ │ │ ├── parseFilterValue.ts │ │ │ └── parseFilterValue.test.ts │ │ │ ├── EditorScroller.ts │ │ │ ├── util.ts │ │ │ ├── index.ts │ │ │ ├── elements │ │ │ ├── line-loader │ │ │ │ └── LineLoaderUi.ts │ │ │ └── presence-toggle │ │ │ │ └── PresenceToggle.ts │ │ │ ├── types.ts │ │ │ ├── template.ts │ │ │ ├── EditorButtonControl.ts │ │ │ └── EditorOperationControl.ts │ ├── Config │ │ ├── config.css │ │ └── assertions.ts │ ├── themes │ │ └── uc-basic │ │ │ ├── layers.css │ │ │ ├── post-reset.css │ │ │ ├── config.css │ │ │ ├── icons │ │ │ ├── play.svg │ │ │ ├── square.svg │ │ │ ├── arrow-dropdown.svg │ │ │ ├── select.svg │ │ │ ├── pause.svg │ │ │ ├── aspect-ratio.svg │ │ │ ├── error.svg │ │ │ ├── upload-error.svg │ │ │ ├── collapse.svg │ │ │ ├── info.svg │ │ │ ├── badge-error.svg │ │ │ ├── video-camera-full.svg │ │ │ ├── vk.svg │ │ │ ├── badge-success.svg │ │ │ ├── external-source-placeholder.svg │ │ │ ├── arrow-down.svg │ │ │ ├── local.svg │ │ │ ├── add.svg │ │ │ ├── file.svg │ │ │ ├── camera-full.svg │ │ │ ├── close.svg │ │ │ ├── expand.svg │ │ │ ├── gphotos.svg │ │ │ ├── edit-file.svg │ │ │ ├── gdrive.svg │ │ │ ├── ngdrive.svg │ │ │ ├── default.svg │ │ │ ├── upload.svg │ │ │ ├── back.svg │ │ │ ├── video-camera.svg │ │ │ ├── mobile-video-camera.svg │ │ │ ├── camera.svg │ │ │ ├── onedrive.svg │ │ │ ├── mobile-photo-camera.svg │ │ │ ├── box.svg │ │ │ ├── flickr.svg │ │ │ ├── remove-file.svg │ │ │ ├── microphone.svg │ │ │ ├── url.svg │ │ │ ├── about.svg │ │ │ ├── huddle.svg │ │ │ ├── dropbox.svg │ │ │ ├── facebook.svg │ │ │ └── microphone-mute.svg │ │ │ ├── index.css │ │ │ └── rules.css │ ├── Thumb │ │ └── thumb.css │ ├── ActivityHeader │ │ ├── ActivityHeader.ts │ │ └── activity-header.css │ ├── Spinner │ │ ├── Spinner.ts │ │ └── spinner.css │ ├── Img │ │ ├── test.css │ │ ├── configurations.ts │ │ ├── Img.js │ │ ├── utils │ │ │ └── parseObjectToString.ts │ │ └── props-map.ts │ ├── Icon │ │ ├── icon.css │ │ └── Icon.ts │ ├── CloudImageEditorActivity │ │ └── cloud-image-editor-activity.css │ ├── UrlSource │ │ └── url-source.css │ ├── ProgressBarCommon │ │ ├── progress-bar-common.css │ │ └── ProgressBarCommon.ts │ ├── ExternalSource │ │ ├── query-string.ts │ │ ├── buildThemeDefinition.ts │ │ └── MessageBridge.ts │ ├── CameraSource │ │ ├── constants.ts │ │ ├── calcCameraModes.ts │ │ └── __tests__ │ │ │ └── calcCameraModes.test.ts │ ├── StartFrom │ │ ├── StartFrom.ts │ │ └── start-from.css │ ├── Copyright │ │ ├── Copyright.ts │ │ └── copyright.css │ ├── Select │ │ ├── select.css │ │ └── Select.ts │ ├── UploadCtxProvider │ │ └── UploadCtxProvider.ts │ ├── SourceBtn │ │ └── source-btn.css │ ├── Range │ │ ├── range.css │ │ └── Range.ts │ ├── Modal │ │ └── modal.css │ ├── ProgressBar │ │ ├── progress-bar.css │ │ └── ProgressBar.ts │ ├── FileItem │ │ └── FileItemConfig.ts │ ├── svg-backgrounds │ │ └── svg-backgrounds.ts │ ├── SimpleBtn │ │ ├── SimpleBtn.ts │ │ └── simple-btn.css │ └── SourceList │ │ └── SourceList.ts ├── solutions │ ├── file-uploader │ │ ├── inline │ │ │ ├── index.ts │ │ │ └── index.css │ │ ├── minimal │ │ │ └── index.ts │ │ └── regular │ │ │ ├── index.ts │ │ │ └── index.css │ ├── adaptive-image │ │ └── index.ts │ └── cloud-image-editor │ │ ├── index.css │ │ ├── index.ts │ │ └── CloudImageEditor.ts ├── types │ ├── index.ts │ └── events.ts ├── utils │ ├── uniqueArray.ts │ ├── delay.ts │ ├── abilities.ts │ ├── validators │ │ ├── collection │ │ │ ├── index.ts │ │ │ ├── validateCollectionUploadError.ts │ │ │ └── validateMultiple.ts │ │ └── file │ │ │ ├── index.ts │ │ │ ├── validateMaxSizeLimit.ts │ │ │ ├── validateIsImage.ts │ │ │ ├── validateFileType.ts │ │ │ └── validateUploadError.ts │ ├── transparentPixelSrc.ts │ ├── getPluralForm.ts │ ├── stringToArray.ts │ ├── warnOnce.ts │ ├── uniqueArray.test.ts │ ├── isPromiseLike.ts │ ├── wildcardRegexp.ts │ ├── get-top-level-origin.test.ts │ ├── comma-separated.ts │ ├── get-top-level-origin.ts │ ├── toKebabCase.ts │ ├── toKebabCase.test.ts │ ├── memoize.ts │ ├── userAgent.ts │ ├── debounce.ts │ ├── withResolvers.ts │ ├── getLocaleDirection.ts │ ├── mixinClass.ts │ ├── getPluralForm.test.ts │ ├── stringToArray.test.ts │ ├── browser-info.ts │ ├── wildcardRegexp.test.ts │ ├── parseShrink.test.ts │ ├── isPromiseLike.test.ts │ ├── isSecureTokenExpired.ts │ ├── UploadSource.ts │ ├── memoize.test.ts │ ├── parseShrink.ts │ ├── parseCdnUrl.ts │ ├── throttle.ts │ ├── waitForAttribute.ts │ ├── preloadImage.ts │ ├── resizeImage.ts │ ├── withResolvers.test.ts │ ├── prettyBytes.ts │ ├── WindowHeightTracker.ts │ ├── isSecureTokenExpired.test.ts │ ├── template-utils.ts │ └── template-utils.test.ts ├── env.ts └── abstract │ ├── sharedConfigKey.ts │ ├── defineComponents.ts │ ├── SolutionBlock.ts │ ├── testModeProcessor.ts │ ├── loadFileUploaderFrom.ts │ ├── CTX.ts │ └── localeRegistry.ts ├── tests ├── utils │ ├── getCtxName.ts │ ├── commands.ts │ └── test-renderer.tsx ├── fixtures │ └── test_image.jpeg └── adaptive-image.e2e.test.tsx ├── .husky └── pre-commit ├── ship.config.mjs ├── .gitignore ├── .lintstagedrc.json ├── tsconfig.json ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── shipjs-manual-prepare.yml │ ├── codeql.yml │ ├── shipjs-trigger.yml │ └── checks.yml ├── pull_request_template.md ├── tsconfig.node.json ├── CONTRIBUTING.md ├── demo ├── test.svg ├── raw-minimal.html ├── preview-proxy │ ├── secure-delivery-proxy-url-template.html │ ├── secure-delivery-proxy-url-resolver.html │ └── secure-delivery-proxy.js ├── raw-regular.html ├── raw-inline.html ├── cloud-image-editor.html ├── custom-icons.html ├── index.html ├── form.html ├── validators.html ├── secure-uploads.html ├── upload-api.html └── new-social-sources-test.html ├── tsconfig.e2e-test.json ├── tsconfig.test.json ├── tsconfig.app.json ├── LICENSE ├── biome.json └── .stylelintrc.cjs /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /types/jsx.js: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | web 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /scripts/build-jsx-types.ts: -------------------------------------------------------------------------------- 1 | // TODO: build JSX types 2 | -------------------------------------------------------------------------------- /types/css.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' { 2 | export {}; 3 | } 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/index.css: -------------------------------------------------------------------------------- 1 | @import url("./src/css/index.css"); 2 | -------------------------------------------------------------------------------- /src/solutions/file-uploader/inline/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../../../index'; 2 | -------------------------------------------------------------------------------- /src/solutions/file-uploader/minimal/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../../../index'; 2 | -------------------------------------------------------------------------------- /src/solutions/file-uploader/regular/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../../../index'; 2 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './events'; 2 | export type * from './exported'; 3 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/index.ts: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | export * from './src/index'; 4 | -------------------------------------------------------------------------------- /src/solutions/file-uploader/regular/index.css: -------------------------------------------------------------------------------- 1 | @import url("../../../blocks/themes/uc-basic/index.css"); 2 | -------------------------------------------------------------------------------- /tests/utils/getCtxName.ts: -------------------------------------------------------------------------------- 1 | export const getCtxName = () => `test-${Math.random().toString(36).slice(2)}`; 2 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/css/index.css: -------------------------------------------------------------------------------- 1 | @import url("./common.css"); 2 | @import url("./icons.css"); 3 | -------------------------------------------------------------------------------- /src/blocks/Config/config.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-config { 3 | display: none; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/layers.css: -------------------------------------------------------------------------------- 1 | @layer uc, uc.base, uc.components, uc.rules, uc.solutions, uc.post-reset; 2 | -------------------------------------------------------------------------------- /src/utils/uniqueArray.ts: -------------------------------------------------------------------------------- 1 | export const uniqueArray = (arr: T[]): T[] => { 2 | return [...new Set(arr)]; 3 | }; 4 | -------------------------------------------------------------------------------- /tests/fixtures/test_image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uploadcare/file-uploader/HEAD/tests/fixtures/test_image.jpeg -------------------------------------------------------------------------------- /src/blocks/Thumb/thumb.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-thumb { 3 | width: 100%; 4 | height: 100%; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/delay.ts: -------------------------------------------------------------------------------- 1 | export const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); 2 | -------------------------------------------------------------------------------- /src/solutions/adaptive-image/index.ts: -------------------------------------------------------------------------------- 1 | import { Img } from '../../blocks/Img/Img'; 2 | 3 | Img.reg('uc-img'); 4 | 5 | export { Img }; 6 | -------------------------------------------------------------------------------- /src/utils/abilities.ts: -------------------------------------------------------------------------------- 1 | export const canUsePermissionsApi = () => { 2 | return typeof navigator.permissions !== 'undefined'; 3 | }; 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | ./node_modules/.bin/lint-staged 5 | npx tsc --project tsconfig.app.json 6 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../package.json'; 2 | 3 | export const PACKAGE_NAME = 'blocks'; 4 | export const PACKAGE_VERSION = version; 5 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/post-reset.css: -------------------------------------------------------------------------------- 1 | @layer uc.post-reset { 2 | :where([uc-wgt-common]) uc-source-btn[type] { 3 | all: unset; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/aspect-ratio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ship.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | buildCommand: () => 'npm run build', 3 | publishCommand: ({ defaultCommand }) => `${defaultCommand} --access public`, 4 | }; 5 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/config.css: -------------------------------------------------------------------------------- 1 | @layer uc.base { 2 | :where([uc-wgt-common]) { 3 | --cfg-init-activity: "start-from"; 4 | --cfg-done-activity: ""; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/abstract/sharedConfigKey.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigType } from '../types'; 2 | 3 | export const sharedConfigKey = (key: T): `*cfg/${T}` => `*cfg/${key}`; 4 | -------------------------------------------------------------------------------- /src/utils/validators/collection/index.ts: -------------------------------------------------------------------------------- 1 | export { validateCollectionUploadError } from './validateCollectionUploadError'; 2 | export { validateMultiple } from './validateMultiple'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | TMP 4 | **/*.d.ts 5 | !types/** 6 | **/*.d.ts.map 7 | !global.d.ts 8 | web 9 | tests/__coverage__ 10 | tsconfig.types.tsbuildinfo 11 | dist 12 | -------------------------------------------------------------------------------- /src/utils/transparentPixelSrc.ts: -------------------------------------------------------------------------------- 1 | export const TRANSPARENT_PIXEL_SRC = 2 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; 3 | -------------------------------------------------------------------------------- /src/blocks/ActivityHeader/ActivityHeader.ts: -------------------------------------------------------------------------------- 1 | import { ActivityBlock } from '../../abstract/ActivityBlock'; 2 | import './activity-header.css'; 3 | 4 | export class ActivityHeader extends ActivityBlock {} 5 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/types/events.ts: -------------------------------------------------------------------------------- 1 | import type { EventPayload } from '../blocks/UploadCtxProvider/EventEmitter'; 2 | 3 | export type EventMap = { 4 | [T in keyof EventPayload]: CustomEvent; 5 | }; 6 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,js,cjs,tsx}": ["biome check --write", "git add"], 3 | "*.css": ["stylelint --fix", "biome check --write", "git add"], 4 | "*.json": ["biome check --write", "git add"] 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/getPluralForm.ts: -------------------------------------------------------------------------------- 1 | export const getPluralForm = (locale: string, count: number): Intl.LDMLPluralRule => { 2 | const pluralForm = new Intl.PluralRules(locale).select(count); 3 | return pluralForm; 4 | }; 5 | -------------------------------------------------------------------------------- /src/solutions/cloud-image-editor/index.css: -------------------------------------------------------------------------------- 1 | @import url("../../blocks/themes/uc-basic/layers.css"); 2 | @import url("../../blocks/themes/uc-basic/theme.css"); 3 | @import url("../../blocks/CloudImageEditor/index.css"); 4 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/arrow-dropdown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/arrow-dropdown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/index.css: -------------------------------------------------------------------------------- 1 | @import url("./layers.css"); 2 | @import url("./config.css"); 3 | @import url("./theme.css"); 4 | @import url("./common.css"); 5 | @import url("./rules.css"); 6 | @import url("./post-reset.css"); 7 | -------------------------------------------------------------------------------- /types/test/uc-form-input.test-d.tsx: -------------------------------------------------------------------------------- 1 | import '../jsx'; 2 | import React from 'react'; 3 | 4 | import { FormInput } from '../../dist/index'; 5 | 6 | () => ; 7 | 8 | const formInput = new FormInput(); 9 | -------------------------------------------------------------------------------- /src/blocks/Spinner/Spinner.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from '@symbiotejs/symbiote'; 2 | import './spinner.css'; 3 | 4 | export class Spinner extends BaseComponent {} 5 | 6 | Spinner.template = /* HTML */ `
`; 7 | -------------------------------------------------------------------------------- /src/utils/stringToArray.ts: -------------------------------------------------------------------------------- 1 | export const stringToArray = (str: string, delimiter = ','): string[] => { 2 | return str 3 | .trim() 4 | .split(delimiter) 5 | .map((part) => part.trim()) 6 | .filter((part) => part.length > 0); 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/warnOnce.ts: -------------------------------------------------------------------------------- 1 | const warnings = new Set(); 2 | 3 | export function warnOnce(message: string): void { 4 | if (warnings.has(message)) { 5 | return; 6 | } 7 | 8 | warnings.add(message); 9 | console.warn(message); 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.e2e-test.json" }, 6 | { "path": "./tsconfig.test.json" }, 7 | { "path": "./tsconfig.node.json" } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/utils/validators/file/index.ts: -------------------------------------------------------------------------------- 1 | export { validateFileType } from './validateFileType'; 2 | export { validateIsImage } from './validateIsImage'; 3 | export { validateMaxSizeLimit } from './validateMaxSizeLimit'; 4 | export { validateUploadError } from './validateUploadError'; 5 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/aspect-ratio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/uniqueArray.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { uniqueArray } from './uniqueArray'; 3 | 4 | describe('uniqueArray', () => { 5 | it('should return deduplicated array', () => { 6 | expect(uniqueArray([1, 2, 3])).toEqual([1, 2, 3]); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/upload-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/Img/test.css: -------------------------------------------------------------------------------- 1 | uc-img { 2 | --uc-img-pubkey: "364c0864158c27472ffe"; 3 | --uc-img-test: "TEST"; 4 | 5 | display: contents; 6 | } 7 | 8 | uc-img > img { 9 | transition: 1s; 10 | } 11 | 12 | uc-img > img[unresolved] { 13 | transform: scale(0.8); 14 | opacity: 0; 15 | transition: 1s; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/isPromiseLike.ts: -------------------------------------------------------------------------------- 1 | export const isPromiseLike = (value: unknown): value is Promise => { 2 | return ( 3 | value instanceof Promise || 4 | Boolean( 5 | value && typeof value === 'object' && 'then' in value && typeof (value as Promise).then === 'function', 6 | ) 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/done.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/wildcardRegexp.ts: -------------------------------------------------------------------------------- 1 | const escapeRegExp = (str: string): string => str.replace(/[\\-\\[]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); 2 | 3 | export const wildcardRegexp = (str: string, flags = 'i'): RegExp => { 4 | const parts = str.split('*').map(escapeRegExp); 5 | return new RegExp(`^${parts.join('.+')}$`, flags); 6 | }; 7 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/rules.css: -------------------------------------------------------------------------------- 1 | @layer uc.rules { 2 | :where([uc-wgt-common]) [hidden] { 3 | display: none; 4 | } 5 | 6 | :where([uc-wgt-common]) [activity]:not([active], .active) { 7 | display: none; 8 | } 9 | 10 | :where([uc-wgt-common]) dialog:not([open]) [activity] { 11 | display: none; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/slider.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/closeMax.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/contrast.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/lib/linspace.ts: -------------------------------------------------------------------------------- 1 | export function linspace(a: number, b: number, n: number): number[] { 2 | const length = n; 3 | const lastIndex = n - 1; 4 | const ret = new Array(length); 5 | for (let i = lastIndex; i >= 0; i -= 1) { 6 | ret[i] = Math.ceil((i * b + (lastIndex - i) * a) / lastIndex); 7 | } 8 | return ret; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/get-top-level-origin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getTopLevelOrigin } from './get-top-level-origin'; 3 | 4 | describe('getTopLevelOrigin', () => { 5 | it('should return the top-level origin', () => { 6 | const origin = getTopLevelOrigin(); 7 | expect(origin).toBe(window.location.origin); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/comma-separated.ts: -------------------------------------------------------------------------------- 1 | export const deserializeCsv = (value: string): string[] => { 2 | if (!value) { 3 | return []; 4 | } 5 | 6 | return value 7 | .split(',') 8 | .map((item) => item.trim()) 9 | .filter(Boolean); 10 | }; 11 | 12 | export const serializeCsv = (value: readonly string[] | string[]): string => { 13 | return value.join(','); 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/get-top-level-origin.ts: -------------------------------------------------------------------------------- 1 | export const getTopLevelOrigin = (): string => { 2 | const topLevelWindow = globalThis.top ?? globalThis.parent ?? globalThis.self; 3 | try { 4 | return topLevelWindow.location.origin; 5 | } catch (e) { 6 | console.warn('Unable to access top-level window location:', e); 7 | return globalThis.location.origin; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/lib/pick.ts: -------------------------------------------------------------------------------- 1 | export function pick(obj: T, keys: readonly K[]): Pick { 2 | const result = {} as Pick; 3 | for (const key of keys) { 4 | const value = obj[key]; 5 | if (Object.hasOwn(obj, key) || value !== undefined) { 6 | result[key] = value; 7 | } 8 | } 9 | return result; 10 | } 11 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/css/icons.css: -------------------------------------------------------------------------------- 1 | @layer uc.solutions { 2 | :where([uc-cloud-image-editor]) uc-icon { 3 | display: flex; 4 | justify-content: center; 5 | width: 100%; 6 | height: 100%; 7 | } 8 | 9 | :where([uc-cloud-image-editor]) uc-icon svg { 10 | width: calc(var(--uc-button-size) / 2); 11 | height: calc(var(--uc-button-size) / 2); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/filters.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/collapse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/gamma.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/Icon/icon.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-icon { 3 | display: inline-flex; 4 | align-items: center; 5 | justify-content: center; 6 | width: var(--uc-button-size); 7 | height: var(--uc-button-size); 8 | } 9 | 10 | uc-icon svg { 11 | width: calc(var(--uc-button-size) / 2); 12 | height: calc(var(--uc-button-size) / 2); 13 | overflow: visible; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/badge-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | allow: 8 | - dependency-name: "@uploadcare/cname-prefix" 9 | - dependency-name: "@uploadcare/image-shrink" 10 | - dependency-name: "@uploadcare/quality-insights" 11 | - dependency-name: "@uploadcare/upload-client" 12 | open-pull-requests-limit: 5 13 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/sad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/video-camera-full.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/toKebabCase.ts: -------------------------------------------------------------------------------- 1 | export type KebabCase = T extends `${infer Head} ${infer Tail}` 2 | ? `${Lowercase}-${KebabCase}` 3 | : Lowercase; 4 | 5 | export const toKebabCase = (str: T): KebabCase => 6 | str 7 | .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g) 8 | ?.map((x) => x.toLowerCase()) 9 | .join('-') as KebabCase; 10 | -------------------------------------------------------------------------------- /src/blocks/ActivityHeader/activity-header.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-activity-header { 3 | display: flex; 4 | justify-content: space-between; 5 | gap: var(--uc-padding); 6 | padding: var(--uc-padding); 7 | color: var(--uc-foreground); 8 | font-weight: 500; 9 | font-size: 1em; 10 | } 11 | 12 | uc-activity-header > * { 13 | display: flex; 14 | align-items: center; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/tuning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/rotate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditorActivity/cloud-image-editor-activity.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-cloud-image-editor-activity { 3 | position: relative; 4 | display: flex; 5 | width: 100%; 6 | height: 100%; 7 | overflow: hidden; 8 | background-color: var(--uc-background); 9 | } 10 | 11 | [uc-modal] > dialog:has(uc-cloud-image-editor-activity[active]) { 12 | width: 100%; 13 | height: 100%; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ## Checklist 10 | 11 | - [ ] Tests (if applicable) 12 | - [ ] Documentation (if applicable) 13 | - [ ] Changelog stub (or use [conventional commit messages](https://www.conventionalcommits.org/)) 14 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/exposure.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/UrlSource/url-source.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-url-source { 3 | display: block; 4 | background-color: var(--uc-background); 5 | } 6 | 7 | uc-url-source > .uc-content { 8 | display: grid; 9 | grid-gap: 4px; 10 | grid-template-columns: 1fr min-content; 11 | padding: var(--uc-padding); 12 | padding-top: 0; 13 | } 14 | 15 | uc-url-source .uc-url-input { 16 | display: flex; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/vk.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/utils/toKebabCase.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { toKebabCase } from './toKebabCase'; 3 | 4 | describe('toKebabCase', () => { 5 | it('should convert camel string to kebab', () => { 6 | expect(toKebabCase('foo')).toBe('foo'); 7 | expect(toKebabCase('foo1')).toBe('foo1'); 8 | expect(toKebabCase('fooBar')).toBe('foo-bar'); 9 | expect(toKebabCase('fooBar1')).toBe('foo-bar1'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/blocks/ProgressBarCommon/progress-bar-common.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-progress-bar-common { 3 | position: fixed; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | z-index: 10000; 8 | display: block; 9 | height: 10px; 10 | background-color: var(--uc-background); 11 | transition: opacity 0.3s; 12 | } 13 | 14 | uc-progress-bar-common:not([active]) { 15 | opacity: 0; 16 | pointer-events: none; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/badge-success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/cropper-constants.ts: -------------------------------------------------------------------------------- 1 | export const CROP_PADDING = 20; 2 | export const THUMB_CORNER_SIZE = 24; 3 | export const THUMB_SIDE_SIZE = 34; 4 | export const THUMB_STROKE_WIDTH = 3; 5 | export const THUMB_OFFSET = THUMB_STROKE_WIDTH / 2; 6 | 7 | export const GUIDE_STROKE_WIDTH = 1; 8 | export const GUIDE_THIRD = 100 / 3; 9 | export const MIN_CROP_SIZE = 1; 10 | export const MAX_INTERACTION_SIZE = 24; 11 | export const MIN_INTERACTION_SIZE = 6; 12 | -------------------------------------------------------------------------------- /src/utils/validators/collection/validateCollectionUploadError.ts: -------------------------------------------------------------------------------- 1 | import type { FuncCollectionValidator } from '../../../abstract/managers/ValidationManager'; 2 | 3 | export const validateCollectionUploadError: FuncCollectionValidator = (collection, api) => { 4 | if (collection.failedCount > 0) { 5 | return { 6 | type: 'SOME_FILES_HAS_ERRORS', 7 | message: api.l10n('some-files-were-not-uploaded'), 8 | }; 9 | } 10 | return undefined; 11 | }; 12 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/crop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/ExternalSource/query-string.ts: -------------------------------------------------------------------------------- 1 | export function queryString(params: Record): string { 2 | const list: string[] = []; 3 | for (const [key, value] of Object.entries(params)) { 4 | if (value === undefined || value === null || (typeof value === 'string' && value.length === 0)) { 5 | continue; 6 | } 7 | list.push(`${key}=${encodeURIComponent(value)}`); 8 | } 9 | return list.join('&'); 10 | } 11 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/external-source-placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/utils/memoize.ts: -------------------------------------------------------------------------------- 1 | export function memoize any>(fn: F): F { 2 | const cache = new Map>(); 3 | const memoized = (...args: Parameters): ReturnType => { 4 | const key = JSON.stringify(args); 5 | if (cache.has(key)) { 6 | return cache.get(key) as ReturnType; 7 | } 8 | const result = fn(...args); 9 | cache.set(key, result); 10 | return result; 11 | }; 12 | return memoized as F; 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/userAgent.ts: -------------------------------------------------------------------------------- 1 | import type { CustomUserAgentFn, CustomUserAgentOptions } from '@uploadcare/upload-client'; 2 | import { getUserAgent } from '@uploadcare/upload-client'; 3 | import { PACKAGE_NAME, PACKAGE_VERSION } from '../env'; 4 | 5 | export function customUserAgent(options: CustomUserAgentOptions): ReturnType { 6 | return getUserAgent({ 7 | ...options, 8 | libraryName: PACKAGE_NAME, 9 | libraryVersion: PACKAGE_VERSION, 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/blocks/CameraSource/constants.ts: -------------------------------------------------------------------------------- 1 | export const CameraSourceTypes = Object.freeze({ 2 | PHOTO: 'photo', 3 | VIDEO: 'video', 4 | }); 5 | 6 | export const CameraSourceEvents = Object.freeze({ 7 | IDLE: 'idle', 8 | SHOT: 'shot', 9 | 10 | PLAY: 'play', 11 | PAUSE: 'pause', 12 | RESUME: 'resume', 13 | STOP: 'stop', 14 | 15 | RETAKE: 'retake', 16 | ACCEPT: 'accept', 17 | }); 18 | 19 | export type ModeCameraType = (typeof CameraSourceTypes)[keyof typeof CameraSourceTypes]; 20 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "pretty": true, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "allowJs": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "noEmit": true, 11 | "types": ["@total-typescript/ts-reset", "@vitest/browser/providers/playwright"] 12 | }, 13 | "include": ["vite.config.js", "test-locales.js", "ship.config.mjs", "scripts"], 14 | "exclude": ["node_modules", "tests"] 15 | } 16 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/utils/parseFilterValue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses a string like "iothari 100" into an object { filter: "iothari", value: 100 } 3 | */ 4 | export function parseFilterValue(str: string): { filter: string; value: number } | null { 5 | const match = str.match(/^([A-Za-z]+)\s+(\d+)$/); 6 | if (!match) return null; 7 | const [, filter, amount] = match; 8 | if (!filter || typeof amount === 'undefined') { 9 | return null; 10 | } 11 | return { filter, value: Number(amount) }; 12 | } 13 | -------------------------------------------------------------------------------- /src/blocks/Spinner/spinner.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | @keyframes uc-spinner-keyframes { 3 | from { 4 | transform: rotate(0deg); 5 | } 6 | to { 7 | transform: rotate(360deg); 8 | } 9 | } 10 | 11 | .uc-spinner { 12 | width: 1em; 13 | height: 1em; 14 | border: solid 2px transparent; 15 | border-top-color: currentColor; 16 | border-left-color: currentColor; 17 | border-radius: 50%; 18 | animation: uc-spinner-keyframes 400ms linear infinite; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export function debounce any>(callback: T, wait: number): T & { cancel: () => void } { 2 | let timer: ReturnType | undefined; 3 | 4 | const debounced = ((...args: Parameters) => { 5 | if (timer) clearTimeout(timer); 6 | timer = setTimeout(() => callback(...args), wait); 7 | }) as T & { cancel: () => void }; 8 | 9 | debounced.cancel = () => { 10 | if (timer) clearTimeout(timer); 11 | }; 12 | 13 | return debounced; 14 | } 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for investing your time in contributing to this project! ❤️ 4 | 5 | ## ⚠️ When contributing to this repository, please first discuss the change you wish to make in [issue](https://github.com/uploadcare/blocks/issues) before making a change. 6 | 7 | - [Issue templates](./.github/ISSUE_TEMPLATE) 8 | - [PR template](./pull_request_template.md) 9 | 10 | Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 11 | -------------------------------------------------------------------------------- /src/blocks/Img/configurations.ts: -------------------------------------------------------------------------------- 1 | export const CSS_PREF = '--uc-img-'; 2 | export const UNRESOLVED_ATTR = 'unresolved'; 3 | export const HI_RES_K = 2; 4 | export const ULTRA_RES_K = 3; 5 | export const DEV_MODE = 6 | !window.location.host.trim() || window.location.host.includes(':') || window.location.hostname.includes('localhost'); 7 | 8 | export const MAX_WIDTH = 3000; 9 | export const MAX_WIDTH_JPG = 5000; 10 | 11 | export const ImgTypeEnum = Object.freeze({ 12 | PREVIEW: 'PREVIEW', 13 | MAIN: 'MAIN', 14 | }); 15 | -------------------------------------------------------------------------------- /src/blocks/Img/Img.js: -------------------------------------------------------------------------------- 1 | import { ImgBase } from './ImgBase.js'; 2 | 3 | export class Img extends ImgBase { 4 | initCallback() { 5 | super.initCallback(); 6 | 7 | this.sub$$('src', () => { 8 | this.init(); 9 | }); 10 | 11 | this.sub$$('uuid', () => { 12 | this.init(); 13 | }); 14 | 15 | this.sub$$('lazy', (val) => { 16 | if (!this.$$('is-background-for') && !this.$$('is-preview-blur')) { 17 | this.img.loading = val ? 'lazy' : 'eager'; 18 | } 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /demo/test.svg: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/local.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/brightness.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/withResolvers.ts: -------------------------------------------------------------------------------- 1 | export const withResolvers = (): { 2 | promise: Promise; 3 | resolve: (value: T | PromiseLike) => void; 4 | reject: (reason?: R) => void; 5 | } => { 6 | let resolveFn: (value: T | PromiseLike) => void = () => {}; 7 | let rejectFn: (reason?: R) => void = () => {}; 8 | 9 | const promise = new Promise((res, rej) => { 10 | resolveFn = res; 11 | rejectFn = rej as unknown as (reason?: R) => void; 12 | }); 13 | 14 | return { promise, resolve: resolveFn, reject: rejectFn }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/blocks/CameraSource/calcCameraModes.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigType } from '../../types/index'; 2 | import { deserializeCsv } from '../../utils/comma-separated'; 3 | import { CameraSourceTypes } from './constants'; 4 | 5 | export const calcCameraModes = ( 6 | cfg: ConfigType, 7 | ): { 8 | isVideoRecordingEnabled: boolean; 9 | isPhotoEnabled: boolean; 10 | } => ({ 11 | isVideoRecordingEnabled: deserializeCsv(cfg.cameraModes).includes(CameraSourceTypes.VIDEO), 12 | isPhotoEnabled: deserializeCsv(cfg.cameraModes).includes(CameraSourceTypes.PHOTO), 13 | }); 14 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/camera-full.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/flip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/Img/utils/parseObjectToString.ts: -------------------------------------------------------------------------------- 1 | type ParseableParams = Record; 2 | 3 | export const parseObjectToString = (params: ParseableParams): (string | number | boolean | null | undefined)[] => 4 | Object.entries(params) 5 | .filter(([_, value]) => value !== undefined && value !== '') 6 | .map(([key, value]) => { 7 | if (key === 'cdn-operations') { 8 | return value; 9 | } 10 | if (key === 'analytics') { 11 | return value; 12 | } 13 | 14 | return `${key}/${value}`; 15 | }); 16 | -------------------------------------------------------------------------------- /src/utils/getLocaleDirection.ts: -------------------------------------------------------------------------------- 1 | type LocaleWithDirection = Intl.Locale & { 2 | textInfo?: { direction?: string }; 3 | getTextInfo?: () => { direction?: string }; 4 | }; 5 | 6 | export const getLocaleDirection = (localeId: string): string => { 7 | const locale = new Intl.Locale(localeId) as LocaleWithDirection; 8 | let direction = 'ltr'; 9 | const fromGetter = locale.getTextInfo?.().direction; 10 | if (fromGetter) { 11 | direction = fromGetter; 12 | } else if (locale.textInfo?.direction) { 13 | direction = locale.textInfo.direction; 14 | } 15 | return direction; 16 | }; 17 | -------------------------------------------------------------------------------- /src/blocks/StartFrom/StartFrom.ts: -------------------------------------------------------------------------------- 1 | import type { ActivityType } from '../../abstract/ActivityBlock'; 2 | import { ActivityBlock } from '../../abstract/ActivityBlock'; 3 | import './start-from.css'; 4 | 5 | export class StartFrom extends ActivityBlock { 6 | override historyTracked = true; 7 | override activityType: ActivityType = ActivityBlock.activities.START_FROM; 8 | 9 | override initCallback(): void { 10 | super.initCallback(); 11 | this.registerActivity(this.activityType ?? ''); 12 | } 13 | } 14 | 15 | StartFrom.template = /* HTML */ `
`; 16 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/mirror.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /types/test/uc-cloud-image-editor.test-d.tsx: -------------------------------------------------------------------------------- 1 | import '../jsx'; 2 | import React from 'react'; 3 | 4 | // @ts-expect-error - no props 5 | () => ; 6 | 7 | // @ts-expect-error - no css-url 8 | () => ; 9 | 10 | // @ts-expect-error - no css-src 11 | () => ; 12 | 13 | () => ; 14 | () => ; 15 | () => ; 16 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/lib/parseTabs.ts: -------------------------------------------------------------------------------- 1 | import { deserializeCsv } from '../../../../utils/comma-separated'; 2 | import type { TabIdValue } from '../toolbar-constants'; 3 | import { ALL_TABS } from '../toolbar-constants'; 4 | 5 | const isTabIdValue = (value: string): value is TabIdValue => (ALL_TABS as readonly string[]).includes(value); 6 | 7 | export const parseTabs = (tabs?: string): readonly TabIdValue[] => { 8 | if (!tabs) { 9 | return ALL_TABS; 10 | } 11 | const tabList = deserializeCsv(tabs).filter(isTabIdValue); 12 | if (tabList.length === 0) { 13 | return ALL_TABS; 14 | } 15 | return tabList; 16 | }; 17 | -------------------------------------------------------------------------------- /src/blocks/Copyright/Copyright.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../abstract/Block'; 2 | import './copyright.css'; 3 | 4 | export class Copyright extends Block { 5 | override initCallback() { 6 | super.initCallback(); 7 | 8 | this.subConfigValue('removeCopyright', (value) => { 9 | this.toggleAttribute('hidden', !!value); 10 | }); 11 | } 12 | 13 | static override template = /* HTML */ ` 14 | Powered by Uploadcare 20 | `; 21 | } 22 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/gphotos.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/solutions/cloud-image-editor/index.ts: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | export * from '../../blocks/CloudImageEditor/index'; 4 | export * from './CloudImageEditor'; 5 | 6 | /* TODO: We need to make some dependency injection/checking magic 7 | I see it as a declared list of tags on which the block depends 8 | Then we can check whether the dependent tag is registered in the CustomElementRegistry or not. 9 | If not, register it from default ones or just log the warning */ 10 | 11 | export { defineComponents } from '../../abstract/defineComponents'; 12 | export { Config } from '../../blocks/Config/Config'; 13 | export { Icon } from '../../blocks/Icon/Icon'; 14 | -------------------------------------------------------------------------------- /src/utils/mixinClass.ts: -------------------------------------------------------------------------------- 1 | export type GConstructor = new (...args: unknown[]) => T; 2 | 3 | /** 4 | * This is a helper to create a class type extended with the provided set of instance properties. It's useful when there 5 | * are some dynamic generated properties or native overrides in the class. We're use it to define dynamic access 6 | * properties and events to subscribe to. 7 | * 8 | */ 9 | export type MixinClass< 10 | Base extends GConstructor, 11 | InstanceProperties extends Record = Record, 12 | > = (new (...args: ConstructorParameters) => InstanceType & InstanceProperties) & Base; 13 | -------------------------------------------------------------------------------- /src/utils/getPluralForm.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getPluralForm } from './getPluralForm'; 3 | 4 | describe('getPluralForm', () => { 5 | it('should return selected form for es-US', () => { 6 | expect(getPluralForm('en-US', 1)).toBe('one'); 7 | expect(getPluralForm('en-US', 2)).toBe('other'); 8 | }); 9 | 10 | it('should return selected form for ru-RU', () => { 11 | expect(getPluralForm('ru-RU', 1)).toBe('one'); 12 | expect(getPluralForm('ru-RU', 2)).toBe('few'); 13 | expect(getPluralForm('ru-RU', 5)).toBe('many'); 14 | expect(getPluralForm('ru-RU', 1.5)).toBe('other'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tsconfig.e2e-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "types": ["@total-typescript/ts-reset", "@vitest/browser/providers/playwright", "vite/client"], 8 | "jsxFactory": "renderer.create", 9 | "jsxFragmentFactory": "renderer.fragment", 10 | "allowJs": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "noEmit": true, 14 | "paths": { 15 | "@/*": ["./src/*"], 16 | "~/*": ["./*"] 17 | } 18 | }, 19 | "include": ["src", "tests"], 20 | "exclude": ["node_modules", "**/*.test.js"] 21 | } 22 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/edit-file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/enhance.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /demo/raw-minimal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/edit-file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/validators/file/validateMaxSizeLimit.ts: -------------------------------------------------------------------------------- 1 | import type { FuncFileValidator } from '../../../abstract/managers/ValidationManager'; 2 | import { prettyBytes } from '../../prettyBytes'; 3 | 4 | export const validateMaxSizeLimit: FuncFileValidator = (outputEntry, api) => { 5 | const maxFileSize = api.cfg.maxLocalFileSizeBytes; 6 | const fileSize = outputEntry.size; 7 | if (maxFileSize && fileSize && fileSize > maxFileSize) { 8 | return { 9 | type: 'FILE_SIZE_EXCEEDED', 10 | message: api.l10n('files-max-size-limit-error', { maxFileSize: prettyBytes(maxFileSize) }), 11 | payload: { entry: outputEntry }, 12 | }; 13 | } 14 | return undefined; 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "preserve", 4 | "moduleResolution": "bundler", 5 | "module": "esnext", 6 | "target": "esnext", 7 | "lib": ["ESNext", "ESNext.Array", "DOM", "DOM.Iterable"], 8 | "types": ["node", "vitest", "@total-typescript/ts-reset", "./types/jsx.d.ts", "vite/client"], 9 | "allowJs": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "paths": { 15 | "@/*": ["./src/*"], 16 | "~/*": ["./*"] 17 | } 18 | }, 19 | "include": ["src", "types/test", "./**/*.test.ts", "./**/*.test-d.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/lib/linspace.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { linspace } from './linspace'; 3 | 4 | describe('linspace', () => { 5 | it('creates inclusive integer steps between endpoints', () => { 6 | expect(linspace(0, 10, 3)).toEqual([0, 5, 10]); 7 | }); 8 | 9 | it('handles descending ranges', () => { 10 | expect(linspace(5, -5, 3)).toEqual([5, 0, -5]); 11 | }); 12 | 13 | it('rounds up fractional steps to integers', () => { 14 | expect(linspace(0, 1, 4)).toEqual([0, 1, 1, 1]); 15 | }); 16 | 17 | it('returns an empty array when n is zero', () => { 18 | expect(linspace(0, 5, 0)).toEqual([]); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/blocks/Select/select.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-select { 3 | display: inline-flex; 4 | } 5 | 6 | uc-select select { 7 | position: relative; 8 | display: inline-flex; 9 | align-items: center; 10 | justify-content: center; 11 | height: var(--uc-button-size); 12 | padding: 0 14px; 13 | font-size: 1em; 14 | font-family: inherit; 15 | white-space: nowrap; 16 | border: none; 17 | border-radius: var(--uc-radius); 18 | cursor: pointer; 19 | user-select: none; 20 | transition: background-color var(--uc-transition); 21 | color: var(--uc-secondary-foreground); 22 | background-color: var(--uc-secondary); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/preview-proxy/secure-delivery-proxy-url-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/gdrive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/ngdrive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/utils/stringToArray.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { stringToArray } from './stringToArray'; 3 | 4 | describe('stringToArray', () => { 5 | it('should convert string to array', () => { 6 | expect(stringToArray('a,b,c')).toEqual(['a', 'b', 'c']); 7 | }); 8 | 9 | it('should trim surrounding spaces', () => { 10 | expect(stringToArray(' a , b , c ')).toEqual(['a', 'b', 'c']); 11 | }); 12 | 13 | it('should trim empty values', () => { 14 | expect(stringToArray(',,,a,b,c')).toEqual(['a', 'b', 'c']); 15 | }); 16 | 17 | it('should accept custom delimiter', () => { 18 | expect(stringToArray('a b c', ' ')).toEqual(['a', 'b', 'c']); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /demo/raw-regular.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tests/adaptive-image.e2e.test.tsx: -------------------------------------------------------------------------------- 1 | import { commands, page, userEvent } from '@vitest/browser/context'; 2 | import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; 3 | import '../types/jsx'; 4 | // biome-ignore lint/correctness/noUnusedImports: Used in JSX 5 | import { renderer } from './utils/test-renderer'; 6 | 7 | beforeAll(async () => { 8 | await import('@/solutions/adaptive-image/index.js'); 9 | }); 10 | 11 | beforeEach(() => { 12 | page.render(); 13 | }); 14 | 15 | describe('Adaptive Image', () => { 16 | it('should be rendered', async () => { 17 | // await expect.element(page.getByTestId('uc-img')).toBeVisible(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "pretty": true, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "lib": ["ESNext", "ESNext.Array", "DOM", "DOM.Iterable"], 8 | "allowArbitraryExtensions": false, 9 | "noImplicitOverride": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noUncheckedIndexedAccess": true, 14 | "allowJs": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "noEmit": true, 18 | "sourceMap": true, 19 | "types": ["@total-typescript/ts-reset", "vite/client"] 20 | }, 21 | "include": ["src"], 22 | "exclude": ["node_modules", "**/*.test.js"] 23 | } 24 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/saturation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/raw-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 18 | 19 | 20 | 21 | 28 | -------------------------------------------------------------------------------- /src/utils/browser-info.ts: -------------------------------------------------------------------------------- 1 | const calcIsDesktopSafari = (): boolean => { 2 | const ua = navigator.userAgent; 3 | return /Macintosh|Windows/.test(ua) && /Version\/[\d.]+.*Safari/.test(ua) && !/Chrome|Chromium|Edg|OPR/.test(ua); 4 | }; 5 | 6 | const calcHtmlMediaCaptureSupport = (): boolean => { 7 | return 'capture' in document.createElement('input'); 8 | }; 9 | 10 | export const calcBrowserInfo = (): { safariDesktop: boolean } => ({ 11 | safariDesktop: calcIsDesktopSafari(), 12 | }); 13 | 14 | export const calcBrowserFeatures = (): { htmlMediaCapture: boolean } => ({ 15 | htmlMediaCapture: calcHtmlMediaCaptureSupport(), 16 | }); 17 | 18 | export const browserInfo = calcBrowserInfo(); 19 | 20 | export const browserFeatures = calcBrowserFeatures(); 21 | -------------------------------------------------------------------------------- /src/abstract/defineComponents.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/suspicious/noExplicitAny: Type is used to represent any class 2 | export function defineComponents(blockExports: Record) { 3 | for (const blockName in blockExports) { 4 | let tagName = [...blockName].reduce((name, char) => { 5 | if (char.toUpperCase() === char) { 6 | char = `-${char.toLowerCase()}`; 7 | } 8 | name += char; 9 | return name; 10 | }, ''); 11 | if (tagName.startsWith('-')) { 12 | tagName = tagName.replace('-', ''); 13 | } 14 | 15 | if (!tagName.startsWith('uc-')) { 16 | tagName = `uc-${tagName}`; 17 | } 18 | if (blockExports[blockName].reg) { 19 | blockExports[blockName].reg(tagName); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/utils/parseFilterValue.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { parseFilterValue } from './parseFilterValue'; 3 | 4 | describe('parseFilterValue', () => { 5 | it('parses filter name and value from valid strings', () => { 6 | expect(parseFilterValue('iothari 100')).toEqual({ filter: 'iothari', value: 100 }); 7 | expect(parseFilterValue('sedis 0')).toEqual({ filter: 'sedis', value: 0 }); 8 | }); 9 | 10 | it('returns null for invalid formats', () => { 11 | expect(parseFilterValue('invalid ')).toBeNull(); 12 | expect(parseFilterValue('invalid')).toBeNull(); 13 | expect(parseFilterValue('no number')).toBeNull(); 14 | expect(parseFilterValue('123 456')).toBeNull(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/utils/wildcardRegexp.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { wildcardRegexp } from './wildcardRegexp'; 3 | 4 | describe('wildcardRegexp', () => { 5 | it('should return regexp to match wildcard', () => { 6 | const regexp = wildcardRegexp('*.jpg'); 7 | expect(regexp).toBeInstanceOf(RegExp); 8 | }); 9 | 10 | it('should work for mime types', () => { 11 | expect(wildcardRegexp('*.jpg').test('test.jpg')).toBe(true); 12 | expect(wildcardRegexp('image/*').test('image/jpeg')).toBe(true); 13 | expect( 14 | wildcardRegexp('application/vnd.openxmlformats-officedocument.*').test( 15 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', 16 | ), 17 | ).toBe(true); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/abstract/SolutionBlock.ts: -------------------------------------------------------------------------------- 1 | import svgIconsSprite from '../blocks/themes/uc-basic/svg-sprite'; 2 | import { Block } from './Block'; 3 | import { solutionBlockCtx } from './CTX'; 4 | 5 | export class SolutionBlock extends Block { 6 | static override styleAttrs = ['uc-wgt-common']; 7 | protected override requireCtxName = true; 8 | override init$ = solutionBlockCtx(this); 9 | private static _template = ''; 10 | 11 | override initCallback(): void { 12 | super.initCallback(); 13 | this.a11y?.registerBlock(this); 14 | } 15 | 16 | static override set template(value: string) { 17 | this._template = /* html */ `${svgIconsSprite + value}`; 18 | } 19 | 20 | static override get template(): string { 21 | return this._template; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/blocks/StartFrom/start-from.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-start-from { 3 | display: block; 4 | overflow-y: auto; 5 | } 6 | 7 | uc-start-from .uc-content { 8 | display: grid; 9 | grid-auto-flow: row; 10 | gap: calc(var(--uc-padding) * 2); 11 | width: 100%; 12 | height: 100%; 13 | padding: calc(var(--uc-padding) * 2); 14 | background-color: var(--uc-background); 15 | } 16 | 17 | [uc-modal] > dialog:has(uc-start-from[active]) { 18 | width: var(--uc-dialog-width); 19 | } 20 | 21 | [uc-modal] uc-start-from uc-drop-area { 22 | border-radius: var(--uc-radius); 23 | } 24 | 25 | @media only screen and (max-width: 430px) { 26 | [uc-modal] uc-start-from uc-drop-area { 27 | display: none; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/video-camera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/parseShrink.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { parseShrink } from './parseShrink'; 3 | 4 | describe('parseShrink', () => { 5 | it('should be false', () => { 6 | // @ts-expect-error 7 | expect(parseShrink()).toBe(false); 8 | }); 9 | 10 | it('should be right', () => { 11 | expect(parseShrink('1000x1000 100%')).toEqual({ 12 | quality: 1, 13 | size: 1000000, 14 | }); 15 | }); 16 | 17 | it('should be right without quality', () => { 18 | expect(parseShrink('1000x1000')).toEqual({ 19 | quality: undefined, 20 | size: 1000000, 21 | }); 22 | }); 23 | 24 | it('should be warn, because size shrink more max size', () => { 25 | expect(parseShrink('268435456x268435456 100%')).toBe(false); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/blocks/Img/props-map.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_CDN_BASE = 'https://ucarecdn.com'; 2 | 3 | export const PROPS_MAP = Object.freeze({ 4 | 'dev-mode': {}, 5 | pubkey: {}, 6 | uuid: {}, 7 | src: {}, 8 | // alt: {}, 9 | // 'placeholder-src': {}, // available via CSS 10 | lazy: { 11 | default: 1, 12 | }, 13 | intersection: {}, 14 | breakpoints: { 15 | // '200, 300, 400' 16 | }, 17 | 'cdn-cname': { 18 | default: DEFAULT_CDN_BASE, 19 | }, 20 | 'proxy-cname': {}, 21 | 'secure-delivery-proxy': {}, 22 | 'hi-res-support': { 23 | default: 1, 24 | }, 25 | 'ultra-res-support': {}, // ? 26 | format: {}, 27 | 'cdn-operations': {}, 28 | progressive: {}, 29 | quality: {}, 30 | 'is-background-for': {}, 31 | 'is-preview-blur': { 32 | default: 1, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/mobile-video-camera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /demo/preview-proxy/secure-delivery-proxy-url-resolver.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /demo/cloud-image-editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 20 | 21 | 22 | 23 | 30 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/camera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/onedrive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/utils/isPromiseLike.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { isPromiseLike } from './isPromiseLike'; 3 | 4 | describe('isPromiseLike', () => { 5 | it('should return true for Promise instances', () => { 6 | expect(isPromiseLike(Promise.resolve())).toBe(true); 7 | }); 8 | 9 | it('should return true for thenable objects', () => { 10 | // biome-ignore lint/suspicious/noThenProperty: This is thenable object for testing purposes 11 | const thenable = { then: () => {} }; 12 | expect(isPromiseLike(thenable)).toBe(true); 13 | }); 14 | 15 | it('should return false for non-thenable objects', () => { 16 | expect(isPromiseLike({})).toBe(false); 17 | expect(isPromiseLike(null)).toBe(false); 18 | expect(isPromiseLike(42)).toBe(false); 19 | expect(isPromiseLike('string')).toBe(false); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/mobile-photo-camera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tests/utils/commands.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import type { BrowserCommand } from 'vitest/node'; 3 | 4 | export const waitFileChooserAndUpload: BrowserCommand<[string[]]> = async ({ page, testPath }, relativePaths) => { 5 | if (!testPath) { 6 | throw new Error('Test path is not defined'); 7 | } 8 | const fileChooserPromise = page.waitForEvent('filechooser'); 9 | const fileChooser = await fileChooserPromise; 10 | for (const relativePath of relativePaths) { 11 | const absolutePath = path.join(path.dirname(testPath), relativePath); 12 | await fileChooser.setFiles(absolutePath); 13 | } 14 | }; 15 | 16 | export const commands = { 17 | waitFileChooserAndUpload, 18 | }; 19 | 20 | declare module '@vitest/browser/context' { 21 | interface BrowserCommands { 22 | waitFileChooserAndUpload: (relativePaths: string[]) => Promise; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/isSecureTokenExpired.ts: -------------------------------------------------------------------------------- 1 | import type { SecureUploadsSignatureAndExpire } from '../types'; 2 | 3 | const msToUnixTimestamp = (ms?: number): number => { 4 | if (typeof ms !== 'number') { 5 | return 0; 6 | } 7 | return Math.floor(ms / 1000); 8 | }; 9 | 10 | /** 11 | * Check if secure token is expired. It uses a threshold of 10 seconds by default. i.e. if the token is not expired yet 12 | * but will expire in the next 10 seconds, it will return false. 13 | */ 14 | export const isSecureTokenExpired = ( 15 | secureToken: SecureUploadsSignatureAndExpire, 16 | { threshold }: { threshold?: number }, 17 | ): boolean => { 18 | const { secureExpire } = secureToken; 19 | const nowUnix = msToUnixTimestamp(Date.now()); 20 | const expireUnix = Number(secureExpire); 21 | const thresholdUnix = msToUnixTimestamp(threshold); 22 | return nowUnix + thresholdUnix >= expireUnix; 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils/UploadSource.ts: -------------------------------------------------------------------------------- 1 | export const ExternalUploadSource = Object.freeze({ 2 | FACEBOOK: 'facebook', 3 | DROPBOX: 'dropbox', 4 | GDRIVE: 'gdrive', 5 | GPHOTOS: 'gphotos', 6 | FLICKR: 'flickr', 7 | VK: 'vk', 8 | EVERNOTE: 'evernote', 9 | BOX: 'box', 10 | ONEDRIVE: 'onedrive', 11 | HUDDLE: 'huddle', 12 | } as const); 13 | 14 | export const UploadSourceMobile = Object.freeze({ 15 | MOBILE_VIDEO_CAMERA: 'mobile-video-camera', 16 | MOBILE_PHOTO_CAMERA: 'mobile-photo-camera', 17 | } as const); 18 | 19 | export const UploadSource = Object.freeze({ 20 | LOCAL: 'local', 21 | DROP_AREA: 'drop-area', 22 | CAMERA: 'camera', 23 | EXTERNAL: 'external', 24 | API: 'js-api', 25 | URL: 'url', 26 | DRAW: 'draw', 27 | 28 | ...UploadSourceMobile, 29 | ...ExternalUploadSource, 30 | } as const); 31 | 32 | export type SourceTypes = (typeof UploadSource)[keyof typeof UploadSource]; 33 | -------------------------------------------------------------------------------- /src/blocks/Copyright/copyright.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-copyright { 3 | display: flex; 4 | width: 100%; 5 | justify-content: center; 6 | } 7 | 8 | uc-copyright .uc-credits { 9 | all: unset; 10 | position: absolute; 11 | bottom: 12px; 12 | background-color: var(--uc-background); 13 | padding: 2px 5px; 14 | border-radius: 6px; 15 | color: var(--uc-muted-foreground); 16 | font-weight: normal; 17 | font-size: 12px; 18 | opacity: 0.9; 19 | cursor: pointer; 20 | transition: 21 | opacity var(--uc-transition), 22 | background-color var(--uc-transition); 23 | } 24 | 25 | uc-copyright .uc-credits:focus-visible { 26 | outline: 1px auto Highlight; 27 | outline: 1px auto -webkit-focus-ring-color; 28 | } 29 | 30 | uc-copyright .uc-credits:hover { 31 | opacity: 1; 32 | background-color: var(--uc-muted); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/validators/file/validateIsImage.ts: -------------------------------------------------------------------------------- 1 | import type { FuncFileValidator } from '../../../abstract/managers/ValidationManager'; 2 | 3 | export const validateIsImage: FuncFileValidator = (outputEntry, api) => { 4 | const imagesOnly = api.cfg.imgOnly; 5 | const isImage = outputEntry.isImage; 6 | 7 | if (!imagesOnly || isImage) { 8 | return; 9 | } 10 | if (!outputEntry.fileInfo && outputEntry.externalUrl) { 11 | // skip validation for not uploaded files with external url, cause we don't know if they're images or not 12 | return; 13 | } 14 | if (!outputEntry.fileInfo && !outputEntry.mimeType) { 15 | // skip validation for not uploaded files without mime-type, cause we don't know if they're images or not 16 | return; 17 | } 18 | 19 | return { 20 | type: 'NOT_AN_IMAGE', 21 | message: api.l10n('images-only-accepted'), 22 | payload: { entry: outputEntry }, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/box.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/EditorScroller.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../abstract/Block'; 2 | 3 | const X_THRESHOLD = 1; 4 | 5 | export class EditorScroller extends Block { 6 | override initCallback(): void { 7 | super.initCallback(); 8 | 9 | this.addEventListener( 10 | 'wheel', 11 | (e: WheelEvent) => { 12 | e.preventDefault(); 13 | 14 | const { deltaY, deltaX } = e; 15 | if (Math.abs(deltaX) > X_THRESHOLD) { 16 | this.scrollLeft += deltaX; 17 | } else { 18 | this.scrollLeft += deltaY; 19 | } 20 | }, 21 | { 22 | passive: false, 23 | }, 24 | ); 25 | 26 | // This fixes some strange bug on MacOS - wheel event doesn't fire for physical mouse wheel if no scroll event attached also 27 | this.addEventListener('scroll', () => {}, { 28 | passive: true, 29 | }); 30 | } 31 | } 32 | 33 | EditorScroller.template = /* HTML */ ` `; 34 | -------------------------------------------------------------------------------- /src/solutions/cloud-image-editor/CloudImageEditor.ts: -------------------------------------------------------------------------------- 1 | import { CloudImageEditorBlock } from '../../blocks/CloudImageEditor/src/CloudImageEditorBlock'; 2 | import { InternalEventType } from '../../blocks/UploadCtxProvider/EventEmitter'; 3 | 4 | type BaseInitState = InstanceType['init$']; 5 | interface CloudImageEditorInitState extends BaseInitState { 6 | '*solution': string; 7 | } 8 | 9 | export class CloudImageEditor extends CloudImageEditorBlock { 10 | static override styleAttrs = [...super.styleAttrs, 'uc-wgt-common']; 11 | 12 | constructor() { 13 | super(); 14 | 15 | this.init$ = { 16 | ...this.init$, 17 | '*solution': this.tagName, 18 | } as CloudImageEditorInitState; 19 | } 20 | 21 | override initCallback(): void { 22 | super.initCallback(); 23 | 24 | this.telemetryManager.sendEvent({ 25 | eventType: InternalEventType.INIT_SOLUTION, 26 | }); 27 | 28 | this.a11y?.registerBlock(this); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/shipjs-manual-prepare.yml: -------------------------------------------------------------------------------- 1 | name: Ship js Prepare 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | prepare: 9 | runs-on: ubuntu-latest 10 | if: ${{ !startsWith(github.event.head_commit.message, format('chore{0} release', ':')) }} 11 | steps: 12 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 13 | with: 14 | fetch-depth: 0 15 | ref: main 16 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 17 | with: 18 | node-version: 22 19 | - run: npm ci 20 | - run: | 21 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 22 | git config --global user.name "github-actions[bot]" 23 | - run: npm run release -- --yes --no-browse 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | SLACK_INCOMING_HOOK: ${{ secrets.SLACK_INCOMING_HOOK }} 27 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/icons/warmth.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/util.ts: -------------------------------------------------------------------------------- 1 | import { PACKAGE_NAME, PACKAGE_VERSION } from '../../../env'; 2 | import { createCdnUrl, createCdnUrlModifiers } from '../../../utils/cdn-utils'; 3 | import { COMMON_OPERATIONS, transformationsToOperations } from './lib/transformationUtils'; 4 | import type { Transformations } from './types'; 5 | 6 | export function viewerImageSrc(originalUrl: string, width: number, transformations: Transformations): string { 7 | const MAX_CDN_DIMENSION = 3000; 8 | const dpr = window.devicePixelRatio; 9 | const size = Math.min(Math.ceil(width * dpr), MAX_CDN_DIMENSION); 10 | const quality = dpr >= 2 ? 'lightest' : 'normal'; 11 | 12 | return createCdnUrl( 13 | originalUrl, 14 | createCdnUrlModifiers( 15 | COMMON_OPERATIONS, 16 | transformationsToOperations(transformations), 17 | `quality/${quality}`, 18 | `stretch/off/-/resize/${size}x`, 19 | `@clib/${PACKAGE_NAME}/${PACKAGE_VERSION}/uc-cloud-image-editor/`, 20 | ), 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /demo/custom-icons.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 14 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/index.ts: -------------------------------------------------------------------------------- 1 | export { CloudImageEditorBlock } from './CloudImageEditorBlock'; 2 | export { CropFrame } from './CropFrame'; 3 | export { EditorAspectRatioButtonControl, EditorFreeformButtonControl } from './EditorAspectRatioButtonControl'; 4 | export { EditorCropButtonControl } from './EditorCropButtonControl'; 5 | export { EditorFilterControl } from './EditorFilterControl'; 6 | export { EditorImageCropper } from './EditorImageCropper'; 7 | export { EditorImageFader } from './EditorImageFader'; 8 | export { EditorOperationControl } from './EditorOperationControl'; 9 | export { EditorScroller } from './EditorScroller'; 10 | export { EditorSlider } from './EditorSlider'; 11 | export { EditorToolbar } from './EditorToolbar'; 12 | export { BtnUi } from './elements/button/BtnUi'; 13 | export { LineLoaderUi } from './elements/line-loader/LineLoaderUi'; 14 | export { PresenceToggle } from './elements/presence-toggle/PresenceToggle'; 15 | export { SliderUi } from './elements/slider/SliderUi'; 16 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/flickr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demo/preview-proxy/secure-delivery-proxy.js: -------------------------------------------------------------------------------- 1 | import http from 'node:http'; 2 | 3 | const PORT = 3000; 4 | 5 | http 6 | .createServer((request, response) => { 7 | if (request.method !== 'GET') { 8 | return response.end('Only GET requests are supported'); 9 | } 10 | const url = new URL(request.url, `http://localhost:${PORT}`); 11 | const path = url.pathname.replace(/([^/])$/, '$1/'); 12 | if (path !== '/preview/') { 13 | return response.end('Only `/preview/` path is supported'); 14 | } 15 | const searchParams = url.searchParams; 16 | const fileUrl = searchParams.get('url'); 17 | const size = searchParams.get('size'); 18 | console.log(`Got request. Url: "${fileUrl}". Size: "${size}"`); 19 | if (!fileUrl) { 20 | return response.end('`url` parameter is required'); 21 | } 22 | response.statusCode = 302; 23 | response.setHeader('Location', fileUrl); //lgtm [js/server-side-unvalidated-url-redirection]; 24 | response.end(); 25 | }) 26 | .listen(PORT); 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main", "dev" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: "29 13 * * 1" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v3 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v3 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/remove-file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/shipjs-trigger.yml: -------------------------------------------------------------------------------- 1 | name: Ship js trigger 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | jobs: 7 | build: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/v') 11 | steps: 12 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 13 | with: 14 | fetch-depth: 0 15 | ref: main 16 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 17 | with: 18 | registry-url: "https://registry.npmjs.org" 19 | node-version: 22 20 | - run: npm ci 21 | - run: | 22 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 23 | git config --global user.name "github-actions[bot]" 24 | - run: npx shipjs trigger 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 28 | SLACK_INCOMING_HOOK: ${{ secrets.SLACK_INCOMING_HOOK }} 29 | -------------------------------------------------------------------------------- /src/abstract/testModeProcessor.ts: -------------------------------------------------------------------------------- 1 | import type { Block } from './Block'; 2 | 3 | export function testModeProcessor(fr: DocumentFragment, fnCtx: T): void { 4 | const elementsWithTestId = fr.querySelectorAll('[data-testid]'); 5 | if (elementsWithTestId.length === 0) { 6 | return; 7 | } 8 | const valuesPerElement = new WeakMap(); 9 | 10 | for (const el of elementsWithTestId) { 11 | const testIdValue = el.getAttribute('data-testid'); 12 | if (testIdValue) { 13 | valuesPerElement.set(el, testIdValue); 14 | } 15 | } 16 | 17 | fnCtx.subConfigValue('testMode', (testMode) => { 18 | if (!testMode) { 19 | for (const el of elementsWithTestId) { 20 | el.removeAttribute('data-testid'); 21 | } 22 | return; 23 | } 24 | 25 | const testIdPrefix = fnCtx.testId; 26 | for (const el of elementsWithTestId) { 27 | const testIdValue = valuesPerElement.get(el); 28 | if (!testIdValue) { 29 | continue; 30 | } 31 | el.setAttribute(`data-testid`, `${testIdPrefix}--${testIdValue}`); 32 | } 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/microphone.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/memoize.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { memoize } from './memoize'; 3 | 4 | describe('memoize', () => { 5 | it('should cache result', () => { 6 | let counter = 0; 7 | const fn = vi.fn(() => counter++); 8 | const memoized = memoize(fn); 9 | memoized(); 10 | memoized(); 11 | memoized(); 12 | expect(fn).toHaveBeenCalledTimes(1); 13 | }); 14 | 15 | it('should cache result for each set of arguments', () => { 16 | const fn = vi.fn((a: number, b: number) => { 17 | return a + b; 18 | }); 19 | const memoized = memoize(fn); 20 | 21 | memoized(1, 2); 22 | memoized(1, 2); 23 | memoized(1, 2); 24 | memoized(2, 3); 25 | memoized(2, 3); 26 | memoized(2, 3); 27 | expect(fn).toHaveBeenCalledTimes(2); 28 | }); 29 | 30 | it('should return the same result as original function', () => { 31 | const fn = (a: number, b: number) => a + b; 32 | const memoized = memoize(fn); 33 | expect(memoized(1, 2)).toBe(fn(1, 2)); 34 | expect(memoized(2, 3)).toBe(fn(2, 3)); 35 | expect(memoized(3, 4)).toBe(fn(3, 4)); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/utils/parseShrink.ts: -------------------------------------------------------------------------------- 1 | /** TODO parseShrink move to package @uploadcare/image-shrink */ 2 | 3 | const MAX_SQUARE_SIDE = 16384; 4 | 5 | const regExpShrink = /^([0-9]+)x([0-9]+)(?:\s+(\d{1,2}|100)%)?$/i; 6 | 7 | type ParseShrinkResult = { 8 | size: number; 9 | quality?: number; 10 | }; 11 | 12 | export const parseShrink = (value: unknown): ParseShrinkResult | false => { 13 | if (typeof value !== 'string') { 14 | return false; 15 | } 16 | const terms = regExpShrink.exec(value.toLocaleLowerCase()) ?? []; 17 | 18 | if (terms.length === 0) { 19 | return false; 20 | } 21 | 22 | const sizeShrink = Number(terms[1]) * Number(terms[2]); 23 | const maxSize = MAX_SQUARE_SIDE * MAX_SQUARE_SIDE; 24 | 25 | if (sizeShrink > maxSize) { 26 | console.warn( 27 | `Shrinked size can not be larger than ${Math.floor(maxSize / 1000 / 1000)}MP. ` + 28 | `You have set ${terms[1]}x${terms[2]} (` + 29 | `${Math.ceil(sizeShrink / 1000 / 100) / 10}MP).`, 30 | ); 31 | return false; 32 | } 33 | 34 | return { 35 | quality: terms[3] ? Number(terms[3]) / 100 : undefined, 36 | size: sizeShrink, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/url.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tests/utils/test-renderer.tsx: -------------------------------------------------------------------------------- 1 | import { page } from '@vitest/browser/context'; 2 | import { CommonDOMRenderer } from 'render-jsx/dom'; 3 | import { beforeEach } from 'vitest'; 4 | 5 | export const renderer = new CommonDOMRenderer(); 6 | 7 | const containers = new Set(); 8 | 9 | export const render = (arg: any) => { 10 | const container = document.createElement('div'); 11 | containers.add(container); 12 | 13 | if (arg instanceof Element) { 14 | container.appendChild(arg); 15 | } else { 16 | renderer.render(arg).on(container); 17 | } 18 | 19 | document.body.appendChild(container); 20 | }; 21 | 22 | export const cleanup = () => { 23 | containers.forEach((container) => { 24 | container.remove(); 25 | }); 26 | containers.clear(); 27 | }; 28 | 29 | export const getCtxName = () => `test-${Math.random().toString(36).slice(2)}`; 30 | 31 | page.extend({ 32 | render, 33 | [Symbol.for('vitest:component-cleanup')]: cleanup, 34 | }); 35 | 36 | beforeEach(async () => { 37 | cleanup(); 38 | }); 39 | 40 | declare module '@vitest/browser/context' { 41 | interface BrowserPage { 42 | render: typeof render; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/blocks/Icon/Icon.ts: -------------------------------------------------------------------------------- 1 | import './icon.css'; 2 | import { Block } from '../../abstract/Block'; 3 | import type { IconHrefResolver } from '../../types/index'; 4 | 5 | export class Icon extends Block { 6 | constructor() { 7 | super(); 8 | 9 | this.init$ = { 10 | ...this.init$, 11 | name: '', 12 | href: '', 13 | }; 14 | } 15 | 16 | override initCallback(): void { 17 | super.initCallback(); 18 | this.sub('name', (val: string) => { 19 | if (!val) { 20 | return; 21 | } 22 | let iconHref = `#uc-icon-${val}`; 23 | this.subConfigValue('iconHrefResolver', (iconHrefResolver: IconHrefResolver | null) => { 24 | if (iconHrefResolver) { 25 | const customIconHref = iconHrefResolver(val); 26 | iconHref = customIconHref ?? iconHref; 27 | } 28 | this.$.href = iconHref; 29 | }); 30 | }); 31 | 32 | this.setAttribute('aria-hidden', 'true'); 33 | } 34 | } 35 | 36 | Icon.template = /* HTML */ ` 37 | 38 | 39 | 40 | `; 41 | 42 | Icon.bindAttributes({ 43 | name: 'name', 44 | }); 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Uploadcare (hello@uploadcare.com). All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/lib/classNames.ts: -------------------------------------------------------------------------------- 1 | type ClassNameMapping = Record; 2 | type ClassNameArg = string | ClassNameMapping; 3 | 4 | function normalize(...args: ClassNameArg[]): Record { 5 | return args.reduce>((result, arg) => { 6 | if (typeof arg === 'string') { 7 | result[arg] = true; 8 | return result; 9 | } 10 | 11 | for (const token of Object.keys(arg)) { 12 | result[token] = arg[token]; 13 | } 14 | 15 | return result; 16 | }, {}); 17 | } 18 | 19 | export function classNames(...args: ClassNameArg[]): string { 20 | const mapping = normalize(...args); 21 | return Object.keys(mapping) 22 | .reduce((result, token) => { 23 | if (mapping[token]) { 24 | result.push(token); 25 | } 26 | 27 | return result; 28 | }, []) 29 | .join(' '); 30 | } 31 | 32 | export function applyClassNames(element: Element, ...args: ClassNameArg[]): void { 33 | const mapping = normalize(...args); 34 | for (const token of Object.keys(mapping)) { 35 | element.classList.toggle(token, Boolean(mapping[token])); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/about.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /src/abstract/loadFileUploaderFrom.ts: -------------------------------------------------------------------------------- 1 | import { defineComponents } from './defineComponents'; 2 | 3 | export const UC_WINDOW_KEY = 'UC'; 4 | 5 | type IndexModule = Record; 6 | 7 | declare global { 8 | interface Window { 9 | [UC_WINDOW_KEY]?: IndexModule; 10 | } 11 | } 12 | 13 | /** 14 | * @param url File Uploader pack url 15 | * @param [register] Register connected package, if it not registered yet 16 | */ 17 | export function loadFileUploaderFrom(url: string, register = false): Promise { 18 | return new Promise((resolve, reject) => { 19 | if (typeof document !== 'object') { 20 | resolve(null); 21 | return; 22 | } 23 | if (typeof window === 'object' && window[UC_WINDOW_KEY]) { 24 | resolve(window[UC_WINDOW_KEY]); 25 | return; 26 | } 27 | const script = document.createElement('script'); 28 | script.async = true; 29 | script.src = url; 30 | script.onerror = () => { 31 | reject(); 32 | }; 33 | script.onload = () => { 34 | const blocks = window[UC_WINDOW_KEY] as IndexModule; 35 | register && defineComponents(blocks); 36 | resolve(blocks); 37 | }; 38 | document.head.appendChild(script); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/huddle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/utils/validators/file/validateFileType.ts: -------------------------------------------------------------------------------- 1 | import type { FuncFileValidator } from '../../../abstract/managers/ValidationManager'; 2 | import { IMAGE_ACCEPT_LIST, matchExtension, matchMimeType, mergeFileTypes } from '../../fileTypes'; 3 | 4 | export const validateFileType: FuncFileValidator = (outputEntry, api) => { 5 | const imagesOnly = api.cfg.imgOnly; 6 | const accept = api.cfg.accept; 7 | const allowedFileTypes = mergeFileTypes([...(imagesOnly ? IMAGE_ACCEPT_LIST : []), accept]); 8 | if (!allowedFileTypes.length) return; 9 | 10 | const mimeType = outputEntry.mimeType; 11 | const fileName = outputEntry.name; 12 | 13 | if (!mimeType || !fileName) { 14 | // Skip client validation if mime type or file name are not available for some reasons 15 | return; 16 | } 17 | 18 | const mimeOk = matchMimeType(mimeType, allowedFileTypes); 19 | const extOk = matchExtension(fileName, allowedFileTypes); 20 | 21 | if (!mimeOk && !extOk) { 22 | // Assume file type is not allowed if both mime and ext checks fail 23 | return { 24 | type: 'FORBIDDEN_FILE_TYPE', 25 | message: api.l10n('file-type-not-allowed'), 26 | payload: { entry: outputEntry }, 27 | }; 28 | } 29 | return undefined; 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/parseCdnUrl.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CDN_CNAME } from '../blocks/Config/initialConfig'; 2 | 3 | type ParseCdnUrlOptions = { 4 | url: string; 5 | cdnBase: string; 6 | }; 7 | 8 | type ParseCdnUrlResult = { 9 | uuid: string; 10 | cdnUrlModifiers: string; 11 | filename: string | null; 12 | }; 13 | 14 | const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i; 15 | const cdnUrlRegex = new RegExp(`^/?(${uuidRegex.source})(?:/(-/(?:[^/]+/)+)?([^/]*))?$`, 'i'); 16 | 17 | export const parseCdnUrl = ({ url, cdnBase }: ParseCdnUrlOptions): ParseCdnUrlResult | null => { 18 | const cdnBaseUrlObj = new URL(cdnBase); 19 | const fallbackCdnBaseUrlObj = new URL(DEFAULT_CDN_CNAME); 20 | const urlObj = new URL(url); 21 | 22 | if (cdnBaseUrlObj.host !== urlObj.host && fallbackCdnBaseUrlObj.host !== urlObj.host) { 23 | return null; 24 | } 25 | 26 | const match = cdnUrlRegex.exec(urlObj.pathname); 27 | 28 | if (!match) { 29 | return null; 30 | } 31 | 32 | const [, uuid, cdnUrlModifiers, filename] = match; 33 | 34 | if (!uuid) { 35 | return null; 36 | } 37 | 38 | return { 39 | uuid, 40 | cdnUrlModifiers: cdnUrlModifiers || '', 41 | filename: filename || null, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/lib/pick.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { pick } from './pick'; 3 | 4 | describe('pick', () => { 5 | it('returns a subset of an object including inherited keys and explicit undefined values', () => { 6 | const prototype = { inherited: 'value' } as const; 7 | type Source = { 8 | own: number; 9 | withUndefined: undefined; 10 | optional?: string; 11 | inherited?: string; 12 | }; 13 | 14 | const source = Object.create(prototype) as Source; 15 | source.own = 1; 16 | source.withUndefined = undefined; 17 | 18 | const result = pick(source, ['own', 'inherited', 'withUndefined', 'optional']); 19 | 20 | expect(result).toEqual({ 21 | own: 1, 22 | inherited: 'value', 23 | withUndefined: undefined, 24 | }); 25 | expect(Object.hasOwn(result, 'optional')).toBe(false); 26 | }); 27 | 28 | it('omits keys when values are undefined and not present on the object', () => { 29 | const source: { defined?: string; other: number } = { other: 42 }; 30 | const result = pick(source, ['defined', 'other']); 31 | 32 | expect(result).toEqual({ other: 42 }); 33 | expect(Object.hasOwn(result, 'defined')).toBe(false); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | type Throttled void> = ((...args: Parameters) => void) & { 2 | readonly cancel: () => void; 3 | }; 4 | 5 | export const throttle = void>( 6 | fn: T, 7 | wait: number, 8 | ): T & { readonly cancel: () => void } => { 9 | let inThrottle = false; 10 | let lastFn: ReturnType | undefined; 11 | let lastTime = 0; 12 | 13 | const throttled = ((...args: Parameters) => { 14 | if (!inThrottle) { 15 | fn(...args); 16 | lastTime = Date.now(); 17 | inThrottle = true; 18 | } else { 19 | if (lastFn) clearTimeout(lastFn); 20 | lastFn = setTimeout( 21 | () => { 22 | if (Date.now() - lastTime >= wait) { 23 | fn(...args); 24 | lastTime = Date.now(); 25 | } 26 | }, 27 | Math.max(wait - (Date.now() - lastTime), 0), 28 | ); 29 | } 30 | }) as Throttled; 31 | 32 | Object.defineProperty(throttled, 'cancel', { 33 | configurable: false, 34 | writable: false, 35 | enumerable: false, 36 | value: () => { 37 | if (lastFn) clearTimeout(lastFn); 38 | }, 39 | }); 40 | 41 | return throttled as T & { readonly cancel: () => void }; 42 | }; 43 | -------------------------------------------------------------------------------- /demo/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 25 | 33 | 34 |
35 | 36 | 37 | 38 |
39 |
40 | 41 |
42 |
43 |
44 |
45 | -------------------------------------------------------------------------------- /src/utils/validators/collection/validateMultiple.ts: -------------------------------------------------------------------------------- 1 | import type { FuncCollectionValidator } from '../../../abstract/managers/ValidationManager'; 2 | 3 | export const validateMultiple: FuncCollectionValidator = (collection, api) => { 4 | const total = collection.totalCount; 5 | const multipleMin = api.cfg.multiple ? api.cfg.multipleMin : 0; 6 | const multipleMax = api.cfg.multiple ? api.cfg.multipleMax : 1; 7 | 8 | if (multipleMin && total < multipleMin) { 9 | const message = api.l10n('files-count-limit-error-too-few', { 10 | min: multipleMin, 11 | max: multipleMax, 12 | total, 13 | }); 14 | 15 | return { 16 | type: 'TOO_FEW_FILES', 17 | message, 18 | payload: { 19 | total, 20 | min: multipleMin, 21 | max: multipleMax, 22 | }, 23 | }; 24 | } 25 | 26 | if (multipleMax && total > multipleMax) { 27 | const message = api.l10n('files-count-limit-error-too-many', { 28 | min: multipleMin, 29 | max: multipleMax, 30 | total, 31 | }); 32 | return { 33 | type: 'TOO_MANY_FILES', 34 | message, 35 | payload: { 36 | total, 37 | min: multipleMin, 38 | max: multipleMax, 39 | }, 40 | }; 41 | } 42 | return undefined; 43 | }; 44 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/dropbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": false 10 | }, 11 | "formatter": { 12 | "enabled": true, 13 | "indentStyle": "space", 14 | "lineWidth": 120 15 | }, 16 | "linter": { 17 | "enabled": true, 18 | "rules": { 19 | "recommended": true, 20 | "suspicious": { 21 | "noDuplicateProperties": "off", 22 | "noExplicitAny": "warn" 23 | }, 24 | "complexity": { 25 | "noThisInStatic": "off" 26 | }, 27 | "style": { 28 | "noInferrableTypes": "error" 29 | } 30 | } 31 | }, 32 | "javascript": { 33 | "formatter": { 34 | "quoteStyle": "single" 35 | } 36 | }, 37 | "assist": { 38 | "enabled": true, 39 | "actions": { 40 | "source": { 41 | "organizeImports": "on", 42 | "recommended": true 43 | } 44 | } 45 | }, 46 | "overrides": [ 47 | { 48 | "includes": ["**/*.test-d.tsx"], 49 | "linter": { 50 | "rules": { 51 | "correctness": { 52 | "noUnusedVariables": "off", 53 | "noUnusedImports": "off" 54 | } 55 | } 56 | } 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/validators/file/validateUploadError.ts: -------------------------------------------------------------------------------- 1 | import { NetworkError, UploadError } from '@uploadcare/upload-client'; 2 | import type { FuncFileValidator } from '../../../abstract/managers/ValidationManager'; 3 | 4 | export const validateUploadError: FuncFileValidator = (outputEntry, api) => { 5 | const { internalId } = outputEntry; 6 | 7 | // @ts-expect-error Use private API that is not exposed in the types 8 | const internalEntry = api._uploadCollection.read(internalId); 9 | 10 | const cause: unknown = internalEntry?.getValue('uploadError'); 11 | if (!cause) { 12 | return; 13 | } 14 | 15 | if (cause instanceof UploadError) { 16 | return { 17 | type: 'UPLOAD_ERROR', 18 | message: cause.message, 19 | payload: { 20 | entry: outputEntry, 21 | error: cause, 22 | }, 23 | }; 24 | } 25 | 26 | if (cause instanceof NetworkError) { 27 | return { 28 | type: 'NETWORK_ERROR', 29 | message: cause.message, 30 | payload: { 31 | entry: outputEntry, 32 | error: cause, 33 | }, 34 | }; 35 | } 36 | 37 | const error = cause instanceof Error ? cause : new Error('Unknown error', { cause }); 38 | return { 39 | type: 'UNKNOWN_ERROR', 40 | message: error.message, 41 | payload: { 42 | entry: outputEntry, 43 | error, 44 | }, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/lib/classNames.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { applyClassNames, classNames } from './classNames'; 3 | 4 | describe('classNames', () => { 5 | it('joins truthy tokens from strings and objects', () => { 6 | const result = classNames('foo', { bar: true, baz: false }, 'qux'); 7 | expect(result).toBe('foo bar qux'); 8 | }); 9 | 10 | it('ignores falsy object values', () => { 11 | expect(classNames({ foo: true, bar: null, baz: undefined, qux: false })).toBe('foo'); 12 | }); 13 | }); 14 | 15 | describe('applyClassNames', () => { 16 | it('toggles classes according to mappings', () => { 17 | const element = document.createElement('div'); 18 | 19 | applyClassNames(element, 'foo', { bar: true, baz: false }); 20 | 21 | expect(element.classList.contains('foo')).toBe(true); 22 | expect(element.classList.contains('bar')).toBe(true); 23 | expect(element.classList.contains('baz')).toBe(false); 24 | }); 25 | 26 | it('removes classes when toggled to false', () => { 27 | const element = document.createElement('div'); 28 | element.classList.add('foo', 'bar'); 29 | 30 | applyClassNames(element, { foo: false, bar: true }); 31 | 32 | expect(element.classList.contains('foo')).toBe(false); 33 | expect(element.classList.contains('bar')).toBe(true); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /types/test/public-upload-api.test-d.tsx: -------------------------------------------------------------------------------- 1 | import '../jsx'; 2 | 3 | import { UploadCtxProvider } from '../../dist/index.js'; 4 | 5 | const instance = new UploadCtxProvider(); 6 | const api = instance.getAPI(); 7 | 8 | api.addFileFromUrl('https://example.com/image.png'); 9 | 10 | api.setCurrentActivity('camera'); 11 | api.setCurrentActivity('cloud-image-edit', { internalId: 'id' }); 12 | api.setCurrentActivity('external', { 13 | externalSourceType: 'type', 14 | }); 15 | 16 | // @ts-expect-error - should not allow to set activity without params 17 | api.setCurrentActivity('cloud-image-edit'); 18 | // @ts-expect-error - should not allow to set activity without params 19 | api.setCurrentActivity('external'); 20 | 21 | // @ts-expect-error - should not allow to set activity with invalid params 22 | api.setCurrentActivity('camera', { 23 | invalidParam: 'value', 24 | }); 25 | api.setCurrentActivity('cloud-image-edit', { 26 | // @ts-expect-error - should not allow to set activity with invalid params 27 | invalidParam: 'value', 28 | }); 29 | api.setCurrentActivity('external', { 30 | // @ts-expect-error - should not allow to set activity with invalid params 31 | invalidParam: 'value', 32 | }); 33 | 34 | // should allow to set some custom activity 35 | api.setCurrentActivity('my-custom-activity'); 36 | api.setCurrentActivity('my-custom-activity', { myCustomParam: 'value' }); 37 | -------------------------------------------------------------------------------- /src/utils/waitForAttribute.ts: -------------------------------------------------------------------------------- 1 | type WaitForAttributeOptions = { 2 | element: HTMLElement; 3 | attribute: string; 4 | onSuccess: (value: string) => void; 5 | onTimeout: () => void; 6 | timeout?: number; 7 | }; 8 | 9 | export const waitForAttribute = ({ 10 | element, 11 | attribute, 12 | onSuccess, 13 | onTimeout, 14 | timeout = 300, 15 | }: WaitForAttributeOptions): void => { 16 | const currentAttrValue = element.getAttribute(attribute); 17 | if (currentAttrValue !== null) { 18 | onSuccess(currentAttrValue); 19 | return; 20 | } 21 | 22 | const observer = new MutationObserver((mutations) => { 23 | const mutation = mutations[mutations.length - 1]; 24 | if (mutation) { 25 | handleMutation(mutation); 26 | } 27 | }); 28 | 29 | observer.observe(element, { 30 | attributes: true, 31 | attributeFilter: [attribute], 32 | }); 33 | 34 | const timeoutId = window.setTimeout(() => { 35 | observer.disconnect(); 36 | onTimeout(); 37 | }, timeout); 38 | 39 | const handleMutation = (mutation: MutationRecord): void => { 40 | const attrValue = element.getAttribute(attribute); 41 | if (mutation.type === 'attributes' && mutation.attributeName === attribute && attrValue !== null) { 42 | window.clearTimeout(timeoutId); 43 | observer.disconnect(); 44 | onSuccess(attrValue); 45 | } 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/blocks/UploadCtxProvider/UploadCtxProvider.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { UploaderBlock } from '../../abstract/UploaderBlock'; 4 | import { type EventPayload, EventType } from './EventEmitter'; 5 | 6 | // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: This is intentional interface merging, used to add event listener types 7 | export class UploadCtxProvider extends UploaderBlock { 8 | static override styleAttrs = ['uc-wgt-common']; 9 | static EventType = EventType; 10 | 11 | override requireCtxName = true; 12 | 13 | override initCallback() { 14 | super.initCallback(); 15 | 16 | this.$['*eventEmitter'].bindTarget(this); 17 | } 18 | 19 | override destroyCallback() { 20 | super.destroyCallback(); 21 | 22 | this.$['*eventEmitter'].unbindTarget(this); 23 | } 24 | } 25 | 26 | type EventListenerMap = { 27 | [K in (typeof EventType)[keyof typeof EventType]]: (e: CustomEvent) => void; 28 | }; 29 | 30 | export interface UploadCtxProvider extends UploaderBlock { 31 | addEventListener( 32 | type: T, 33 | listener: EventListenerMap[T], 34 | options?: boolean | AddEventListenerOptions, 35 | ): void; 36 | removeEventListener( 37 | type: T, 38 | listener: EventListenerMap[T], 39 | options?: boolean | EventListenerOptions, 40 | ): void; 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/preloadImage.ts: -------------------------------------------------------------------------------- 1 | import { TRANSPARENT_PIXEL_SRC } from './transparentPixelSrc'; 2 | 3 | export function preloadImage(src: string): { 4 | promise: Promise; 5 | image: HTMLImageElement; 6 | cancel: () => void; 7 | } { 8 | const image: HTMLImageElement = new Image(); 9 | 10 | const promise: Promise = new Promise((resolve, reject) => { 11 | image.src = src; 12 | image.onload = () => resolve(); 13 | image.onerror = (err) => reject(err); 14 | }); 15 | 16 | const cancel = () => { 17 | if (image.naturalWidth === 0) { 18 | image.src = TRANSPARENT_PIXEL_SRC; 19 | } 20 | }; 21 | 22 | return { promise, image, cancel }; 23 | } 24 | 25 | export function batchPreloadImages(list: string[]): { 26 | promise: Promise[]>; 27 | images: HTMLImageElement[]; 28 | cancel: () => void; 29 | } { 30 | const preloaders: ReturnType[] = []; 31 | 32 | for (const src of list) { 33 | const preload = preloadImage(src); 34 | preloaders.push(preload); 35 | } 36 | 37 | const images = preloaders.map((preload) => preload.image); 38 | const promise = Promise.allSettled(preloaders.map((preload) => preload.promise)); 39 | const cancel = () => { 40 | preloaders.forEach((preload) => { 41 | preload.cancel(); 42 | }); 43 | }; 44 | 45 | return { promise, images, cancel }; 46 | } 47 | -------------------------------------------------------------------------------- /src/abstract/CTX.ts: -------------------------------------------------------------------------------- 1 | import type { UploadcareGroup } from '@uploadcare/upload-client'; 2 | import { Queue } from '@uploadcare/upload-client'; 3 | import type { OutputCollectionState, OutputErrorCollection } from '../types/index'; 4 | import type { Block } from './Block'; 5 | import type { SecureUploadsManager } from './managers/SecureUploadsManager'; 6 | 7 | export const blockCtx = () => ({}); 8 | 9 | export const activityBlockCtx = (fnCtx: Block) => ({ 10 | ...blockCtx(), 11 | '*currentActivity': null, 12 | '*currentActivityParams': {}, 13 | 14 | '*history': [], 15 | '*historyBack': null, 16 | '*closeModal': () => { 17 | fnCtx.modalManager?.close(fnCtx.$['*currentActivity']); 18 | 19 | fnCtx.set$({ 20 | '*currentActivity': null, 21 | }); 22 | }, 23 | }); 24 | 25 | export const uploaderBlockCtx = (fnCtx: Block) => ({ 26 | ...activityBlockCtx(fnCtx), 27 | '*commonProgress': 0, 28 | '*uploadList': [], 29 | '*uploadQueue': new Queue(1), 30 | '*collectionErrors': [] as OutputErrorCollection[], 31 | '*collectionState': null as OutputCollectionState | null, 32 | '*groupInfo': null as UploadcareGroup | null, 33 | '*uploadTrigger': new Set(), 34 | '*secureUploadsManager': null as SecureUploadsManager | null, 35 | }); 36 | 37 | export const solutionBlockCtx = (fnCtx: Block) => ({ 38 | ...uploaderBlockCtx(fnCtx), 39 | '*solution': null as string | null, 40 | }); 41 | -------------------------------------------------------------------------------- /src/utils/resizeImage.ts: -------------------------------------------------------------------------------- 1 | export function generateThumb(imgFile: File, size = 40): string | Promise { 2 | if (imgFile.type === 'image/svg+xml') { 3 | // TODO: Return destuctor here 4 | return URL.createObjectURL(imgFile); 5 | } 6 | const canvas: HTMLCanvasElement = document.createElement('canvas'); 7 | const ctx = canvas.getContext('2d'); 8 | if (!ctx) { 9 | return Promise.reject(new Error('Canvas context not supported')); 10 | } 11 | const img = new Image(); 12 | const promise: Promise = new Promise((resolve, reject) => { 13 | img.onload = () => { 14 | const ratio = img.height / img.width; 15 | if (ratio > 1) { 16 | canvas.width = size; 17 | canvas.height = size * ratio; 18 | } else { 19 | canvas.height = size; 20 | canvas.width = size / ratio; 21 | } 22 | ctx.fillStyle = 'rgb(240, 240, 240)'; 23 | ctx.fillRect(0, 0, canvas.width, canvas.height); 24 | ctx.drawImage(img, 0, 0, canvas.width, canvas.height); 25 | canvas.toBlob((blob: Blob | null) => { 26 | if (!blob) { 27 | reject(); 28 | return; 29 | } 30 | const url = URL.createObjectURL(blob); 31 | resolve(url); 32 | }); 33 | }; 34 | img.onerror = (err: unknown) => { 35 | reject(err); 36 | }; 37 | }); 38 | img.src = URL.createObjectURL(imgFile); 39 | return promise; 40 | } 41 | -------------------------------------------------------------------------------- /src/blocks/Config/assertions.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigType } from '../../types/index'; 2 | import { debounce } from '../../utils/debounce'; 3 | import { warnOnce } from '../../utils/warnOnce'; 4 | 5 | type Assertion = { 6 | test: (cfg: ConfigType) => boolean; 7 | message: string; 8 | }; 9 | 10 | const ASSERTIONS: Assertion[] = [ 11 | { 12 | test: (cfg) => !!cfg.accept && !!cfg.imgOnly, 13 | message: 14 | 'There could be a mistake.\n' + 15 | 'Both `accept` and `imgOnly` parameters are set.\n' + 16 | 'The value of `accept` will be concatenated with the internal image mime types list.', 17 | }, 18 | { 19 | test: (cfg) => cfg.enableVideoRecording !== null, 20 | message: 21 | 'The `enableVideoRecording` parameter is deprecated and will be removed in the next major release.\n' + 22 | 'Please use the `cameraModes` parameter instead.', 23 | }, 24 | { 25 | test: (cfg) => cfg.defaultCameraMode !== null, 26 | message: 27 | 'The `defaultCameraMode` parameter is deprecated and will be removed in the next major release.\n' + 28 | 'Please use the `cameraModes` parameter instead.', 29 | }, 30 | ]; 31 | 32 | /** Runs on every config change and warns about potential issues. */ 33 | export const runAssertions = debounce((cfg: ConfigType) => { 34 | for (const { test, message } of ASSERTIONS) { 35 | if (test(cfg)) { 36 | warnOnce(message); 37 | } 38 | } 39 | }, 0); 40 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/lib/parseCropPreset.test.ts: -------------------------------------------------------------------------------- 1 | import { UID } from '@symbiotejs/symbiote'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | import { getClosestAspectRatio, parseCropPreset } from './parseCropPreset'; 4 | 5 | describe('parseCropPreset', () => { 6 | it('should parse crop presets correctly', () => { 7 | const uniqueIds = 4; 8 | let uidCallCount = 0; 9 | 10 | const generateSpy = vi.spyOn(UID, 'generate').mockImplementation(() => { 11 | const id = `id-${(uidCallCount % uniqueIds) + 1}`; 12 | uidCallCount += 1; 13 | return id; 14 | }); 15 | 16 | const input = '16:9, 3:4, 4:3, 1:1'; 17 | const uuid = () => UID.generate(); 18 | const expected = [ 19 | { id: uuid(), type: 'aspect-ratio', width: 16, height: 9, hasFreeform: false }, 20 | { id: uuid(), type: 'aspect-ratio', width: 3, height: 4, hasFreeform: false }, 21 | { id: uuid(), type: 'aspect-ratio', width: 4, height: 3, hasFreeform: false }, 22 | { id: uuid(), type: 'aspect-ratio', width: 1, height: 1, hasFreeform: false }, 23 | ]; 24 | const list = parseCropPreset(input); 25 | expect(list).toEqual(expected); 26 | 27 | expect(getClosestAspectRatio(400, 500, list, 0.1)).toEqual({ 28 | hasFreeform: false, 29 | height: 4, 30 | id: 'id-2', 31 | type: 'aspect-ratio', 32 | width: 3, 33 | }); 34 | 35 | generateSpy.mockRestore(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 11 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 12 | with: 13 | node-version: 22 14 | cache: "npm" 15 | - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 16 | id: playwright-cache 17 | with: 18 | path: | 19 | ~/.cache/ms-playwright 20 | key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }} 21 | - name: Install dependencies 22 | working-directory: ./ 23 | run: npm ci 24 | - name: Install playwright deps 25 | run: npm run playwright:install 26 | if: steps.playwright-cache.outputs.cache-hit != 'true' 27 | - name: Run build 28 | run: npm run build 29 | - name: Run lint 30 | run: npm run lint 31 | - name: Run test 32 | run: npm run test 33 | - name: Run tsc 34 | run: npm run tsc 35 | - name: Archive artifacts 36 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 37 | if: always() 38 | with: 39 | name: e2e-tests 40 | path: | 41 | tests/__screenshots__/** 42 | tests/__coverage__/** 43 | -------------------------------------------------------------------------------- /src/utils/withResolvers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { withResolvers } from './withResolvers'; 3 | 4 | describe('withResolvers', () => { 5 | it('resolves when external resolve is called', async () => { 6 | const { promise, resolve } = withResolvers() as { 7 | promise: Promise; 8 | resolve: (value?: unknown) => void; 9 | }; 10 | 11 | setTimeout(() => resolve(42), 10); 12 | 13 | const result = await promise; 14 | expect(result).toBe(42); 15 | }); 16 | 17 | it('rejects when external reject is called', async () => { 18 | const { promise, reject } = withResolvers() as { 19 | promise: Promise; 20 | reject: (reason?: unknown) => void; 21 | }; 22 | 23 | setTimeout(() => reject(new Error('fail')), 10); 24 | 25 | try { 26 | await promise; 27 | throw new Error('Promise should have been rejected'); 28 | } catch (err) { 29 | expect(err).toBeInstanceOf(Error); 30 | expect((err as Error).message).toBe('fail'); 31 | } 32 | }); 33 | 34 | it('resolves with a promise-like value (flattened)', async () => { 35 | const { promise, resolve } = withResolvers() as { 36 | promise: Promise; 37 | resolve: (value?: unknown) => void; 38 | }; 39 | 40 | setTimeout(() => resolve(Promise.resolve('ok')), 10); 41 | 42 | const result = await promise; 43 | expect(result).toBe('ok'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/blocks/ExternalSource/buildThemeDefinition.ts: -------------------------------------------------------------------------------- 1 | import type { ThemeDefinition } from './types'; 2 | 3 | type ThemeCustomProperty = keyof ThemeDefinition; 4 | 5 | const ucCustomProperties: ThemeCustomProperty[] = [ 6 | '--uc-font-family', 7 | '--uc-font-size', 8 | '--uc-line-height', 9 | '--uc-button-size', 10 | '--uc-preview-size', 11 | '--uc-input-size', 12 | '--uc-padding', 13 | '--uc-radius', 14 | '--uc-transition', 15 | '--uc-background', 16 | '--uc-foreground', 17 | '--uc-primary', 18 | '--uc-primary-hover', 19 | '--uc-primary-transparent', 20 | '--uc-primary-foreground', 21 | '--uc-secondary', 22 | '--uc-secondary-hover', 23 | '--uc-secondary-foreground', 24 | '--uc-muted', 25 | '--uc-muted-foreground', 26 | '--uc-destructive', 27 | '--uc-destructive-foreground', 28 | '--uc-border', 29 | ]; 30 | 31 | const getCssValue = (element: HTMLElement, propName: ThemeCustomProperty): string => { 32 | const style = window.getComputedStyle(element); 33 | return style.getPropertyValue(propName).trim(); 34 | }; 35 | 36 | export const buildThemeDefinition = (element: HTMLElement): Record => { 37 | const theme: Partial> = {}; 38 | 39 | for (const prop of ucCustomProperties) { 40 | const value = getCssValue(element, prop); 41 | if (value) { 42 | theme[prop] = value; 43 | } 44 | } 45 | return theme as Record; 46 | }; 47 | -------------------------------------------------------------------------------- /src/blocks/SourceBtn/source-btn.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-source-btn > button { 3 | display: flex; 4 | align-items: center; 5 | margin-bottom: 2px; 6 | padding: 2px var(--uc-padding); 7 | color: var(--uc-foreground); 8 | border-radius: var(--uc-radius); 9 | cursor: pointer; 10 | transition: 11 | background-color var(--uc-transition), 12 | color var(--uc-transition); 13 | user-select: none; 14 | width: 100%; 15 | background-color: unset; 16 | height: unset; 17 | } 18 | 19 | uc-source-btn:last-child > button { 20 | margin-bottom: 0; 21 | } 22 | 23 | uc-source-btn > button:hover { 24 | background-color: var(--uc-primary-transparent); 25 | } 26 | 27 | :where(.uc-contrast) uc-source-btn > button:hover { 28 | background-color: var(--uc-secondary); 29 | color: var(--uc-foreground); 30 | } 31 | 32 | uc-source-btn uc-icon { 33 | display: inline-flex; 34 | flex-grow: 1; 35 | justify-content: center; 36 | min-width: var(--uc-button-size); 37 | margin-right: var(--uc-padding); 38 | opacity: 0.8; 39 | } 40 | 41 | :where(.uc-contrast) uc-source-btn uc-icon { 42 | opacity: 1; 43 | } 44 | 45 | uc-source-btn .uc-txt { 46 | display: flex; 47 | align-items: center; 48 | box-sizing: border-box; 49 | width: 100%; 50 | height: var(--uc-button-size); 51 | padding: 0; 52 | white-space: nowrap; 53 | border: none; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/prettyBytes.ts: -------------------------------------------------------------------------------- 1 | import { getPluralForm } from './getPluralForm'; 2 | 3 | const BASE = 1000; 4 | 5 | export const ByteUnitEnum = Object.freeze({ 6 | AUTO: 'auto', 7 | BYTE: 'byte', 8 | KB: 'kb', 9 | MB: 'mb', 10 | GB: 'gb', 11 | TB: 'tb', 12 | PB: 'pb', 13 | } as const); 14 | 15 | export type ByteUnit = (typeof ByteUnitEnum)[keyof typeof ByteUnitEnum]; 16 | 17 | const round = (number: number): number => Math.ceil(number * 100) / 100; 18 | 19 | export const prettyBytes = (bytes: number, unit: ByteUnit = ByteUnitEnum.AUTO): string => { 20 | const isAutoMode = unit === ByteUnitEnum.AUTO; 21 | 22 | if (unit === ByteUnitEnum.BYTE || (isAutoMode && bytes < BASE ** 1)) { 23 | const pluralForm = getPluralForm('en-US', bytes); 24 | const pluralized = pluralForm === 'one' ? 'byte' : 'bytes'; 25 | 26 | return `${bytes} ${pluralized}`; 27 | } 28 | 29 | if (unit === ByteUnitEnum.KB || (isAutoMode && bytes < BASE ** 2)) { 30 | return `${round(bytes / BASE ** 1)} KB`; 31 | } 32 | 33 | if (unit === ByteUnitEnum.MB || (isAutoMode && bytes < BASE ** 3)) { 34 | return `${round(bytes / BASE ** 2)} MB`; 35 | } 36 | 37 | if (unit === ByteUnitEnum.GB || (isAutoMode && bytes < BASE ** 4)) { 38 | return `${round(bytes / BASE ** 3)} GB`; 39 | } 40 | 41 | if (unit === ByteUnitEnum.TB || (isAutoMode && bytes < BASE ** 5)) { 42 | return `${round(bytes / BASE ** 4)} TB`; 43 | } 44 | 45 | return `${round(bytes / BASE ** 5)} PB`; 46 | }; 47 | -------------------------------------------------------------------------------- /src/utils/WindowHeightTracker.ts: -------------------------------------------------------------------------------- 1 | import type { Block } from '../abstract/Block.js'; 2 | import { debounce } from '../utils/debounce.js'; 3 | 4 | const WINDOW_HEIGHT_TRACKER_PROPERTY = '--uploadcare-blocks-window-height'; 5 | 6 | // biome-ignore lint/complexity/noStaticOnlyClass: This class is static only by design 7 | export class WindowHeightTracker { 8 | private static clientsRegistry = new Set(); 9 | 10 | private static flush = debounce(() => { 11 | document.documentElement.style.setProperty(WINDOW_HEIGHT_TRACKER_PROPERTY, `${window.innerHeight}px`); 12 | }, 100); 13 | 14 | static registerClient(client: Block): void { 15 | if (WindowHeightTracker.clientsRegistry.size === 0) { 16 | WindowHeightTracker.attachTracker(); 17 | } 18 | WindowHeightTracker.clientsRegistry.add(client); 19 | } 20 | 21 | static unregisterClient(client: Block): void { 22 | WindowHeightTracker.clientsRegistry.delete(client); 23 | if (WindowHeightTracker.clientsRegistry.size === 0) { 24 | WindowHeightTracker.detachTracker(); 25 | } 26 | } 27 | 28 | private static attachTracker(): void { 29 | window.addEventListener('resize', WindowHeightTracker.flush, { passive: true, capture: true }); 30 | WindowHeightTracker.flush(); 31 | } 32 | 33 | private static detachTracker(): void { 34 | window.removeEventListener('resize', WindowHeightTracker.flush, { capture: true }); 35 | document.documentElement.style.removeProperty(WINDOW_HEIGHT_TRACKER_PROPERTY); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /demo/validators.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 49 | -------------------------------------------------------------------------------- /src/blocks/Range/range.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-range { 3 | position: relative; 4 | display: inline-flex; 5 | align-items: center; 6 | justify-content: center; 7 | height: var(--uc-button-size); 8 | } 9 | 10 | uc-range datalist { 11 | display: none; 12 | } 13 | 14 | uc-range input { 15 | width: 100%; 16 | height: 100%; 17 | opacity: 0; 18 | } 19 | 20 | uc-range .uc-track-wrapper { 21 | position: absolute; 22 | right: 10px; 23 | left: 10px; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | height: 2px; 28 | user-select: none; 29 | pointer-events: none; 30 | } 31 | 32 | uc-range .uc-track { 33 | position: absolute; 34 | right: 0; 35 | left: 0; 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | height: 2px; 40 | background-color: currentColor; 41 | border-radius: 2px; 42 | opacity: 0.5; 43 | } 44 | 45 | uc-range .uc-slider { 46 | position: absolute; 47 | width: 16px; 48 | height: 16px; 49 | background-color: currentColor; 50 | border-radius: 100%; 51 | transform: translateX(-50%); 52 | } 53 | 54 | uc-range .uc-bar { 55 | position: absolute; 56 | left: 0; 57 | height: 100%; 58 | background-color: currentColor; 59 | border-radius: 2px; 60 | } 61 | 62 | uc-range .uc-caption { 63 | position: absolute; 64 | display: inline-flex; 65 | justify-content: center; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/blocks/Modal/modal.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | @supports selector(dialog::backdrop) { 3 | :where([uc-modal]) > dialog::backdrop { 4 | /* backdrop don't inherit theme properties */ 5 | background-color: oklch(0 0 0 / 0.1); 6 | } 7 | :where([uc-modal])[strokes] > dialog::backdrop { 8 | /* TODO: it's not working, fix it */ 9 | background-image: var(--modal-backdrop-background-image); 10 | } 11 | } 12 | 13 | :where([uc-modal]) > dialog[open] { 14 | transform: translateY(0px); 15 | visibility: visible; 16 | opacity: 1; 17 | } 18 | 19 | :where([uc-modal]) > dialog:not([open]) { 20 | transform: translateY(20px); 21 | visibility: hidden; 22 | opacity: 0; 23 | } 24 | 25 | :where([uc-modal]) > dialog { 26 | display: flex; 27 | flex-direction: column; 28 | width: min(var(--uc-dialog-width), 100%); 29 | max-width: min(calc(100% - var(--uc-padding) * 2), var(--uc-dialog-max-width)); 30 | min-height: var(--uc-button-size); 31 | max-height: min(calc(100% - var(--uc-padding) * 2), var(--uc-dialog-max-height)); 32 | margin: auto; 33 | padding: 0; 34 | overflow: hidden; 35 | background-color: var(--uc-background); 36 | border: 0; 37 | border-radius: calc(var(--uc-radius) * 1.75); 38 | box-shadow: var(--uc-dialog-shadow); 39 | transition: 40 | transform 0.4s ease, 41 | opacity 0.4s ease; 42 | } 43 | 44 | :where(.uc-contrast) :where([uc-modal]) > dialog { 45 | outline: 1px solid var(--uc-border); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/elements/line-loader/LineLoaderUi.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../../../abstract/Block'; 2 | 3 | export class LineLoaderUi extends Block { 4 | private _active = false; 5 | 6 | private readonly _handleTransitionEndRight = (): void => { 7 | const lineEl = this.ref['line-el'] as HTMLElement; 8 | lineEl.style.transition = 'initial'; 9 | lineEl.style.opacity = '0'; 10 | lineEl.style.transform = 'translateX(-101%)'; 11 | if (this._active) { 12 | this._start(); 13 | } 14 | }; 15 | 16 | override initCallback(): void { 17 | super.initCallback(); 18 | this.defineAccessor('active', (active: boolean | undefined) => { 19 | if (typeof active !== 'boolean') { 20 | return; 21 | } 22 | if (active) { 23 | this._start(); 24 | } else { 25 | this._stop(); 26 | } 27 | }); 28 | } 29 | 30 | private _start(): void { 31 | this._active = true; 32 | const { width } = this.getBoundingClientRect(); 33 | const lineEl = this.ref['line-el'] as HTMLElement; 34 | lineEl.style.transition = 'transform 1s'; 35 | lineEl.style.opacity = '1'; 36 | lineEl.style.transform = `translateX(${width}px)`; 37 | lineEl.addEventListener('transitionend', this._handleTransitionEndRight, { 38 | once: true, 39 | }); 40 | } 41 | 42 | private _stop(): void { 43 | this._active = false; 44 | } 45 | } 46 | 47 | LineLoaderUi.template = /* HTML */ ` 48 |
49 |
50 |
51 | `; 52 | -------------------------------------------------------------------------------- /src/blocks/ProgressBar/progress-bar.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | uc-progress-bar { 3 | --l-progress-value: 0; 4 | 5 | position: absolute; 6 | top: 0; 7 | bottom: 0; 8 | left: 0; 9 | width: 100%; 10 | height: 100%; 11 | overflow: hidden; 12 | pointer-events: none; 13 | transition: opacity 0.3s; 14 | opacity: 1; 15 | } 16 | 17 | uc-progress-bar.uc-progress-bar--hidden { 18 | opacity: 0; 19 | } 20 | 21 | uc-progress-bar .uc-progress { 22 | position: absolute; 23 | width: calc(var(--l-progress-value) * 1%); 24 | height: 100%; 25 | background-color: var(--uc-primary); 26 | transform: translateX(0); 27 | opacity: 1; 28 | transition: 29 | width 0.6s, 30 | opacity 0.3s; 31 | } 32 | 33 | uc-progress-bar .uc-progress--hidden { 34 | opacity: 0; 35 | } 36 | 37 | uc-progress-bar .uc-fake-progress { 38 | --l-fake-progress-width: 30; 39 | 40 | position: absolute; 41 | width: calc(var(--l-fake-progress-width) * 1%); 42 | height: 100%; 43 | background-color: var(--uc-primary); 44 | animation: fake-progress-animation 1s ease-in-out infinite; 45 | opacity: 1; 46 | transition: opacity 0.3s; 47 | z-index: 1; 48 | } 49 | 50 | uc-progress-bar .uc-fake-progress--hidden { 51 | opacity: 0; 52 | animation: none; 53 | } 54 | 55 | @keyframes fake-progress-animation { 56 | from { 57 | transform: translateX(-100%); 58 | } 59 | 60 | to { 61 | transform: translateX(calc(100 / var(--l-fake-progress-width) * 100 * 1%)); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/isSecureTokenExpired.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { isSecureTokenExpired } from './isSecureTokenExpired'; 3 | 4 | const DATE_NOW = 60 * 1000; 5 | const THRESHOLD = 10 * 1000; 6 | 7 | describe('isSecureTokenExpired', () => { 8 | beforeEach(() => { 9 | vi.useFakeTimers(); 10 | vi.setSystemTime(DATE_NOW); 11 | }); 12 | 13 | afterEach(() => { 14 | vi.useRealTimers(); 15 | }); 16 | 17 | it('should return true if the token is expired', () => { 18 | expect(isSecureTokenExpired({ secureExpire: '0', secureSignature: '' }, { threshold: THRESHOLD })).toBe(true); 19 | expect(isSecureTokenExpired({ secureExpire: '59', secureSignature: '' }, { threshold: THRESHOLD })).toBe(true); 20 | }); 21 | 22 | it('should return true if the token will expire in the next 10 seconds', () => { 23 | expect(isSecureTokenExpired({ secureExpire: '60', secureSignature: '' }, { threshold: THRESHOLD })).toBe(true); 24 | expect(isSecureTokenExpired({ secureExpire: '61', secureSignature: '' }, { threshold: THRESHOLD })).toBe(true); 25 | expect(isSecureTokenExpired({ secureExpire: '70', secureSignature: '' }, { threshold: THRESHOLD })).toBe(true); 26 | }); 27 | 28 | it("should return false if the token is not expired and won't expire in next 10 seconds", () => { 29 | expect(isSecureTokenExpired({ secureExpire: '71', secureSignature: '' }, { threshold: THRESHOLD })).toBe(false); 30 | expect(isSecureTokenExpired({ secureExpire: '80', secureSignature: '' }, { threshold: THRESHOLD })).toBe(false); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /.stylelintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['stylelint-config-standard'], 3 | plugins: ['stylelint-order', 'stylelint-declaration-block-no-ignored-properties'], 4 | rules: { 5 | 'hue-degree-notation': null, 6 | 'alpha-value-notation': null, 7 | 'plugin/declaration-block-no-ignored-properties': true, 8 | 'function-calc-no-unspaced-operator': true, // can cause out of memory in some cases 9 | 'keyframes-name-pattern': null, 10 | 'selector-class-pattern': null, 11 | 'custom-property-pattern': null, 12 | 'declaration-block-no-redundant-longhand-properties': null, 13 | 'custom-property-empty-line-before': null, 14 | 'length-zero-no-unit': null, 15 | 'no-descending-specificity': null, 16 | 'value-keyword-case': [ 17 | 'lower', 18 | { 19 | ignoreKeywords: ['currentColor'], 20 | }, 21 | ], 22 | 'color-function-notation': null, 23 | 'order/order': ['custom-properties', 'declarations'], 24 | 'order/properties-order': ['width', 'height'], 25 | 'rule-empty-line-before': null, 26 | 'at-rule-no-unknown': [ 27 | true, 28 | { 29 | ignoreAtRules: ['container'], 30 | }, 31 | ], 32 | 'property-no-unknown': [ 33 | true, 34 | { 35 | ignoreProperties: ['container-type', 'container-name'], 36 | }, 37 | ], 38 | 'media-feature-range-notation': null, 39 | }, 40 | overrides: [ 41 | { 42 | files: ['blocks/**/*.css', 'solutions/**/*.css'], 43 | ignoreFiles: ['**/test/**/*.css'], 44 | plugins: ['./stylelint-force-app-name-prefix.cjs'], 45 | }, 46 | ], 47 | }; 48 | -------------------------------------------------------------------------------- /src/solutions/file-uploader/inline/index.css: -------------------------------------------------------------------------------- 1 | @import url("../../../blocks/themes/uc-basic/index.css"); 2 | 3 | @layer uc.solutions { 4 | [uc-file-uploader-inline] uc-start-from { 5 | height: 100%; 6 | container-type: inline-size; 7 | } 8 | 9 | [uc-file-uploader-inline] { 10 | --cfg-done-activity: "start-from"; 11 | --cfg-init-activity: "start-from"; 12 | 13 | flex: 1; 14 | } 15 | 16 | [uc-file-uploader-inline] uc-activity-header::after { 17 | width: var(--uc-button-size); 18 | height: var(--uc-button-size); 19 | content: ""; 20 | } 21 | 22 | [uc-file-uploader-inline] uc-activity-header .uc-close-btn { 23 | display: none; 24 | } 25 | 26 | [uc-file-uploader-inline] uc-copyright .uc-credits { 27 | position: static; 28 | } 29 | 30 | @container (min-width: 500px) { 31 | [uc-file-uploader-inline] uc-start-from .uc-content { 32 | grid-template-columns: 1fr max-content; 33 | height: 100%; 34 | } 35 | 36 | [uc-file-uploader-inline] uc-start-from uc-copyright { 37 | grid-column: 2; 38 | } 39 | 40 | [uc-file-uploader-inline] uc-start-from uc-drop-area { 41 | grid-row: span 3; 42 | } 43 | 44 | [uc-file-uploader-inline] uc-start-from:has(uc-copyright[hidden]) uc-drop-area { 45 | grid-row: span 2; 46 | } 47 | 48 | [uc-file-uploader-inline] uc-start-from:has(.uc-cancel-btn[hidden]) uc-drop-area { 49 | grid-row: span 2; 50 | } 51 | 52 | [uc-file-uploader-inline] uc-start-from:has(uc-copyright[hidden]):has(.uc-cancel-btn[hidden]) uc-drop-area { 53 | grid-row: span 1; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mapping of loading resources per operation 3 | */ 4 | export type LoadingOperations = Map>; 5 | 6 | /** 7 | * Image size 8 | */ 9 | export interface ImageSize { 10 | width: number; 11 | height: number; 12 | } 13 | 14 | export interface Rectangle { 15 | x: number; 16 | y: number; 17 | width: number; 18 | height: number; 19 | } 20 | 21 | export interface Transformations { 22 | enhance?: number; 23 | brightness?: number; 24 | exposure?: number; 25 | gamma?: number; 26 | contrast?: number; 27 | saturation?: number; 28 | vibrance?: number; 29 | warmth?: number; 30 | rotate?: number; 31 | mirror?: boolean; 32 | flip?: boolean; 33 | filter?: { name: string; amount: number }; 34 | crop?: { dimensions: [number, number]; coords: [number, number] }; 35 | } 36 | 37 | export interface ApplyResult { 38 | originalUrl: string; 39 | cdnUrlModifiers: string; 40 | cdnUrl: string; 41 | transformations: Transformations; 42 | } 43 | 44 | export type ChangeResult = ApplyResult; 45 | 46 | export interface CropAspectRatio { 47 | type: 'aspect-ratio'; 48 | width: number; 49 | height: number; 50 | id: string; 51 | hasFreeform?: boolean; 52 | } 53 | 54 | export type CropPresetList = CropAspectRatio[]; 55 | 56 | export type Direction = '' | 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; 57 | 58 | export type FrameThumbs = Partial< 59 | Record< 60 | Direction, 61 | { 62 | direction: Direction; 63 | pathNode: SVGElement; 64 | interactionNode: SVGElement; 65 | groupNode: SVGElement; 66 | } 67 | > 68 | >; 69 | -------------------------------------------------------------------------------- /src/blocks/ProgressBar/ProgressBar.ts: -------------------------------------------------------------------------------- 1 | import './progress-bar.css'; 2 | import { Block } from '../../abstract/Block'; 3 | 4 | export class ProgressBar extends Block { 5 | private _value = 0; 6 | 7 | private _visible = true; 8 | 9 | constructor() { 10 | super(); 11 | this.init$ = { 12 | ...this.init$, 13 | width: 0, 14 | opacity: 0, 15 | }; 16 | } 17 | 18 | override initCallback(): void { 19 | super.initCallback(); 20 | const handleFakeProgressAnimation = (): void => { 21 | const fakeProgressLine = this.ref.fakeProgressLine as HTMLElement; 22 | if (!this._visible) { 23 | fakeProgressLine.classList.add('uc-fake-progress--hidden'); 24 | return; 25 | } 26 | if (this._value > 0) { 27 | fakeProgressLine.classList.add('uc-fake-progress--hidden'); 28 | } 29 | }; 30 | 31 | (this.ref.fakeProgressLine as HTMLElement).addEventListener('animationiteration', handleFakeProgressAnimation); 32 | 33 | this.defineAccessor('value', (value: number | null | undefined) => { 34 | if (value === undefined || value === null) return; 35 | this._value = value; 36 | if (!this._visible) return; 37 | this.style.setProperty('--l-progress-value', this._value.toString()); 38 | }); 39 | 40 | this.defineAccessor('visible', (visible: boolean) => { 41 | this._visible = visible; 42 | this.classList.toggle('uc-progress-bar--hidden', !visible); 43 | }); 44 | } 45 | } 46 | 47 | ProgressBar.template = /* HTML */ ` 48 |
49 |
50 | `; 51 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/template.ts: -------------------------------------------------------------------------------- 1 | import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc'; 2 | import svgIconsSprite from './svg-sprite'; 3 | 4 | export const TEMPLATE = /* HTML */ ` 5 | ${svgIconsSprite} 6 |
7 | 8 |
9 |
10 | 11 |
12 |
Network error
13 |
14 | 17 |
18 |
19 |
20 |
{{fileType}}
21 |
22 |
23 | 24 | 25 | 26 |
27 |
{{msg}}
28 |
29 |
30 | 31 |
32 | 33 |
34 |
35 |
36 | `; 37 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/EditorButtonControl.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../abstract/Block'; 2 | import { classNames } from './lib/classNames.js'; 3 | 4 | interface EditorButtonControlInitState { 5 | active: boolean; 6 | title: string; 7 | icon: string; 8 | 'on.click': ((event: MouseEvent) => unknown) | null; 9 | 'title-prop': string; 10 | } 11 | 12 | export class EditorButtonControl extends Block { 13 | private _titleEl?: HTMLElement; 14 | 15 | constructor() { 16 | super(); 17 | this.init$ = { 18 | ...this.init$, 19 | active: false, 20 | title: '', 21 | icon: '', 22 | 'on.click': null, 23 | 'title-prop': '', 24 | } as EditorButtonControlInitState; 25 | } 26 | 27 | override initCallback(): void { 28 | super.initCallback(); 29 | 30 | this._titleEl = this.ref['title-el'] as HTMLElement | undefined; 31 | this.sub('title', (title: string) => { 32 | const titleEl = this._titleEl; 33 | if (titleEl) { 34 | titleEl.style.display = title ? 'block' : 'none'; 35 | } 36 | }); 37 | 38 | this.sub('active', (active: boolean) => { 39 | this.className = classNames({ 40 | 'uc-active': active, 41 | 'uc-not_active': !active, 42 | }); 43 | }); 44 | 45 | this.sub('on.click', (onClick: ((event: MouseEvent) => unknown) | null) => { 46 | this.onclick = onClick ?? null; 47 | }); 48 | } 49 | } 50 | 51 | EditorButtonControl.template = /* HTML */ ` 52 | 56 | `; 57 | -------------------------------------------------------------------------------- /src/blocks/FileItem/FileItemConfig.ts: -------------------------------------------------------------------------------- 1 | import { UploaderBlock } from '../../abstract/UploaderBlock'; 2 | import type { UploadEntryData, UploadEntryKeys, UploadEntryTypedData } from '../../abstract/uploadEntrySchema'; 3 | 4 | type EntrySubscription = ReturnType; 5 | 6 | export class FileItemConfig extends UploaderBlock { 7 | protected _entrySubs: Set = new Set(); 8 | 9 | protected _entry: UploadEntryTypedData | null = null; 10 | 11 | protected _withEntry( 12 | fn: (entry: UploadEntryTypedData, ...args: A) => R, 13 | ): (...args: A) => R | undefined { 14 | return (...args: A) => { 15 | const entry = this._entry; 16 | if (!entry) { 17 | console.warn('No entry found'); 18 | return undefined; 19 | } 20 | return fn(entry, ...args); 21 | }; 22 | } 23 | 24 | protected _subEntry(prop: K, handler: (value: UploadEntryData[K]) => void): void { 25 | this._withEntry<[K, (value: UploadEntryData[K]) => void], void>((entry, propInner, handlerInner) => { 26 | const sub = entry.subscribe(propInner, (value) => { 27 | if (this.isConnected) { 28 | handlerInner(value); 29 | } 30 | }); 31 | this._entrySubs.add(sub); 32 | })(prop, handler); 33 | } 34 | 35 | protected _reset(): void { 36 | for (const sub of this._entrySubs) { 37 | sub.remove(); 38 | } 39 | 40 | this._entrySubs = new Set(); 41 | this._entry = null; 42 | } 43 | 44 | override disconnectedCallback(): void { 45 | super.disconnectedCallback(); 46 | this._entrySubs = new Set(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/blocks/Range/Range.ts: -------------------------------------------------------------------------------- 1 | import { BaseComponent } from '@symbiotejs/symbiote'; 2 | 3 | interface RangeInitState { 4 | cssLeft: string; 5 | barActive: boolean; 6 | value: number; 7 | onChange: (e: Event) => void; 8 | } 9 | 10 | export class Range extends BaseComponent { 11 | private _range!: HTMLInputElement; 12 | 13 | constructor() { 14 | super(); 15 | this.init$ = { 16 | ...this.init$, 17 | cssLeft: '50%', 18 | barActive: false, 19 | value: 50, 20 | onChange: (e: Event) => { 21 | e.preventDefault(); 22 | e.stopPropagation(); 23 | this.$.value = parseFloat(this._range.value); 24 | this.dispatchEvent(new Event('change')); 25 | }, 26 | } as RangeInitState; 27 | } 28 | 29 | override initCallback(): void { 30 | super.initCallback(); 31 | this._range = this.ref.range as HTMLInputElement; 32 | [...this.attributes].forEach((attr) => { 33 | const exclude = ['style', 'ref']; 34 | if (!exclude.includes(attr.name)) { 35 | this.ref.range.setAttribute(attr.name, attr.value); 36 | } 37 | }); 38 | this.sub('value', (val: number) => { 39 | const pcnt = (val / 100) * 100; 40 | this.$.cssLeft = `${pcnt}%`; 41 | }); 42 | this.defineAccessor('value', (val: number) => { 43 | this.$.value = val; 44 | }); 45 | } 46 | } 47 | 48 | Range.template = /* HTML */ ` 49 |
50 |
51 |
52 |
53 |
54 | 55 | 56 | `; 57 | -------------------------------------------------------------------------------- /src/blocks/svg-backgrounds/svg-backgrounds.ts: -------------------------------------------------------------------------------- 1 | function createSvgBlobUrl(svg: string): string { 2 | const blob = new Blob([svg], { 3 | type: 'image/svg+xml', 4 | }); 5 | return URL.createObjectURL(blob); 6 | } 7 | 8 | export function checkerboardCssBg(color1 = '#fff', color2 = 'rgba(0, 0, 0, .1)'): string { 9 | return createSvgBlobUrl(/*svg*/ ` 10 | 11 | 12 | 13 | `); 14 | } 15 | 16 | export function strokesCssBg(color = 'rgba(0, 0, 0, .1)'): string { 17 | return createSvgBlobUrl(/*svg*/ ` 18 | 19 | `); 20 | } 21 | 22 | export function fileCssBg(color = 'hsl(209, 21%, 65%)', width = 32, height = 32): string { 23 | return createSvgBlobUrl(/*svg*/ ` 24 | 25 | 26 | 27 | `); 28 | } 29 | -------------------------------------------------------------------------------- /src/blocks/ProgressBarCommon/ProgressBarCommon.ts: -------------------------------------------------------------------------------- 1 | import './progress-bar-common.css'; 2 | import { UploaderBlock } from '../../abstract/UploaderBlock'; 3 | 4 | type BaseInitState = InstanceType['init$']; 5 | 6 | interface ProgressBarCommonInitState extends BaseInitState { 7 | visible: boolean; 8 | value: number; 9 | '*commonProgress': number; 10 | } 11 | 12 | export class ProgressBarCommon extends UploaderBlock { 13 | private _unobserveCollectionCb?: () => void; 14 | 15 | constructor() { 16 | super(); 17 | this.init$ = { 18 | ...this.init$, 19 | visible: false, 20 | value: 0, 21 | 22 | '*commonProgress': 0, 23 | } as ProgressBarCommonInitState; 24 | } 25 | 26 | override initCallback(): void { 27 | super.initCallback(); 28 | this._unobserveCollectionCb = this.uploadCollection.observeProperties(() => { 29 | const anyUploading = this.uploadCollection.items().some((id) => { 30 | const item = this.uploadCollection.read(id); 31 | return item?.getValue('isUploading') ?? false; 32 | }); 33 | 34 | this.$.visible = anyUploading; 35 | }); 36 | 37 | this.sub('visible', (visible: boolean) => { 38 | if (visible) { 39 | this.setAttribute('active', ''); 40 | } else { 41 | this.removeAttribute('active'); 42 | } 43 | }); 44 | 45 | this.sub('*commonProgress', (progress: number) => { 46 | this.$.value = progress; 47 | }); 48 | } 49 | 50 | override destroyCallback(): void { 51 | super.destroyCallback(); 52 | this._unobserveCollectionCb?.(); 53 | this._unobserveCollectionCb = undefined; 54 | } 55 | } 56 | 57 | ProgressBarCommon.template = /* HTML */ ` `; 58 | -------------------------------------------------------------------------------- /src/blocks/SimpleBtn/SimpleBtn.ts: -------------------------------------------------------------------------------- 1 | import './simple-btn.css'; 2 | import { UploaderBlock } from '../../abstract/UploaderBlock'; 3 | import { asBoolean } from '../Config/validatorsType'; 4 | 5 | type BaseInitState = InstanceType['init$']; 6 | interface SimpleBtnInitState extends BaseInitState { 7 | withDropZone: boolean; 8 | onClick: () => void; 9 | 'button-text': string; 10 | } 11 | 12 | export class SimpleBtn extends UploaderBlock { 13 | static override styleAttrs = [...super.styleAttrs, 'uc-simple-btn']; 14 | override couldBeCtxOwner = true; 15 | 16 | constructor() { 17 | super(); 18 | 19 | this.init$ = { 20 | ...this.init$, 21 | withDropZone: true, 22 | onClick: () => { 23 | this.api.initFlow(); 24 | }, 25 | 'button-text': '', 26 | } as SimpleBtnInitState; 27 | } 28 | 29 | override initCallback(): void { 30 | super.initCallback(); 31 | 32 | this.defineAccessor('dropzone', (val: unknown) => { 33 | if (typeof val === 'undefined') { 34 | return; 35 | } 36 | this.$.withDropZone = asBoolean(val); 37 | }); 38 | this.subConfigValue('multiple', (val) => { 39 | this.$['button-text'] = val ? 'upload-files' : 'upload-file'; 40 | }); 41 | } 42 | } 43 | 44 | SimpleBtn.template = /* HTML */ ` 45 | 46 | 52 | 53 | `; 54 | 55 | SimpleBtn.bindAttributes({ 56 | // @ts-expect-error TODO: we need to update symbiote types 57 | dropzone: null, 58 | }); 59 | -------------------------------------------------------------------------------- /src/blocks/Select/Select.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../abstract/Block'; 2 | import './select.css'; 3 | 4 | type SelectOption = { 5 | text: string; 6 | value: string; 7 | }; 8 | 9 | type BaseInitState = InstanceType['init$']; 10 | 11 | interface SelectInitState extends BaseInitState { 12 | currentText: string; 13 | options: SelectOption[]; 14 | selectHtml: string; 15 | onSelect: (event: Event) => void; 16 | } 17 | 18 | export class Select extends Block { 19 | declare ref: { select: HTMLSelectElement } & Record; 20 | declare value: string; 21 | 22 | constructor() { 23 | super(); 24 | this.init$ = { 25 | ...this.init$, 26 | currentText: '', 27 | options: [], 28 | selectHtml: '', 29 | onSelect: (event: Event) => { 30 | event.preventDefault(); 31 | event.stopPropagation(); 32 | const selectElement = this.ref.select; 33 | this.value = selectElement.value; 34 | this.$.currentText = 35 | this.$.options.find((option: SelectOption) => { 36 | return option.value === this.value; 37 | })?.text || ''; 38 | this.dispatchEvent(new Event('change')); 39 | }, 40 | } as SelectInitState; 41 | } 42 | 43 | override initCallback(): void { 44 | super.initCallback(); 45 | 46 | this.sub('options', (options: SelectOption[]) => { 47 | this.$.currentText = options?.[0]?.text || ''; 48 | let html = ''; 49 | options?.forEach((option) => { 50 | html += /* HTML */ ``; 51 | }); 52 | this.$.selectHtml = html; 53 | }); 54 | } 55 | } 56 | 57 | Select.template = /* HTML */ ` `; 58 | -------------------------------------------------------------------------------- /src/abstract/localeRegistry.ts: -------------------------------------------------------------------------------- 1 | import { default as en } from '../locales/file-uploader/en'; 2 | 3 | export type LocaleDefinition = Record; 4 | export type LocaleDefinitionResolver = () => Promise; 5 | 6 | const localeRegistry: Map = new Map(); 7 | const localeResolvers: Map = new Map(); 8 | 9 | const defineLocaleSync = (localeName: string, definition: LocaleDefinition): void => { 10 | if (localeRegistry.has(localeName)) { 11 | console.log(`Locale ${localeName} is already defined. Overwriting...`); 12 | } 13 | 14 | localeRegistry.set(localeName, { ...(en as unknown as LocaleDefinition), ...definition }); 15 | }; 16 | 17 | const defineLocaleAsync = (localeName: string, definitionResolver: LocaleDefinitionResolver): void => { 18 | localeResolvers.set(localeName, definitionResolver); 19 | }; 20 | 21 | export const defineLocale = ( 22 | localeName: string, 23 | definitionOrResolver: LocaleDefinition | LocaleDefinitionResolver, 24 | ): void => { 25 | if (typeof definitionOrResolver === 'function') { 26 | defineLocaleAsync(localeName, definitionOrResolver); 27 | } else { 28 | defineLocaleSync(localeName, definitionOrResolver); 29 | } 30 | }; 31 | 32 | export const resolveLocaleDefinition = async (localeName: string): Promise => { 33 | if (!localeRegistry.has(localeName)) { 34 | if (!localeResolvers.has(localeName)) { 35 | throw new Error(`Locale ${localeName} is not defined`); 36 | } 37 | 38 | const definitionResolver = localeResolvers.get(localeName)!; 39 | const definition = await definitionResolver(); 40 | defineLocaleSync(localeName, definition); 41 | } 42 | 43 | return localeRegistry.get(localeName)!; 44 | }; 45 | 46 | defineLocale('en', en); 47 | -------------------------------------------------------------------------------- /src/blocks/themes/uc-basic/icons/microphone-mute.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /demo/secure-uploads.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/blocks/SourceList/SourceList.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../abstract/Block'; 2 | import { browserFeatures } from '../../utils/browser-info'; 3 | import { deserializeCsv } from '../../utils/comma-separated'; 4 | import { stringToArray } from '../../utils/stringToArray'; 5 | 6 | export class SourceList extends Block { 7 | override initCallback(): void { 8 | super.initCallback(); 9 | 10 | this.subConfigValue('sourceList', (val: string) => { 11 | const list = stringToArray(val); 12 | let html = ''; 13 | 14 | list.forEach((srcName) => { 15 | if (srcName === 'instagram') { 16 | console.error( 17 | "Instagram source was removed because the Instagram Basic Display API hasn't been available since December 4, 2024. " + 18 | 'Official statement, see here:' + 19 | 'https://developers.facebook.com/blog/post/2024/09/04/update-on-instagram-basic-display-api/?locale=en_US', 20 | ); 21 | return; 22 | } 23 | 24 | if (srcName === 'camera' && browserFeatures.htmlMediaCapture) { 25 | this.subConfigValue('cameraModes', (cameraModesValue: string) => { 26 | const cameraModes = deserializeCsv(cameraModesValue); 27 | 28 | cameraModes.forEach((mode) => { 29 | html += /* HTML */ ``; 30 | }); 31 | 32 | if (cameraModes.length === 0) { 33 | html += /* HTML */ ``; 34 | } 35 | }); 36 | 37 | return; 38 | } 39 | 40 | html += /* HTML */ ``; 41 | }); 42 | 43 | if (this.cfg.sourceListWrap) { 44 | this.innerHTML = html; 45 | } else { 46 | this.outerHTML = html; 47 | } 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/EditorOperationControl.ts: -------------------------------------------------------------------------------- 1 | import { EditorButtonControl } from './EditorButtonControl.js'; 2 | import type { ColorOperation } from './toolbar-constants'; 3 | import { COLOR_OPERATIONS_CONFIG } from './toolbar-constants.js'; 4 | import type { Transformations } from './types'; 5 | import { parseFilterValue } from './utils/parseFilterValue.js'; 6 | 7 | export class EditorOperationControl extends EditorButtonControl { 8 | private _operation: ColorOperation | '' = ''; 9 | 10 | override initCallback(): void { 11 | super.initCallback(); 12 | 13 | this.$['on.click'] = (e: MouseEvent) => { 14 | const slider = this.$['*sliderEl'] as { setOperation: (operation: ColorOperation | '') => void }; 15 | slider.setOperation(this._operation); 16 | this.$['*showSlider'] = true; 17 | this.$['*currentOperation'] = this._operation; 18 | 19 | this.telemetryManager.sendEventCloudImageEditor(e, this.$['*tabId'], { 20 | operation: parseFilterValue(this.$['*operationTooltip']), 21 | }); 22 | }; 23 | 24 | this.defineAccessor('operation', (operation: ColorOperation) => { 25 | if (operation) { 26 | this._operation = operation; 27 | this.$.icon = operation; 28 | this.bindL10n('title-prop', () => 29 | this.l10n('a11y-cloud-editor-apply-tuning', { 30 | name: this.l10n(operation).toLowerCase(), 31 | }), 32 | ); 33 | this.bindL10n('title', () => this.l10n(operation)); 34 | } 35 | }); 36 | 37 | this.sub('*editorTransformations', (editorTransformations: Transformations) => { 38 | if (!this._operation) { 39 | return; 40 | } 41 | 42 | const { zero } = COLOR_OPERATIONS_CONFIG[this._operation]; 43 | const value = editorTransformations[this._operation]; 44 | const isActive = typeof value !== 'undefined' ? value !== zero : false; 45 | this.$.active = isActive; 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/blocks/SimpleBtn/simple-btn.css: -------------------------------------------------------------------------------- 1 | @layer uc.components { 2 | :where([uc-simple-btn]) { 3 | position: relative; 4 | display: inline-flex; 5 | } 6 | 7 | :where([uc-simple-btn]) button { 8 | height: auto; 9 | gap: 0.5em; 10 | padding: var(--uc-simple-btn-padding); 11 | background-color: var(--uc-simple-btn); 12 | color: var(--uc-simple-btn-foreground); 13 | font-size: var(--uc-simple-btn-font-size); 14 | font-family: var(--uc-simple-btn-font-family); 15 | } 16 | 17 | :where([uc-simple-btn]) button uc-icon { 18 | width: auto; 19 | height: auto; 20 | } 21 | 22 | :where([uc-simple-btn]) button uc-icon svg { 23 | width: 0.9em; 24 | height: 0.9em; 25 | } 26 | 27 | :where([uc-simple-btn]) button:hover { 28 | background-color: var(--uc-simple-btn-hover); 29 | } 30 | 31 | :where([uc-simple-btn]) > uc-drop-area { 32 | display: contents; 33 | } 34 | 35 | :where([uc-simple-btn]) .uc-visual-drop-area { 36 | position: absolute; 37 | top: 0px; 38 | left: 0px; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | width: 100%; 43 | height: 100%; 44 | padding: var(--uc-simple-btn-padding); 45 | background-color: transparent; 46 | color: transparent; 47 | font-size: var(--uc-simple-btn-font-size); 48 | border: 1px dashed var(--uc-simple-btn-foreground); 49 | border-radius: inherit; 50 | opacity: 0; 51 | transition: opacity var(--uc-transition); 52 | } 53 | 54 | :where([uc-simple-btn]) > uc-drop-area[drag-state="active"] .uc-visual-drop-area { 55 | opacity: 1; 56 | } 57 | :where([uc-simple-btn]) > uc-drop-area[drag-state="inactive"] .uc-visual-drop-area { 58 | opacity: 0; 59 | } 60 | :where([uc-simple-btn]) > uc-drop-area[drag-state="near"] .uc-visual-drop-area { 61 | opacity: 1; 62 | } 63 | :where([uc-simple-btn]) > uc-drop-area[drag-state="over"] .uc-visual-drop-area { 64 | opacity: 1; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/blocks/CameraSource/__tests__/calcCameraModes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import type { ConfigType } from '../../../types/index'; 3 | import { initialConfig } from '../../Config/initialConfig'; 4 | import { calcCameraModes } from '../calcCameraModes'; 5 | 6 | describe('calcCameraModes', () => { 7 | it('should return both modes enabled when cameraModes includes video and photo', () => { 8 | const cfg = { ...initialConfig } as ConfigType; 9 | const result = calcCameraModes(cfg); 10 | expect(result).toEqual({ 11 | isVideoRecordingEnabled: true, 12 | isPhotoEnabled: true, 13 | }); 14 | }); 15 | 16 | it('should return only video enabled when cameraModes includes only video', () => { 17 | const cfg = { ...initialConfig, cameraModes: 'video' } as ConfigType; 18 | const result = calcCameraModes(cfg); 19 | expect(result).toEqual({ 20 | isVideoRecordingEnabled: true, 21 | isPhotoEnabled: false, 22 | }); 23 | }); 24 | 25 | it('should return only photo enabled when cameraModes includes only photo', () => { 26 | const cfg = { ...initialConfig, cameraModes: 'photo' } as ConfigType; 27 | const result = calcCameraModes(cfg); 28 | expect(result).toEqual({ 29 | isVideoRecordingEnabled: false, 30 | isPhotoEnabled: true, 31 | }); 32 | }); 33 | 34 | it('should return both modes disabled when cameraModes is empty', () => { 35 | const cfg = { ...initialConfig, cameraModes: '' } as ConfigType; 36 | const result = calcCameraModes(cfg); 37 | expect(result).toEqual({ 38 | isVideoRecordingEnabled: false, 39 | isPhotoEnabled: false, 40 | }); 41 | }); 42 | 43 | it('should handle mixed valid and invalid values', () => { 44 | const cfg = { cameraModes: 'video,unknown,photo' } as ConfigType; 45 | const result = calcCameraModes(cfg); 46 | expect(result).toEqual({ 47 | isVideoRecordingEnabled: true, 48 | isPhotoEnabled: true, 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/utils/template-utils.ts: -------------------------------------------------------------------------------- 1 | type InputData = { 2 | [key: string]: string | number | boolean | InputData; 3 | }; 4 | 5 | type Transformer = (value: string) => string; 6 | 7 | type Options = { 8 | openToken?: string; 9 | closeToken?: string; 10 | transform?: Transformer; 11 | }; 12 | 13 | const DEFAULT_TRANSFORMER: Transformer = (value) => value; 14 | const OPEN_TOKEN = '{{'; 15 | const CLOSE_TOKEN = '}}'; 16 | const PLURAL_PREFIX = 'plural:'; 17 | 18 | export function applyTemplateData(template: string, data: InputData = {}, options: Options = {}): string { 19 | const { openToken = OPEN_TOKEN, closeToken = CLOSE_TOKEN, transform = DEFAULT_TRANSFORMER } = options; 20 | 21 | for (const key in data) { 22 | const rawValue = data[key]; 23 | const value = rawValue != null ? rawValue.toString() : undefined; 24 | const replacement = typeof value === 'string' ? transform(value) : String(value); 25 | template = template.replaceAll(openToken + key + closeToken, replacement); 26 | } 27 | return template; 28 | } 29 | 30 | export function getPluralObjects( 31 | template: string, 32 | ): Array<{ variable: string; pluralKey: string; countVariable: string }> { 33 | const pluralObjects: Array<{ variable: string; pluralKey: string; countVariable: string }> = []; 34 | let open = template.indexOf(OPEN_TOKEN); 35 | while (open !== -1) { 36 | const close = template.indexOf(CLOSE_TOKEN, open); 37 | if (close === -1) { 38 | break; 39 | } 40 | const variable = template.substring(open + 2, close); 41 | if (variable.startsWith(PLURAL_PREFIX)) { 42 | const keyValue = template.substring(open + 2, close).replace(PLURAL_PREFIX, ''); 43 | const key = keyValue.substring(0, keyValue.indexOf('(')); 44 | const count = keyValue.substring(keyValue.indexOf('(') + 1, keyValue.indexOf(')')); 45 | pluralObjects.push({ variable, pluralKey: key, countVariable: count }); 46 | } 47 | open = template.indexOf(OPEN_TOKEN, close); 48 | } 49 | return pluralObjects; 50 | } 51 | -------------------------------------------------------------------------------- /src/blocks/CloudImageEditor/src/elements/presence-toggle/PresenceToggle.ts: -------------------------------------------------------------------------------- 1 | import { Block } from '../../../../../abstract/Block'; 2 | import { applyClassNames } from '../../lib/classNames'; 3 | 4 | type PresenceToggleStyle = { 5 | transition?: string; 6 | visible?: string; 7 | hidden?: string; 8 | }; 9 | 10 | const DEFAULT_STYLE: Required = { 11 | transition: 'uc-transition', 12 | visible: 'uc-visible', 13 | hidden: 'uc-hidden', 14 | }; 15 | 16 | export class PresenceToggle extends Block { 17 | private _visible = false; 18 | private _visibleStyle: string = DEFAULT_STYLE.visible; 19 | private _hiddenStyle: string = DEFAULT_STYLE.hidden; 20 | private _externalTransitions = false; 21 | 22 | constructor() { 23 | super(); 24 | 25 | this.defineAccessor('styles', (styles?: PresenceToggleStyle) => { 26 | if (!styles) { 27 | return; 28 | } 29 | this._externalTransitions = true; 30 | this._visibleStyle = styles.visible ?? DEFAULT_STYLE.visible; 31 | this._hiddenStyle = styles.hidden ?? DEFAULT_STYLE.hidden; 32 | }); 33 | 34 | this.defineAccessor('visible', (visible?: boolean) => { 35 | if (typeof visible !== 'boolean') { 36 | return; 37 | } 38 | 39 | this._visible = visible; 40 | this._handleVisible(); 41 | }); 42 | } 43 | 44 | private _handleVisible(): void { 45 | this.style.visibility = this._visible ? 'inherit' : 'hidden'; 46 | applyClassNames(this, { 47 | [DEFAULT_STYLE.transition]: !this._externalTransitions, 48 | [this._visibleStyle]: this._visible, 49 | [this._hiddenStyle]: !this._visible, 50 | }); 51 | this.setAttribute('aria-hidden', this._visible ? 'false' : 'true'); 52 | } 53 | 54 | override initCallback(): void { 55 | super.initCallback(); 56 | 57 | this.classList.toggle('uc-initial', true); 58 | 59 | if (!this._externalTransitions) { 60 | this.classList.add(DEFAULT_STYLE.transition); 61 | } 62 | 63 | this._handleVisible(); 64 | setTimeout(() => { 65 | this.classList.toggle('uc-initial', false); 66 | }, 0); 67 | } 68 | } 69 | PresenceToggle.template = /* HTML */ ` `; 70 | -------------------------------------------------------------------------------- /demo/upload-api.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
53 | Please select behaviour: 54 |
55 | 56 | 57 | 58 | 59 | 60 |
61 |
62 | -------------------------------------------------------------------------------- /demo/new-social-sources-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 42 | 43 | 44 | 45 | 51 | 52 | 53 |
54 | Options 55 | 59 | 63 | 67 |
68 | -------------------------------------------------------------------------------- /src/blocks/ExternalSource/MessageBridge.ts: -------------------------------------------------------------------------------- 1 | import type { InputMessageHandler, InputMessageMap, InputMessageType, OutputMessage } from './types'; 2 | 3 | const MESSAGE_TYPE_WHITELIST: InputMessageType[] = ['selected-files-change', 'toolbar-state-change']; 4 | 5 | const isWhitelistedMessage = (message: unknown): message is InputMessageMap[InputMessageType] => { 6 | if (!message) return false; 7 | if (typeof message !== 'object') return false; 8 | if (!('type' in message)) return false; 9 | 10 | const type = (message as { type?: unknown }).type; 11 | if (typeof type !== 'string') return false; 12 | if (!MESSAGE_TYPE_WHITELIST.includes(type as InputMessageType)) return false; 13 | 14 | return true; 15 | }; 16 | 17 | export class MessageBridge { 18 | private _handlerMap = new Map>>(); 19 | 20 | private _context: Window; 21 | 22 | private _getTargetOrigin: () => string; 23 | 24 | constructor(context: Window, getTargetOrigin: () => string) { 25 | this._context = context; 26 | this._getTargetOrigin = getTargetOrigin; 27 | 28 | window.addEventListener('message', this._handleMessage); 29 | } 30 | 31 | _handleMessage = (e: MessageEvent) => { 32 | if (e.source !== this._context) { 33 | return; 34 | } 35 | const message = e.data; 36 | if (!isWhitelistedMessage(message)) { 37 | return; 38 | } 39 | 40 | const handlers = this._handlerMap.get(message.type); 41 | if (handlers) { 42 | for (const handler of handlers) { 43 | handler(message); 44 | } 45 | } 46 | }; 47 | 48 | on(type: T, handler: InputMessageHandler) { 49 | const handlers = this._handlerMap.get(type) ?? new Set>(); 50 | if (!this._handlerMap.has(type)) { 51 | this._handlerMap.set(type, handlers); 52 | } 53 | 54 | handlers.add(handler as InputMessageHandler); 55 | } 56 | 57 | send(message: OutputMessage) { 58 | const targetOrigin = this._getTargetOrigin(); 59 | this._context.postMessage(message, targetOrigin); 60 | } 61 | 62 | destroy() { 63 | window.removeEventListener('message', this._handleMessage); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/template-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { applyTemplateData, getPluralObjects } from './template-utils'; 3 | 4 | describe('template-utils', () => { 5 | describe('applyTemplateData', () => { 6 | it('should return the same string if no variables passed', () => { 7 | const result = applyTemplateData('Hello world!'); 8 | expect(result).toBe('Hello world!'); 9 | }); 10 | 11 | it('should replace variables', () => { 12 | const result = applyTemplateData("Hello world! My name is {{name}}. I'm {{age}} years old.", { 13 | name: 'John Doe', 14 | age: 12, 15 | }); 16 | expect(result).toBe("Hello world! My name is John Doe. I'm 12 years old."); 17 | }); 18 | 19 | it('should work with variables at start/end', () => { 20 | const result = applyTemplateData("{{name}} my name is. I'm {{age}}", { name: 'John Doe', age: 12 }); 21 | expect(result).toBe("John Doe my name is. I'm 12"); 22 | }); 23 | 24 | it('should work with single variable', () => { 25 | const result = applyTemplateData('{{name}}', { name: 'John Doe' }); 26 | expect(result).toBe('John Doe'); 27 | }); 28 | 29 | it('should not replace non-defined variabled', () => { 30 | const result = applyTemplateData('My name is {{name}}'); 31 | expect(result).toBe('My name is {{name}}'); 32 | }); 33 | 34 | it('should accept `transform` option', () => { 35 | const result = applyTemplateData( 36 | 'My name is {{name}}', 37 | { name: 'John Doe' }, 38 | { transform: (value) => value.toUpperCase() }, 39 | ); 40 | expect(result).toBe('My name is JOHN DOE'); 41 | }); 42 | }); 43 | 44 | describe('getPluralObjects', () => { 45 | it('should return array of plural objects', () => { 46 | expect( 47 | getPluralObjects( 48 | 'Uploading {{filesCount}} {{plural:file(filesCount)}} with {{errorsCount}} {{plural:error(errorsCount)}}', 49 | ), 50 | ).toEqual([ 51 | { variable: 'plural:file(filesCount)', pluralKey: 'file', countVariable: 'filesCount' }, 52 | { variable: 'plural:error(errorsCount)', pluralKey: 'error', countVariable: 'errorsCount' }, 53 | ]); 54 | }); 55 | }); 56 | }); 57 | --------------------------------------------------------------------------------