diff --git a/editor/README.md b/editor/README.md new file mode 100644 index 000000000..4fdc483b8 --- /dev/null +++ b/editor/README.md @@ -0,0 +1,11 @@ +# example + +```html + + + + + + + \ No newline at end of file diff --git a/editor/src/elements/button/test.js b/editor/src/elements/button/test.js new file mode 100644 index 000000000..91dcc5d67 --- /dev/null +++ b/editor/src/elements/button/test.js @@ -0,0 +1,21 @@ +import { AppComponent } from '../../lib/AppComponent.js'; +import { UcBtnUi } from './UcBtnUi.js'; + +class TestApp extends AppComponent { + constructor() { + super(); + this.state = { + text: 'Button Text', + icon: 'more', + }; + } +} +TestApp.renderShadow = true; +TestApp.template = /*html*/ ` + +<${UcBtnUi.is} reverse set="#text: text; #icon: icon"> +
 
+<${UcBtnUi.is} text="One more button..."> +
 
+`; +TestApp.defineTag('test-app'); diff --git a/editor/src/elements/line-loader/LineLoaderUi.js b/editor/src/elements/line-loader/LineLoaderUi.js new file mode 100644 index 000000000..00113e35b --- /dev/null +++ b/editor/src/elements/line-loader/LineLoaderUi.js @@ -0,0 +1,54 @@ +import { AppComponent } from '../../AppComponent.js' + +export class LineLoaderUi extends AppComponent { + constructor() { + super() + + this._active = false + + this._handleTransitionEndRight = () => { + let lineEl = this['line-el'] + lineEl.style.transition = `initial` + lineEl.style.opacity = '0' + lineEl.style.transform = `translateX(-100%)` + this._active && this._start() + } + } + + readyCallback() { + super.readyCallback() + this.defineAccessor('active', (active) => { + if (typeof active === 'boolean') { + if (active) { + this._start() + } else { + this._stop() + } + } + }) + } + + _start() { + this._active = true + let { width } = this.getBoundingClientRect() + let lineEl = this['line-el'] + lineEl.style.transition = `transform 1s` + lineEl.style.opacity = '1' + lineEl.style.transform = `translateX(${width}px)` + lineEl.addEventListener('transitionend', this._handleTransitionEndRight, { + once: true, + }) + } + + _stop() { + this._active = false + } +} + +LineLoaderUi.template = /*html*/ ` +
+
+
+` + +LineLoaderUi.is = 'line-loader-ui' diff --git a/editor/src/elements/presence-toggle/PresenceToggle.js b/editor/src/elements/presence-toggle/PresenceToggle.js new file mode 100644 index 000000000..024c94efa --- /dev/null +++ b/editor/src/elements/presence-toggle/PresenceToggle.js @@ -0,0 +1,65 @@ +import { AppComponent } from '../../AppComponent.js' +import { applyElementStyles } from '../../../../symbiote/core/css_utils.js' +import { applyClassNames } from '../../lib/classNames.js' + +const DEFAULT_STYLE = { + transition: 'transition', + visible: 'visible', + hidden: 'hidden', +} + +export class PresenceToggle extends AppComponent { + constructor() { + super() + + this._visible = false + this._visibleStyle = DEFAULT_STYLE.visible + this._hiddenStyle = DEFAULT_STYLE.hidden + this._externalTransitions = false + + this.defineAccessor('visible', (visible) => { + if (typeof visible !== 'boolean') { + return + } + + this._visible = visible + if (this.__readyOnce) { + this._handleVisible() + } + }) + + this.defineAccessor('styles', (styles) => { + if (!styles) { + return + } + this._externalTransitions = true + this._visibleStyle = styles.visible + this._hiddenStyle = styles.hidden + }) + } + + _handleVisible() { + this.style.visibility = this._visible ? 'inherit' : 'hidden' + applyClassNames(this, { + [DEFAULT_STYLE.transition]: !this._externalTransitions, + [this._visibleStyle]: this._visible, + [this._hiddenStyle]: !this._visible, + }) + this.setAttribute('aria-hidden', this._visible ? 'true' : 'false') + } + + readyCallback() { + super.readyCallback() + + if (!this._externalTransitions) { + this.classList.add(DEFAULT_STYLE.transition) + } + + this._handleVisible() + } +} +PresenceToggle.template = /*html*/ ` + +` + +PresenceToggle.is = 'presence-toggle' diff --git a/editor/src/elements/slider/SliderUi.js b/editor/src/elements/slider/SliderUi.js new file mode 100644 index 000000000..2dc62a4b6 --- /dev/null +++ b/editor/src/elements/slider/SliderUi.js @@ -0,0 +1,182 @@ +import { AppComponent } from '../../AppComponent.js' +import { ResizeObserver } from '../../lib/ResizeObserver.js' + +export class SliderUi extends AppComponent { + constructor() { + super() + this.setAttribute('with-effects', '') + this.state = { + disabled: false, + min: 0, + max: 100, + onInput: null, + onChange: null, + defaultValue: null, + 'on.sliderInput': () => { + let value = parseInt(this['input-el'].value, 10) + this._updateValue(value) + this.state.onInput && this.state.onInput(value) + }, + 'on.sliderChange': () => { + let value = parseInt(this['input-el'].value, 10) + this.state.onChange && this.state.onChange(value) + }, + } + } + + readyCallback() { + super.readyCallback() + + this.defineAccessor('disabled', (disabled) => { + this.state.disabled = disabled + }) + + this.defineAccessor('min', (min) => { + this.state.min = min + }) + + this.defineAccessor('max', (max) => { + this.state.max = max + }) + + this.defineAccessor('defaultValue', (defaultValue) => { + this.state.defaultValue = defaultValue + this['input-el'].value = defaultValue + this._updateValue(defaultValue) + }) + + this.defineAccessor('zero', (zero) => { + this._zero = zero + }) + + this.defineAccessor('onInput', (onInput) => { + if (!onInput) return + this.state.onInput = onInput + }) + + this.defineAccessor('onChange', (onChange) => { + if (!onChange) return + this.state.onChange = onChange + }) + } + + _updateValue(value) { + this._updateZeroDot(value) + + let { width } = this.getBoundingClientRect() + let slope = 100 / (this.state.max - this.state.min) + let mappedValue = slope * (value - this.state.min) + let offset = (mappedValue * (width - this._thumbSize)) / 100 + window.requestAnimationFrame(() => { + this['thumb-el'].style.transform = `translateX(${offset}px)` + }) + } + + _updateZeroDot(value) { + if (!this._zeroDotEl) { + return + } + if (value === this._zero) { + this._zeroDotEl.style.opacity = '0' + } else { + this._zeroDotEl.style.opacity = '0.2' + } + let { width } = this.getBoundingClientRect() + let slope = 100 / (this.state.max - this.state.min) + let mappedValue = slope * (this._zero - this.state.min) + let offset = (mappedValue * (width - this._thumbSize)) / 100 + window.requestAnimationFrame(() => { + this._zeroDotEl.style.transform = `translateX(${offset}px)` + }) + } + + _updateSteps() { + const STEP_GAP = 15 + + let stepsEl = this['steps-el'] + let { width } = stepsEl.getBoundingClientRect() + let half = Math.ceil(width / 2) + let count = Math.ceil(half / STEP_GAP) - 2 + + if (this._stepsCount === count) { + return + } + + let fr = document.createDocumentFragment() + let minorStepEl = document.createElement('div') + let borderStepEl = document.createElement('div') + minorStepEl.className = 'minor-step' + borderStepEl.className = 'border-step' + fr.appendChild(borderStepEl) + for (let i = 0; i < count; i++) { + fr.appendChild(minorStepEl.cloneNode()) + } + fr.appendChild(borderStepEl.cloneNode()) + for (let i = 0; i < count; i++) { + fr.appendChild(minorStepEl.cloneNode()) + } + fr.appendChild(borderStepEl.cloneNode()) + + let zeroDotEl = document.createElement('div') + zeroDotEl.className = 'zero-dot' + fr.appendChild(zeroDotEl) + this._zeroDotEl = zeroDotEl + + stepsEl.innerHTML = '' + stepsEl.appendChild(fr) + this._stepsCount = count + } + + connectedCallback() { + super.connectedCallback() + + this._updateSteps() + + this._observer = new ResizeObserver(() => { + this._updateSteps() + let value = parseInt(this['input-el'].value, 10) + this._updateValue(value) + }) + this._observer.observe(this) + + this._thumbSize = parseInt( + this.style.getPropertyValue('--l-thumb-size'), + 10, + ) + + setTimeout(() => { + let value = parseInt(this['input-el'].value, 10) + this._updateValue(value) + }, 0) + + this.sub('disabled', (disabled) => { + let el = this['input-el'] + if (disabled) { + el.setAttribute('disabled', 'disabled') + } else { + el.removeAttribute('disabled') + } + }) + + let inputEl = this.ref('input-el') + inputEl.addEventListener('focus', () => { + this.style.setProperty('--color-effect', 'var(--hover-color-rgb)') + }) + inputEl.addEventListener('blur', () => { + this.style.setProperty('--color-effect', 'var(--idle-color-rgb)') + }) + } + + disconnectedCallback() { + this._observer.unobserve(this) + this._observer = undefined + } +} +SliderUi.template = /*html*/ ` +
+
+
+ +` + +SliderUi.is = 'slider-ui' diff --git a/editor/src/elements/slider/test.html b/editor/src/elements/slider/test.html new file mode 100644 index 000000000..7ad2c1514 --- /dev/null +++ b/editor/src/elements/slider/test.html @@ -0,0 +1,12 @@ + + + + + + SliderUi + + + + + + diff --git a/editor/src/elements/slider/test.js b/editor/src/elements/slider/test.js new file mode 100644 index 000000000..1e221dde3 --- /dev/null +++ b/editor/src/elements/slider/test.js @@ -0,0 +1,34 @@ +import { BaseComponent } from '../../../jsdk/symbiote/core/BaseComponent.js'; +import { UploadcareUI } from '../../api/ui.js'; +import { SliderUi } from './SliderUi.js'; + +UploadcareUI.init(); + +class CtxProvider extends BaseComponent { + constructor() { + super(); + + this.state = { + min: -200, + max: 200, + defaultValue: -100, + }; + } +} +CtxProvider.styles = { + ':host': { + '--color-text-base': 'black', + '--color-primary-accent': 'blue', + width: '190px', + height: '40px', + backgroundColor: '#F5F5F5', + borderRadius: '3px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + paddingLeft: '10px', + paddingRight: '10px', + }, +}; +CtxProvider.template = /*html*/ `<${SliderUi.is} set="min: min; max: max: defaultValue: defaultValue">`; +window.customElements.define('ctx-provider', CtxProvider); diff --git a/editor/src/env.js b/editor/src/env.js new file mode 100644 index 000000000..6fa0a614f --- /dev/null +++ b/editor/src/env.js @@ -0,0 +1,4 @@ +export const DEPLOY_ENV = typeof __DEPLOY_ENV__ !== 'undefined' ? __DEPLOY_ENV__ : 'local'; +export const DEBUG = typeof __DEBUG__ !== 'undefined' ? __DEBUG__ : 'true'; +export const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : '0.0.0'; +export const LOCALES = typeof __LOCALES__ !== 'undefined' ? __LOCALES__ : ['ru-RU']; diff --git a/editor/src/icons/README.md b/editor/src/icons/README.md new file mode 100644 index 000000000..3528d5097 --- /dev/null +++ b/editor/src/icons/README.md @@ -0,0 +1 @@ +### SVG Icons library diff --git a/editor/src/icons/icon-set.js b/editor/src/icons/icon-set.js new file mode 100644 index 000000000..c559e6d40 --- /dev/null +++ b/editor/src/icons/icon-set.js @@ -0,0 +1,225 @@ +/** @type {Readonly<{ [key: string]: { w: import('./ucIconHtml.js').IconSize; g?: string; p?: string } }>} */ +export const UC_ICONS = Object.freeze({ + edit: { + w: 20, + p: + 'M5.51389 17.6793L1 19.151l1.5-4.4288m3.01389 2.9571L2.5 14.7222m3.01389 2.9571L17.0139 6.39623M2.5 14.7222L14 3.43921m3.0139 2.95702l1.7586-1.72539c.3995-.39201.3995-1.03561 0-1.42762l-1.586-1.55609c-.389-.38159-1.0118-.38159-1.4007 0L14 3.43921m3.0139 2.95702L14 3.43921', + }, + closeMax: { w: 20, p: 'M3 3l14 14m0-14L3 17' }, + more: { w: 10, p: 'M.5 3L5 7l4.5-4' }, + check: { w: 10, p: 'M.5 5.31579l2.84211 2.8421L9.5 2' }, + arrowRight: { w: 20, p: 'M1 10h17M10.8421 3L18 10l-7.1579 7' }, + done: { w: 20, p: 'M1 10.6316l5.68421 5.6842L19 4' }, + dropzone: { w: 20, p: 'M4.154 6.902L10 1l5.846 5.902M.5 19h19M10 15.016V1.01' }, + crop: { + w: 20, + p: + 'M20 14H7.00513C6.45001 14 6 13.55 6 12.9949V0M0 6h13.0667c.5154 0 .9333.41787.9333.93333V20M14.5.399902L13 1.9999l1.5 1.6M13 2h2c1.6569 0 3 1.34315 3 3v2M5.5 19.5999l1.5-1.6-1.5-1.6M7 18H5c-1.65685 0-3-1.3431-3-3v-2', + }, + sliders: { + w: 20, + p: + 'M8 10h11M1 10h4M1 4.5h11m3 0h4m-18 11h11m3 0h4M12 4.5a1.5 1.5 0 103 0 1.5 1.5 0 10-3 0M5 10a1.5 1.5 0 103 0 1.5 1.5 0 10-3 0M12 15.5a1.5 1.5 0 103 0 1.5 1.5 0 10-3 0', + }, + filters: { + w: 20, + p: + 'M4.5 6.5a5.5 5.5 0 1011 0 5.5 5.5 0 10-11 0m-3.5 6a5.5 5.5 0 1011 0 5.5 5.5 0 10-11 0m7 0a5.5 5.5 0 1011 0 5.5 5.5 0 10-11 0', + }, + abort: { + w: 10, + p: 'M.625.625l8.75 8.75m0-8.75l-8.75 8.75', + }, + exposure: { + w: 20, + p: + 'M10 20v-3M2.92946 2.92897l2.12132 2.12132M0 10h3m-.07054 7.071l2.12132-2.1213M10 0v3m7.0705 14.071l-2.1213-2.1213M20 10h-3m.0705-7.07103l-2.1213 2.12132M5 10a5 5 0 1010 0 5 5 0 10-10 0', + }, + contrast: { + w: 20, + p: 'M2 10a8 8 0 1016 0 8 8 0 10-16 0m8-8v16m8-8h-8m7.5977 2.5H10m6.24 2.5H10m7.6-7.5H10M16.2422 5H10', + }, + rotate: { + w: 20, + p: + 'M13.5.399902L12 1.9999l1.5 1.6M12.0234 2H14.4C16.3882 2 18 3.61178 18 5.6V8M4 17h9c.5523 0 1-.4477 1-1V7c0-.55228-.4477-1-1-1H4c-.55228 0-1 .44771-1 1v9c0 .5523.44771 1 1 1z', + }, + mirror: { + w: 20, + p: + 'M5.00042.399902l-1.5 1.599998 1.5 1.6M15.0004.399902l1.5 1.599998-1.5 1.6M3.51995 2H16.477M8.50042 16.7V6.04604c0-.30141-.39466-.41459-.5544-.159L1.28729 16.541c-.12488.1998.01877.459.2544.459h6.65873c.16568 0 .3-.1343.3-.3zm2.99998 0V6.04604c0-.30141.3947-.41459.5544-.159L18.7135 16.541c.1249.1998-.0187.459-.2544.459h-6.6587c-.1657 0-.3-.1343-.3-.3z', + }, + flip: { + w: 20, + p: + 'M19.6001 4.99993l-1.6-1.5-1.6 1.5m3.2 9.99997l-1.6 1.5-1.6-1.5M18 3.52337V16.4765M3.3 8.49993h10.654c.3014 0 .4146-.39466.159-.5544L3.459 1.2868C3.25919 1.16192 3 1.30557 3 1.5412v6.65873c0 .16568.13432.3.3.3zm0 2.99997h10.654c.3014 0 .4146.3947.159.5544L3.459 18.7131c-.19981.1248-.459-.0188-.459-.2544v-6.6588c0-.1657.13432-.3.3-.3z', + }, + brightness: { + w: 20, + p: + 'M15 10c0 2.7614-2.2386 5-5 5m5-5c0-2.76142-2.2386-5-5-5m5 5h-5m0 5c-2.76142 0-5-2.2386-5-5 0-2.76142 2.23858-5 5-5m0 10V5m0 15v-3M2.92946 2.92897l2.12132 2.12132M0 10h3m-.07054 7.071l2.12132-2.1213M10 0v3m7.0705 14.071l-2.1213-2.1213M20 10h-3m.0705-7.07103l-2.1213 2.12132M14.3242 7.5H10m4.3242 5H10', + }, + gamma: { + w: 20, + p: + 'M17 3C9 6 2.5 11.5 2.5 17.5m0 0h1m-1 0v-1m14 1h1m-3 0h1m-3 0h1m-3 0h1m-3 0h1m-3 0h1m-3 0h1m-3-14v-1m0 3v-1m0 3v-1m0 3v-1m0 3v-1m0 3v-1m0 3v-1', + }, + saturation: { + w: 20, + g: /*html*/ ` + + + + + + + + + + + + `, + }, + enhance: { + w: 20, + p: + 'M19 13h-2m0 0c-2.2091 0-4-1.7909-4-4m4 4c-2.2091 0-4 1.7909-4 4m0-8V7m0 2c0 2.2091-1.7909 4-4 4m-2 0h2m0 0c2.2091 0 4 1.7909 4 4m0 0v2M8 8.5H6.5m0 0c-1.10457 0-2-.89543-2-2m2 2c-1.10457 0-2 .89543-2 2m0-4V5m0 1.5c0 1.10457-.89543 2-2 2M1 8.5h1.5m0 0c1.10457 0 2 .89543 2 2m0 0V12M12 3h-1m0 0c-.5523 0-1-.44772-1-1m1 1c-.5523 0-1 .44772-1 1m0-2V1m0 1c0 .55228-.44772 1-1 1M8 3h1m0 0c.55228 0 1 .44772 1 1m0 0v1', + }, + slider: { + w: 20, + p: 'M0 10h11m0 0c0 1.1046.8954 2 2 2s2-.8954 2-2m-4 0c0-1.10457.8954-2 2-2s2 .89543 2 2m0 0h5', + }, + diagonal: { + w: 40, + p: 'M0 40L40-.00000133', + }, + exclamation: { + w: 20, + p: 'M10 0v14m1 4c0 .5523-.4477 1-1 1-.55228 0-1-.4477-1-1s.44772-1 1-1c.5523 0 1 .4477 1 1z', + }, + sad: { + w: 20, + p: + 'M2 17c4.41828-4 11.5817-4 16 0M16.5 5c0 .55228-.4477 1-1 1s-1-.44772-1-1 .4477-1 1-1 1 .44772 1 1zm-11 0c0 .55228-.44772 1-1 1s-1-.44772-1-1 .44772-1 1-1 1 .44772 1 1z', + }, + warmth: { + w: 20, + g: /*html*/ ` + + + + `, + }, + vibrance: { + w: 20, + g: /*html*/ ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + }, + dropbox: { + w: 30, + g: /*html*/ ` + + `, + }, + googleDrive: { + w: 30, + g: /*html*/ ` + + + + `, + }, + link: { + w: 30, + g: /*html*/ ` + + `, + }, + camera: { + w: 30, + g: /*html*/ ` + + + + `, + }, +}); diff --git a/editor/src/icons/ucIconHtml.js b/editor/src/icons/ucIconHtml.js new file mode 100644 index 000000000..f6e2a370b --- /dev/null +++ b/editor/src/icons/ucIconHtml.js @@ -0,0 +1,41 @@ +import { UC_ICONS } from './icon-set.js'; + +/** @typedef {10 | 20 | 30 | 40} IconSize */ + +/** + * @param {String} markup + * @param {IconSize} viewBoxWidth + * @param {IconSize} viewBoxHeight + * @param {IconSize} svgWidth + * @param {IconSize} svgHeight + */ +function iconHtml(markup, viewBoxWidth, viewBoxHeight, svgWidth, svgHeight) { + return /*html*/ `${markup}`; +} + +/** + * @param {string} path + * @returns {string} + */ +function singlePath(path) { + return /* html */ ` + + `; +} + +/** + * @param {keyof typeof UC_ICONS} name + * @param {IconSize} [width] + * @param {IconSize} [height] + */ +export function ucIconHtml(name, width, height) { + let { w, g, p } = UC_ICONS[name]; + let markup = p ? singlePath(p) : g; + + return iconHtml(markup, w, w, width || w, height || width || w); +} diff --git a/editor/src/index.js b/editor/src/index.js new file mode 100644 index 000000000..c2c367fdb --- /dev/null +++ b/editor/src/index.js @@ -0,0 +1 @@ +import {UploadcareEditor} from './UploadcareEditor.js' diff --git a/editor/src/l10n/L10n.js b/editor/src/l10n/L10n.js new file mode 100644 index 000000000..58bd9bdca --- /dev/null +++ b/editor/src/l10n/L10n.js @@ -0,0 +1,133 @@ +import { State } from '../../../symbiote/core/State.js'; +import { ASSETS_LOCALES_URL } from '../paths.js'; +import { LOCALES } from '../env.js'; + +const DEFAULT_PLURAL_STRINGS = { + 'file#': { + zero: 'file', + one: 'file', + two: 'files', + few: 'files', + many: 'files', + other: 'files', + }, +}; +const DEFAULT_LOCALE = 'en-EN'; +const CTX_NAME = 'l10n'; + +export class L10n { + /** @param {String} str */ + static _log(str, locName) { + if (locName && locName !== DEFAULT_LOCALE) { + console.warn(`Translation needed for: "${str}"`); + } + } + + /** + * @param {Object} locMap + * @param {String} str + * @param {Number} num + */ + static pluralize(locMap, str, num) { + let localeName = this.localeName || DEFAULT_LOCALE; + return (locMap || DEFAULT_PLURAL_STRINGS)[str][new Intl.PluralRules(localeName).select(num)] || str; + } + + /** + * @param {String} str + * @param {Number} [number] + */ + static t(str, number) { + let translation = str; + if (number !== undefined && str.includes('#')) { + let map = this.localeMap && this.localeMap[str] && this.localeMap; + translation = this.pluralize(map, str, number); + } else if (this.custom?.[str] || this.localeMap?.[str]) { + translation = this.custom?.[str] || this.localeMap?.[str]; + } else { + this._log(str, this.localeName); + } + return translation; + } + + static notify() { + window.dispatchEvent(new CustomEvent(CTX_NAME)); + } + + static applyTranslations(trMap) { + for (let lStr in trMap) { + this.state.add(lStr, trMap[lStr]); + } + this.notify(); + } + + static applyCustom(trMap) { + this.custom = { ...trMap }; + for (let str in this.custom) { + this.state.notify(str); + } + this.notify(); + } + + static async _loadLocale(localeName) { + let isLocaleSupported = LOCALES.includes(localeName); + let localeUrl; + + if (isLocaleSupported) { + localeUrl = `${ASSETS_LOCALES_URL}/${localeName}.json`; + } else { + // TODO: get user's url from config + let msg = `Locale "${localeName}" is not supported`; + let error = new Error(msg); + console.error(msg, { error, payload: { supportedLocales: LOCALES } }); + throw error; + } + + try { + return await (await fetch(localeUrl)).json(); + } catch (error) { + console.error(`Failed to load locale "${localeName}"`, { error, payload: { localeUrl } }); + throw error; + } + } + + /** @param {String} localeName */ + static async applyLocale(localeName) { + if (this._lastLocaleName === localeName) { + return; + } + if (!this.state) { + let localeMap = new Proxy(Object.create(null), { + set: (target, prop, val) => { + this.localeMap[prop] = val; + return true; + }, + get: (target, prop) => { + return this.custom?.[prop] || this.localeMap?.[prop] || prop; + }, + }); + State.registerNamedCtx(CTX_NAME, localeMap); + this.state = State.getNamedCtx(CTX_NAME); + } + this.localeName = localeName; + try { + if (localeName) { + if (localeName === DEFAULT_LOCALE) { + this.localeMap = {}; + } else { + this.localeMap = await this._loadLocale(localeName); + } + this.applyTranslations(this.localeMap); + } + } catch (err) { + this.applyLocale(DEFAULT_LOCALE); + } + this._lastLocaleName = localeName; + } + + static init() { + this.applyLocale(DEFAULT_LOCALE); + } +} + +L10n.init(); diff --git a/editor/src/l10n/README.md b/editor/src/l10n/README.md new file mode 100644 index 000000000..222e96d07 --- /dev/null +++ b/editor/src/l10n/README.md @@ -0,0 +1 @@ +### Localizations diff --git a/editor/src/l10n/locales/ru-RU.json b/editor/src/l10n/locales/ru-RU.json new file mode 100644 index 000000000..1145ce33b --- /dev/null +++ b/editor/src/l10n/locales/ru-RU.json @@ -0,0 +1,30 @@ +{ + "Choose files": "Выбрать файлы", + "Choose file": "Выбрать файл", + "Other source...": "Источник...", + "From URL": "Ссылка на файл", + "Camera": "Вебкамера", + "B": "Б", + "KB": "КБ", + "MB": "МБ", + "GB": "ГБ", + "TB": "ТБ", + "Remove...": "Удалить...", + "Add more": "Добавить к загрузкам", + "Cancel": "Отмена", + "Remove": "Удалить", + "Browse...": "Выбрать файлы...", + "Uploading": "Загружаем", + "Loading...": "Загрузка...", + "Upload error...": "Ошибка загрузки...", + "Edit image": "Редактировать", + "file#": { + "zero": "файлов", + "one": "файл", + "two": "файла", + "few": "файла", + "many": "файлов", + "other": "файлов" + }, + "Uploading failed": "Загрузка не удалась" +} diff --git a/editor/src/lib/FocusVisible.js b/editor/src/lib/FocusVisible.js new file mode 100644 index 000000000..4badd96a9 --- /dev/null +++ b/editor/src/lib/FocusVisible.js @@ -0,0 +1,33 @@ +import { applyFocusVisiblePolyfill } from './applyFocusVisiblePolyfill.js'; + +export class FocusVisible { + /** + * @param {boolean} focusVisible + * @param {HTMLElement} element + */ + static handleFocusVisible(focusVisible, element) { + if (focusVisible) { + let customOutline = element.style.getPropertyValue('--focus-visible-outline'); + element.style.outline = customOutline || '2px solid var(--color-focus-ring)'; + } else { + element.style.outline = 'none'; + } + } + + /** @param {ShadowRoot | Document} scope */ + static register(scope) { + FocusVisible._destructors.set(scope, applyFocusVisiblePolyfill(scope, FocusVisible.handleFocusVisible)); + } + + /** @param {Document | ShadowRoot} scope */ + static unregister(scope) { + if (!FocusVisible._destructors.has(scope)) { + return; + } + let removeFocusVisiblePolyfill = FocusVisible._destructors.get(scope); + removeFocusVisiblePolyfill(); + FocusVisible._destructors.delete(scope); + } +} + +FocusVisible._destructors = new WeakMap(); diff --git a/editor/src/lib/ResizeObserver.js b/editor/src/lib/ResizeObserver.js new file mode 100644 index 000000000..1f47671a1 --- /dev/null +++ b/editor/src/lib/ResizeObserver.js @@ -0,0 +1,48 @@ +export const ResizeObserver = + window.ResizeObserver || + class UcResizeObserver { + /** @param {Function} callback */ + constructor(callback) { + this._callback = callback; + } + + /** @param {Element} el */ + _flush(el) { + let rect = el.getBoundingClientRect(); + if (JSON.stringify(rect) !== JSON.stringify(this._lastRect)) { + this._callback([ + { + borderBoxSize: [ + { + inlineSize: rect.width, + blockSize: rect.height, + }, + ], + contentBoxSize: [ + { + inlineSize: rect.width, + blockSize: rect.height, + }, + ], + contentRect: rect, + target: el, + }, + ]); + } + this._lastRect = rect; + } + + /** @param {Element} el */ + observe(el) { + this.unobserve(); + this._observeInterval = window.setInterval(() => this._flush(el), 500); + this._flush(el); + } + + /** @param {Element} [el] */ + unobserve(el) { + if (this._observeInterval) { + window.clearInterval(this._observeInterval); + } + } + }; diff --git a/editor/src/lib/applyFocusVisiblePolyfill.js b/editor/src/lib/applyFocusVisiblePolyfill.js new file mode 100644 index 000000000..ccac1d6ee --- /dev/null +++ b/editor/src/lib/applyFocusVisiblePolyfill.js @@ -0,0 +1,255 @@ +/** + * Helper function for legacy browsers and iframes which sometimes focus elements like document, body, and non-interactive SVG. + * + * @param {EventTarget} el + */ +function isValidFocusTarget(el) { + if ( + el && + el !== document && + /** @type {Element} */ (el).nodeName !== 'HTML' && + /** @type {Element} */ (el).nodeName !== 'BODY' && + 'classList' in el && + 'contains' in /** @type {Element} */ (el).classList + ) { + return true; + } + return false; +} + +/** + * Computes whether the given element should automatically trigger the `focus-visible` class being added, i.e. whether + * it should always match `:focus-visible` when focused. + * + * @param {EventTarget} el + * @returns {boolean} + */ +function focusTriggersKeyboardModality(el) { + let { tagName } = /** @type {Element} */ (el); + + if (tagName === 'INPUT' && !(/** @type {HTMLInputElement} */ (el).readOnly)) { + return true; + } + + if (tagName === 'TEXTAREA' && !(/** @type {HTMLTextAreaElement} */ (el).readOnly)) { + return true; + } + + if (/** @type {HTMLElement} */ (el).isContentEditable) { + return true; + } + + return false; +} + +let hadKeyboardEvent = true; +let hadFocusVisibleRecently = false; + +/** + * Applies the :focus-visible polyfill at the given scope. A scope in this case is either the top-level Document or a Shadow Root. + * + * @param {Document | ShadowRoot} scope + * @param {(focusVisible: boolean, el: EventTarget) => void} [callback] + * @see https://github.com/WICG/focus-visible + */ +export function applyFocusVisiblePolyfill(scope, callback) { + let hadFocusVisibleRecentlyTimeout = null; + + /** + * Add the `focus-visible` class to the given element if it was not added by the author. + * + * @param {EventTarget} el + */ + function addFocusVisibleClass(el) { + /** @type {Element} */ (el).setAttribute('focus-visible', ''); + callback(true, el); + } + + /** + * Remove the `focus-visible` class from the given element if it was not originally added by the author. + * + * @param {EventTarget} el + */ + function removeFocusVisibleClass(el) { + if (!(/** @type {Element} */ (el).hasAttribute('focus-visible'))) { + return; + } + /** @type {Element} */ (el).removeAttribute('focus-visible'); + callback(false, el); + } + + /** + * If the most recent user interaction was via the keyboard; and the key press did not include a meta, alt/option, or + * control key; then the modality is keyboard. Otherwise, the modality is not keyboard. Apply `focus-visible` to any + * current active element and keep track of our keyboard modality state with `hadKeyboardEvent`. + * + * @param {KeyboardEvent} e + */ + function onKeyDown(e) { + if (e.metaKey || e.altKey || e.ctrlKey) { + return; + } + + if (isValidFocusTarget(scope.activeElement)) { + addFocusVisibleClass(scope.activeElement); + } + + hadKeyboardEvent = true; + } + + /** + * If at any point a user clicks with a pointing device, ensure that we change the modality away from keyboard. This + * avoids the situation where a user presses a key on an already focused element, and then clicks on a different + * element, focusing it with a pointing device, while we still think we're in keyboard modality. + * + * @param {Event} e + */ + function onPointerDown(e) { + hadKeyboardEvent = false; + } + + /** + * On `focus`, add the `focus-visible` class to the target if: - the target received focus as a result of keyboard + * navigation, or - the event target is an element that will likely require interaction via the keyboard (e.g. a text box) + * + * @param {Event} e + */ + function onFocus(e) { + // Prevent IE from focusing the document or HTML element. + if (!isValidFocusTarget(e.target)) { + return; + } + + if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) { + addFocusVisibleClass(e.target); + } + } + + /** + * On `blur`, remove the `focus-visible` class from the target. + * + * @param {Event} e + */ + function onBlur(e) { + if (!isValidFocusTarget(e.target)) { + return; + } + + if (/** @type {Element} */ (e.target).hasAttribute('focus-visible')) { + // To detect a tab/window switch, we look for a blur event followed + // rapidly by a visibility change. + // If we don't see a visibility change within 100ms, it's probably a + // regular focus change. + hadFocusVisibleRecently = true; + window.clearTimeout(hadFocusVisibleRecentlyTimeout); + hadFocusVisibleRecentlyTimeout = window.setTimeout(() => { + hadFocusVisibleRecently = false; + }, 100); + removeFocusVisibleClass(e.target); + } + } + + /** + * Add a group of listeners to detect usage of any pointing devices. These listeners will be added when the polyfill + * first loads, and anytime the window is blurred, so that they are active when the window regains focus. + */ + function addInitialPointerMoveListeners() { + /* eslint-disable no-use-before-define */ + document.addEventListener('mousemove', onInitialPointerMove); + document.addEventListener('mousedown', onInitialPointerMove); + document.addEventListener('mouseup', onInitialPointerMove); + document.addEventListener('pointermove', onInitialPointerMove); + document.addEventListener('pointerdown', onInitialPointerMove); + document.addEventListener('pointerup', onInitialPointerMove); + document.addEventListener('touchmove', onInitialPointerMove); + document.addEventListener('touchstart', onInitialPointerMove); + document.addEventListener('touchend', onInitialPointerMove); + /* eslint-enable no-use-before-define */ + } + + function removeInitialPointerMoveListeners() { + /* eslint-disable no-use-before-define */ + document.removeEventListener('mousemove', onInitialPointerMove); + document.removeEventListener('mousedown', onInitialPointerMove); + document.removeEventListener('mouseup', onInitialPointerMove); + document.removeEventListener('pointermove', onInitialPointerMove); + document.removeEventListener('pointerdown', onInitialPointerMove); + document.removeEventListener('pointerup', onInitialPointerMove); + document.removeEventListener('touchmove', onInitialPointerMove); + document.removeEventListener('touchstart', onInitialPointerMove); + document.removeEventListener('touchend', onInitialPointerMove); + /* eslint-enable no-use-before-define */ + } + + /** + * If the user changes tabs, keep track of whether or not the previously focused element had .focus-visible. + * + * @param {Event} e + */ + function onVisibilityChange(e) { + if (document.visibilityState === 'hidden') { + // If the tab becomes active again, the browser will handle calling focus + // on the element (Safari actually calls it twice). + // If this tab change caused a blur on an element with focus-visible, + // re-apply the class when the user switches back to the tab. + if (hadFocusVisibleRecently) { + hadKeyboardEvent = true; + } + addInitialPointerMoveListeners(); + } + } + + // TODO: remove this line + + /** + * When the polfyill first loads, assume the user is in keyboard modality. If any event is received from a pointing + * device (e.g. mouse, pointer, touch), turn off keyboard modality. This accounts for situations where focus enters + * the page from the URL bar. + * + * @param {Event} e + */ + function onInitialPointerMove(e) { + // Work around a Safari quirk that fires a mousemove on whenever the + // window blurs, even if you're tabbing out of the page. ¯\_(ツ)_/¯ + if ( + /** @type {Element} */ (e.target).nodeName && + /** @type {Element} */ (e.target).nodeName.toLowerCase() === 'html' + ) { + return; + } + + hadKeyboardEvent = false; + removeInitialPointerMoveListeners(); + } + + // For some kinds of state, we are interested in changes at the global scope + // only. For example, global pointer input, global key presses and global + // visibility change should affect the state at every scope: + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('mousedown', onPointerDown, true); + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('touchstart', onPointerDown, true); + document.addEventListener('visibilitychange', onVisibilityChange, true); + + addInitialPointerMoveListeners(); + + // For focus and blur, we specifically care about state changes in the local + // scope. This is because focus / blur events that originate from within a + // shadow root are not re-dispatched from the host element if it was already + // the active element in its own scope: + scope.addEventListener('focus', onFocus, true); + scope.addEventListener('blur', onBlur, true); + + return () => { + removeInitialPointerMoveListeners(); + + document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('mousedown', onPointerDown, true); + document.removeEventListener('pointerdown', onPointerDown, true); + document.removeEventListener('touchstart', onPointerDown, true); + document.removeEventListener('visibilitychange', onVisibilityChange, true); + + scope.removeEventListener('focus', onFocus, true); + scope.removeEventListener('blur', onBlur, true); + }; +} diff --git a/editor/src/lib/cdnUtils.js b/editor/src/lib/cdnUtils.js new file mode 100644 index 000000000..1c7de6e55 --- /dev/null +++ b/editor/src/lib/cdnUtils.js @@ -0,0 +1,99 @@ +export const OPERATIONS_ZEROS = { + brightness: 0, + exposure: 0, + gamma: 100, + contrast: 0, + saturation: 0, + vibrance: 0, + warmth: 0, + enhance: 0, + filter: 0, + rotate: 0, +}; + +/** + * @param {string} operation + * @param {number | string | object} options + * @returns {string} + */ +function operationToStr(operation, options) { + if (typeof options === 'number') { + return OPERATIONS_ZEROS[operation] !== options ? `${operation}/${options}` : ''; + } + + if (typeof options === 'boolean') { + return options && OPERATIONS_ZEROS[operation] !== options ? `${operation}` : ''; + } + + if (operation === 'filter') { + if (!options || OPERATIONS_ZEROS[operation] === options.amount) { + return ''; + } + let { name, amount } = options; + return `${operation}/${name}/${amount}`; + } + + if (operation === 'crop') { + if (!options) { + return ''; + } + let { dimensions, coords } = options; + return `${operation}/${dimensions.join('x')}/${coords.join(',')}`; + } + + return ''; +} + +/** + * @param {string[]} list + * @returns {string} + */ +export function joinCdnOperations(...list) { + return list.join('/-/').replace(/\/\//g, '/'); +} + +const ORDER = [ + 'enhance', + 'brightness', + 'exposure', + 'gamma', + 'contrast', + 'saturation', + 'vibrance', + 'warmth', + 'filter', + 'mirror', + 'flip', + 'rotate', + 'crop', +]; + +/** + * @param {import('../types/UploadEntry.js').Transformations} transformations + * @returns {string} + */ +export function transformationsToString(transformations) { + return joinCdnOperations( + ...ORDER.filter( + (operation) => typeof transformations[operation] !== 'undefined' && transformations[operation] !== null + ) + .map((operation) => { + let options = transformations[operation]; + return operationToStr(operation, options); + }) + .filter((str) => str && str.length > 0) + ); +} + +/** + * @param {string} originalUrl + * @param {string[]} list + * @returns {string} + */ +export function constructCdnUrl(originalUrl, ...list) { + return ( + originalUrl.replace(/\/$/g, '') + '/-/' + joinCdnOperations(...list.filter((str) => str && str.length > 0)) + '/' + ); +} + +export const COMMON_OPERATIONS = ['format/auto', 'progressive/yes'].join('/-/'); diff --git a/editor/src/lib/classNames.js b/editor/src/lib/classNames.js new file mode 100644 index 000000000..e15e19dc6 --- /dev/null +++ b/editor/src/lib/classNames.js @@ -0,0 +1,34 @@ +function normalize(...args) { + return args.reduce((result, arg) => { + if (typeof arg === 'string') { + result[arg] = true + return result + } + + for (let token of Object.keys(arg)) { + result[token] = arg[token] + } + + return result + }, {}) +} + +export function classNames(...args) { + let mapping = normalize(...args) + return Object.keys(mapping) + .reduce((result, token) => { + if (mapping[token]) { + result.push(token) + } + + return result + }, []) + .join(' ') +} + +export function applyClassNames(element, ...args) { + let mapping = normalize(...args) + for (let token of Object.keys(mapping)) { + element.classList.toggle(token, mapping[token]) + } +} diff --git a/editor/src/lib/debounce.js b/editor/src/lib/debounce.js new file mode 100644 index 000000000..ae3c9dcab --- /dev/null +++ b/editor/src/lib/debounce.js @@ -0,0 +1,16 @@ +/** + * @param {function} callback + * @param {number} wait + * @returns {function} + */ +export function debounce(callback, wait) { + let timer; + let debounced = (...args) => { + clearTimeout(timer); + timer = setTimeout(() => callback(...args), wait); + }; + debounced.cancel = () => { + clearTimeout(timer); + }; + return debounced; +} diff --git a/editor/src/lib/linspace.js b/editor/src/lib/linspace.js new file mode 100644 index 000000000..8ea5f2ab8 --- /dev/null +++ b/editor/src/lib/linspace.js @@ -0,0 +1,14 @@ +/** + * @param {number} a - Start of sample (int) + * @param {number} b - End of sample (int) + * @param {number} n - Number of elements (int) + * @returns {number[]} + */ +export function linspace(a, b, n) { + let ret = Array(n); + n--; + for (let i = n; i >= 0; i--) { + ret[i] = Math.ceil((i * b + (n - i) * a) / n); + } + return ret; +} diff --git a/editor/src/lib/pick.js b/editor/src/lib/pick.js new file mode 100644 index 000000000..37c921c85 --- /dev/null +++ b/editor/src/lib/pick.js @@ -0,0 +1,15 @@ +/** + * @param {{}} obj + * @param {string[]} keys + * @returns {{}} + */ +export function pick(obj, keys) { + let result = {}; + for (let key of keys) { + let value = obj[key]; + if (obj.hasOwnProperty(key) || value !== undefined) { + result[key] = value; + } + } + return result; +} diff --git a/editor/src/lib/preloadImage.js b/editor/src/lib/preloadImage.js new file mode 100644 index 000000000..debff8ba3 --- /dev/null +++ b/editor/src/lib/preloadImage.js @@ -0,0 +1,38 @@ +import { TRANSPARENT_PIXEL_SRC } from './transparentPixelSrc.js'; + +export function preloadImage(src) { + let image = new Image(); + + let promise = new Promise((resolve, reject) => { + image.src = src; + image.onload = resolve; + image.onerror = reject; + }); + + let cancel = () => { + if (image.naturalWidth === 0) { + image.src = TRANSPARENT_PIXEL_SRC; + } + }; + + return { promise, image, cancel }; +} + +export function batchPreloadImages(list) { + let preloaders = []; + + for (let src of list) { + let preload = preloadImage(src); + preloaders.push(preload); + } + + let images = preloaders.map((preload) => preload.image); + let promise = Promise.allSettled(preloaders.map((preload) => preload.promise)); + let cancel = () => { + preloaders.forEach((preload) => { + preload.cancel(); + }); + }; + + return { promise, images, cancel }; +} diff --git a/editor/src/lib/themeFilter.js b/editor/src/lib/themeFilter.js new file mode 100644 index 000000000..aab2a0861 --- /dev/null +++ b/editor/src/lib/themeFilter.js @@ -0,0 +1,26 @@ +const ALLOWED_PROPS = ['--rgb-primary-accent']; + +// TODO: maybe should be moved right into logger core +let warnOnce = (() => { + let cache = new Set(); + + return (...args) => { + let key = JSON.stringify(args); + if (!cache.has(key)) { + console.warn(.../** @type {[string, object]} */ (args)); + } + cache.add(key); + }; +})(); + +export function themeFilter(themeObj) { + let filtered = {}; + for (let prop in themeObj) { + if (ALLOWED_PROPS.includes(prop)) { + filtered[prop] = themeObj[prop]; + } else { + warnOnce(`It's not allowed to modify "${prop}" property`, { scope: 'theme' }); + } + } + return filtered; +} diff --git a/editor/src/lib/transparentPixelSrc.js b/editor/src/lib/transparentPixelSrc.js new file mode 100644 index 000000000..4fe504917 --- /dev/null +++ b/editor/src/lib/transparentPixelSrc.js @@ -0,0 +1,2 @@ +export const TRANSPARENT_PIXEL_SRC = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; diff --git a/editor/src/paths.js b/editor/src/paths.js new file mode 100644 index 000000000..13c62becc --- /dev/null +++ b/editor/src/paths.js @@ -0,0 +1,11 @@ +import { DEPLOY_ENV, VERSION } from './env.js'; + +const ASSETS_CDN_HOST = 'ucarecdn.com'; +const ASSETS_PROD_URL = `https://${ASSETS_CDN_HOST}/libs/editor/${VERSION}`; +const ASSETS_STAGING_ROOT_URL = 'src'; +const ASSETS_STYLES_PATH = 'css'; +const ASSETS_LOCALES_PATH = 'l10n/locales'; + +export const ASSETS_ROOT_URL = DEPLOY_ENV === 'production' ? ASSETS_PROD_URL : ASSETS_STAGING_ROOT_URL; +export const ASSETS_STYLES_URL = `${ASSETS_ROOT_URL}/${ASSETS_STYLES_PATH}`; +export const ASSETS_LOCALES_URL = `${ASSETS_ROOT_URL}/${ASSETS_LOCALES_PATH}`; diff --git a/editor/src/state.js b/editor/src/state.js new file mode 100644 index 000000000..29b94aaf2 --- /dev/null +++ b/editor/src/state.js @@ -0,0 +1,47 @@ +import { TRANSPARENT_PIXEL_SRC } from './lib/transparentPixelSrc.js' + +export function initState(fnCtx) { + return { + entry: null, + extension: null, + originalUrl: null, + transformations: null, + editorMode: false, + editorToolbarEl: null, + faderEl: null, + cropperEl: null, + imgEl: null, + imgContainerEl: null, + modalEl: fnCtx, + ctxProvider: fnCtx, + modalCaption: '', + isImage: false, + msg: '', + src: TRANSPARENT_PIXEL_SRC, + fileType: '', + widthBreakpoint: null, + showLoader: false, + networkProblems: false, + imageSize: null, + uuid: null, + 'public-key': null, + + 'presence.networkProblems': false, + 'presence.modalCaption': true, + 'presence.editorToolbar': false, + 'presence.viewerToolbar': true, + + 'on.retryNetwork': () => { + let images = fnCtx.shadowRoot.querySelectorAll('img') + for (let img of images) { + let originalSrc = img.src + img.src = TRANSPARENT_PIXEL_SRC + img.src = originalSrc + } + fnCtx.state.networkProblems = false + }, + 'on.close': () => { + fnCtx.remove() + }, + } +} diff --git a/editor/src/styles.js b/editor/src/styles.js new file mode 100644 index 000000000..9abb27dae --- /dev/null +++ b/editor/src/styles.js @@ -0,0 +1,230 @@ +import { TRANSPARENT_PIXEL_SRC } from './lib/transparentPixelSrc.js'; + +export const STYLES = { + ':host': { + display: 'block', + '--modal-header-opacity': '1', + '--modal-header-height': 'var(--size-panel-heading)', + '--modal-toolbar-height': 'var(--size-panel-heading)', + }, + editor_ON: { + '--modal-header-opacity': '0', + '--modal-header-height': '0px', + '--modal-toolbar-height': 'calc(var(--size-panel-heading) * 2)', + '--viewer-border-top-radius': 'var(--border-radius-base)', + }, + editor_OFF: { + '--modal-header-opacity': '1', + '--modal-header-height': 'var(--size-panel-heading)', + '--modal-toolbar-height': 'var(--size-panel-heading)', + '--viewer-border-top-radius': '0px', + }, + wrapper: { + height: '100%', + position: 'relative', + display: 'grid', + gridTemplateRows: 'minmax(var(--l-min-img-height), var(--l-max-img-height)) max-content', + borderBottomLeftRadius: 'var(--border-radius-base)', + borderBottomRightRadius: 'var(--border-radius-base)', + overflow: 'hidden', + transition: '.3s', + }, + _mobile: { + '--l-min-img-height': 'var(--modal-toolbar-height)', + '--l-max-img-height': '1fr', + '--l-edit-button-width': '70px', + '--l-toolbar-horizontal-padding': 'var(--gap-min)', + }, + _desktop: { + '--l-min-img-height': 'var(--modal-toolbar-height)', + '--l-max-img-height': '450px', + '--l-edit-button-width': '120px', + '--l-toolbar-horizontal-padding': 'var(--gap-mid-1)', + }, + viewport: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + image: { + '--viewer-image-opacity': 1, + position: 'absolute', + top: '0px', + left: '0px', + display: 'block', + width: '100%', + height: '100%', + objectFit: 'scale-down', + userSelect: 'none', + opacity: 'var(--viewer-image-opacity)', + transform: 'scale(1)', + boxSizing: 'border-box', + zIndex: '10', + pointerEvents: 'auto', + backgroundColor: 'var(--color-image-background)', + }, + image_visible_viewer: { + transition: 'opacity var(--transition-duration-3) ease-in-out, transform var(--transition-duration-4)', + }, + image_visible_from_cropper: { + transition: 'transform var(--transition-duration-4), opacity var(--transition-duration-3) steps(1, jump-end)', + }, + image_visible_from_editor: { + transition: 'opacity var(--transition-duration-3) ease-in-out, transform var(--transition-duration-4)', + }, + image_hidden_to_cropper: { + pointerEvents: 'none', + '--viewer-image-opacity': 0, + backgroundImage: `url("${TRANSPARENT_PIXEL_SRC}")`, + transform: 'scale(1)', + transition: 'transform var(--transition-duration-4), opacity var(--transition-duration-3) steps(1, jump-end)', + }, + image_hidden_viewer: { + pointerEvents: 'none', + '--viewer-image-opacity': 0, + transform: 'scale(0.95)', + transition: 'opacity var(--transition-duration-3) ease, transform var(--transition-duration-4)', + }, + image_hidden_effects: { + pointerEvents: 'none', + '--viewer-image-opacity': 0, + transform: 'scale(1)', + transition: 'opacity var(--transition-duration-3) cubic-bezier(.5,0,1,1), transform var(--transition-duration-4)', + }, + image_container: { + position: 'relative', + display: 'block', + width: '100%', + height: '100%', + backgroundColor: 'var(--color-image-background)', + borderTopLeftRadius: 'var(--viewer-border-top-radius)', + borderTopRightRadius: 'var(--viewer-border-top-radius)', + transition: 'var(--transition-duration-3)', + }, + toolbar: { + position: 'relative', + height: 'var(--modal-toolbar-height)', + transition: '.3s', + }, + tb_block: { + display: 'inline-flex', + gap: 'var(--gap-mid-1)', + }, + tb_block_align_right: { + justifyContent: 'flex-end', + }, + toolbar_content: { + position: 'absolute', + left: '0px', + bottom: '0px', + width: '100%', + height: '100%', + minHeight: 'var(--size-panel-heading)', + backgroundColor: 'var(--color-fill-contrast)', + boxSizing: 'border-box', + }, + toolbar_content__viewer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + paddingLeft: 'var(--l-toolbar-horizontal-padding)', + paddingRight: 'var(--l-toolbar-horizontal-padding)', + height: 'var(--size-panel-heading)', + }, + toolbar_content__editor: { + display: 'flex', + }, + list_btn: { + '--opacity-effect': 0.4, + color: 'var(--color-primary-accent)', + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + height: 'var(--size-touch-area)', + width: 'var(--size-touch-area)', + cursor: 'pointer', + opacity: 'var(--opacity-effect)', + transition: 'opacity var(--transition-duration-2)', + }, + list_btn__prew: { + transform: 'scaleX(-1)', + }, + info_pan: { + position: 'absolute', + userSelect: 'none', + }, + file_type_outer: { + position: 'absolute', + userSelect: 'none', + zIndex: 2, + display: 'flex', + transform: 'translateX(-40px)', + maxWidth: '120px', + }, + file_type: { + padding: '4px .8em', + }, + modal: { + '--size-modal-width': '840px', + }, + non_image: { + transition: 'var(--transition-duration-3)', + display: 'block', + width: '100%', + height: '100%', + }, + non_image_visible: { + transform: 'scale(1)', + opacity: '1', + }, + non_image_hidden: { + transform: 'scale(0.95)', + opacity: '0', + }, + network_problems_splash: { + backgroundColor: 'var(--color-fill-contrast)', + position: 'absolute', + width: '100%', + height: '100%', + zIndex: 4, + display: 'flex', + flexDirection: 'column', + }, + network_problems_content: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', + flex: '1', + }, + network_problems_icon: { + backgroundColor: 'rgba(var(--rgb-fill-shaded))', + width: '40px', + height: '40px', + borderRadius: '50%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + color: 'rgba(var(--rgb-text-base), 0.6)', + }, + network_problems_text: { + marginTop: 'var(--gap-max)', + fontSize: 'var(--font-size-ui)', + }, + network_problems_footer: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: 'var(--size-panel-heading)', + }, +}; + +export const COND = { + img_visible_viewer: 'image image_visible_viewer', + img_visible_editor: 'image image_visible_from_editor', + img_visible_cropper: 'image image_visible_from_cropper', + img_hidden_cropper: 'image image_hidden_to_cropper', + img_hidden_viewer: 'image image_hidden_viewer', + img_hidden_effects: 'image image_hidden_effects', +}; diff --git a/editor/src/template.js b/editor/src/template.js new file mode 100644 index 000000000..b803aa346 --- /dev/null +++ b/editor/src/template.js @@ -0,0 +1,51 @@ +import { toDslString } from '../../symbiote/core/render_utils.js'; +import { ucIconHtml } from './icons/ucIconHtml.js'; +import { UcBtnUi } from './elements/button/UcBtnUi.js'; +import { LineLoaderUi } from './elements/line-loader/LineLoaderUi.js'; +import { PresenceToggle } from './elements/presence-toggle/PresenceToggle.js'; +import { EditorImageCropper } from './EditorImageCropper.js'; +import { EditorImageFader } from './EditorImageFader.js'; +import { EditorToolbar } from './EditorToolbar.js'; + +export const TEMPLATE = /*html*/ ` +
+ <${PresenceToggle.is} class="network_problems_splash" set="visible: presence.networkProblems;"> +
+
+ ${ucIconHtml('sad')} +
+
+ Network error +
+
+ + +
+
+
+
+
+ + <${EditorImageCropper.is} ref="cropper-el" set="dataCtxProvider: editorToolbarEl"> + <${EditorImageFader.is} ref="fader-el" set="dataCtxProvider: editorToolbarEl"> +
+
+
+
+ <${LineLoaderUi.is} set="active: showLoader"> +
+ <${EditorToolbar.is} + ref="editor-toolbar-el" + set="${toDslString({ + dataCtxProvider: 'ctxProvider', + imgContainerEl: 'imgContainerEl', + faderEl: 'faderEl', + cropperEl: 'cropperEl', + })}"> +
+
+
+ +`; diff --git a/editor/src/toolbar-constants.js b/editor/src/toolbar-constants.js new file mode 100644 index 000000000..f9b277c65 --- /dev/null +++ b/editor/src/toolbar-constants.js @@ -0,0 +1,113 @@ +import { OPERATIONS_ZEROS } from './lib/cdnUtils.js'; + +export const TabId = { + CROP: 'crop', + SLIDERS: 'sliders', + FILTERS: 'filters', +}; +export const TABS = [TabId.CROP, TabId.SLIDERS, TabId.FILTERS]; + +export const ALL_COLOR_OPERATIONS = [ + 'brightness', + 'exposure', + 'gamma', + 'contrast', + 'saturation', + 'vibrance', + 'warmth', + 'enhance', +]; + +export const ALL_FILTERS = [ + 'adaris', + 'briaril', + 'calarel', + 'carris', + 'cynarel', + 'cyren', + 'elmet', + 'elonni', + 'enzana', + 'erydark', + 'fenralan', + 'ferand', + 'galen', + 'gavin', + 'gethriel', + 'iorill', + 'iothari', + 'iselva', + 'jadis', + 'lavra', + 'misiara', + 'namala', + 'nerion', + 'nethari', + 'pamaya', + 'sarnar', + 'sedis', + 'sewen', + 'sorahel', + 'sorlen', + 'tarian', + 'thellassan', + 'varriel', + 'varven', + 'vevera', + 'virkas', + 'yedis', + 'yllara', + 'zatvel', + 'zevcen', +]; + +export const ALL_CROP_OPERATIONS = ['rotate', 'mirror', 'flip']; + +/** KeypointsNumber is the number of keypoints loaded from each side of zero, not total number */ +export const COLOR_OPERATIONS_CONFIG = { + brightness: { + zero: OPERATIONS_ZEROS.brightness, + range: [-100, 100], + keypointsNumber: 2, + }, + exposure: { + zero: OPERATIONS_ZEROS.exposure, + range: [-500, 500], + keypointsNumber: 2, + }, + gamma: { + zero: OPERATIONS_ZEROS.gamma, + range: [0, 1000], + keypointsNumber: 2, + }, + contrast: { + zero: OPERATIONS_ZEROS.contrast, + range: [-100, 500], + keypointsNumber: 2, + }, + saturation: { + zero: OPERATIONS_ZEROS.saturation, + range: [-100, 500], + keypointsNumber: 1, + }, + vibrance: { + zero: OPERATIONS_ZEROS.vibrance, + range: [-100, 500], + keypointsNumber: 1, + }, + warmth: { + zero: OPERATIONS_ZEROS.warmth, + range: [-100, 100], + keypointsNumber: 1, + }, + enhance: { + zero: OPERATIONS_ZEROS.enhance, + range: [0, 100], + keypointsNumber: 1, + }, + filter: { + zero: OPERATIONS_ZEROS.filter, + range: [0, 100], + keypointsNumber: 1, + }, +}; diff --git a/editor/src/toolbar-styles.js b/editor/src/toolbar-styles.js new file mode 100644 index 000000000..57caa37f3 --- /dev/null +++ b/editor/src/toolbar-styles.js @@ -0,0 +1,161 @@ +export const STYLES = { + ':host': { + position: 'relative', + height: '100%', + width: '100%', + }, + ':host--mobile': { + '--l-tab-gap': 'var(--gap-mid-1)', + '--l-slider-padding': 'var(--gap-min)', + '--l-controls-padding': 'var(--gap-min)', + }, + ':host--desktop': { + '--l-tab-gap': 'calc(var(--gap-mid-1) + var(--gap-max))', + '--l-slider-padding': 'var(--gap-mid-1)', + '--l-controls-padding': 'var(--gap-mid-1)', + }, + 'toolbar-container': { + position: 'relative', + height: '100%', + width: '100%', + overflow: 'hidden', + }, + 'sub-toolbar': { + position: 'absolute', + width: '100%', + height: '100%', + display: 'grid', + gridTemplateRows: '1fr 1fr', + transition: + 'opacity var(--transition-duration-3) ease-in-out, transform var(--transition-duration-3) ease-in-out, visibility var(--transition-duration-3) ease-in-out', + backgroundColor: 'var(--color-fill-contrast)', + }, + 'sub-toolbar--visible': { + opacity: '1', + pointerEvents: 'auto', + transform: 'translateY(0px)', + }, + 'sub-toolbar--top-hidden': { + opacity: '0', + pointerEvents: 'none', + transform: 'translateY(100%)', + }, + 'sub-toolbar--bottom-hidden': { + opacity: '0', + pointerEvents: 'none', + transform: 'translateY(-100%)', + }, + 'controls-row': { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + paddingLeft: 'var(--l-controls-padding)', + paddingRight: 'var(--l-controls-padding)', + }, + 'tab-toggles': { + position: 'relative', + display: 'grid', + gridAutoFlow: 'column', + gridGap: '0px var(--l-tab-gap)', + height: '100%', + alignItems: 'center', + }, + 'tab-toggles_indicator': { + position: 'absolute', + bottom: '0px', + left: '0px', + width: 'var(--size-touch-area)', + height: '2px', + backgroundColor: 'var(--color-primary-accent)', + transform: 'translateX(0px)', + transition: 'transform var(--transition-duration-3)', + }, + 'tab-content-row': { + position: 'relative', + }, + 'tab-content': { + position: 'absolute', + left: '0px', + top: '0px', + width: '100%', + height: '100%', + opacity: 0, + contentVisibility: 'auto', + display: 'flex', + overflow: 'hidden', + }, + 'tab-content--visible': { + opacity: 1, + pointerEvents: 'auto', + }, + 'tab-content--hidden': { + opacity: 0, + pointerEvents: 'none', + }, + 'controls-list_align': { + display: 'grid', + gridTemplateAreas: '". inner ."', + gridTemplateColumns: '1fr auto 1fr', + paddingLeft: 'var(--gap-max)', + boxSizing: 'border-box', + minWidth: '100%', + }, + 'controls-list_inner': { + gridArea: 'inner', + display: 'grid', + gridAutoFlow: 'column', + gridGap: 'calc((var(--gap-min) - 1px) * 3)', + }, + 'controls-list_last-item': { + marginRight: 'var(--gap-max)', + }, + 'filter-tooltip_container': { + position: 'absolute', + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + }, + 'filter-tooltip_wrapper': { + position: 'absolute', + top: 'calc(-100% - var(--gap-mid-2))', + height: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + pointerEvents: 'none', + }, + 'filter-tooltip': { + backgroundColor: 'var(--color-text-accent-contrast)', + color: 'var(--color-text-base)', + borderRadius: 'var(--border-radius-editor)', + textTransform: 'uppercase', + paddingLeft: 'var(--gap-min)', + paddingRight: 'var(--gap-min)', + paddingTop: 'calc(var(--gap-min) / 2)', + paddingBottom: 'calc(var(--gap-min) / 2)', + fontSize: '0.7em', + transition: 'var(--transition-duration-3)', + opacity: 0, + zIndex: 3, + letterSpacing: '1px', + }, + 'filter-tooltip_visible': { + opacity: '1', + transform: 'translateY(0px)', + }, + 'filter-tooltip_hidden': { + opacity: '0', + transform: 'translateY(100%)', + }, + slider: { + paddingLeft: 'var(--l-slider-padding)', + paddingRight: 'var(--l-slider-padding)', + }, +}; + +export const COND = { + operation_tooltip_visible: 'filter-tooltip filter-tooltip_visible', + operation_tooltip_hidden: 'filter-tooltip filter-tooltip_hidden', +}; diff --git a/editor/src/util.js b/editor/src/util.js new file mode 100644 index 000000000..4b2c1197e --- /dev/null +++ b/editor/src/util.js @@ -0,0 +1,16 @@ +import { transformationsToString, constructCdnUrl, COMMON_OPERATIONS } from './lib/cdnUtils.js'; + +export function viewerImageSrc(originalUrl, width, transformations) { + const MAX_CDN_DIMENSION = 3000; + let dpr = window.devicePixelRatio; + let size = Math.min(Math.ceil(width * dpr), MAX_CDN_DIMENSION); + let quality = dpr >= 2 ? 'lightest' : 'normal'; + + return constructCdnUrl( + originalUrl, + COMMON_OPERATIONS, + transformationsToString(transformations), + `quality/${quality}`, + `stretch/off/-/resize/${size}x` + ); +} diff --git a/editor/src/viewer_util.js b/editor/src/viewer_util.js new file mode 100644 index 000000000..4b2c1197e --- /dev/null +++ b/editor/src/viewer_util.js @@ -0,0 +1,16 @@ +import { transformationsToString, constructCdnUrl, COMMON_OPERATIONS } from './lib/cdnUtils.js'; + +export function viewerImageSrc(originalUrl, width, transformations) { + const MAX_CDN_DIMENSION = 3000; + let dpr = window.devicePixelRatio; + let size = Math.min(Math.ceil(width * dpr), MAX_CDN_DIMENSION); + let quality = dpr >= 2 ? 'lightest' : 'normal'; + + return constructCdnUrl( + originalUrl, + COMMON_OPERATIONS, + transformationsToString(transformations), + `quality/${quality}`, + `stretch/off/-/resize/${size}x` + ); +} diff --git a/editor/test.html b/editor/test.html new file mode 100644 index 000000000..faeda1845 --- /dev/null +++ b/editor/test.html @@ -0,0 +1,19 @@ + + + + + + Editor + + + + + + + diff --git a/editor/test.js b/editor/test.js new file mode 100644 index 000000000..507729c5b --- /dev/null +++ b/editor/test.js @@ -0,0 +1,3 @@ +import { UploadcareEditor } from './src/UploadcareEditor.js'; + +window.customElements.define('uc-editor', UploadcareEditor);