const base = require("../base");
require("./visual");
const ripe = base.ripe;
/**
* Binds an PRC Configurator to this Ripe instance.
*
* @param {Configurator} element The PRC Configurator to be used by the Ripe instance.
* @param {Object} options An Object with options to configure the Configurator instance.
* @returns {Configurator} The Configurator instance created.
*/
ripe.Ripe.prototype.bindConfiguratorPrc = function(element, options = {}) {
const config = new ripe.ConfiguratorPrc(this, element, options);
return this.bindInteractable(config);
};
/**
* @class
* @classdesc Class that defines an interactive Configurator instance to be
* used in connection with the main Ripe owner to provide an
* interactive configuration experience inside a DOM.
*
* @param {Object} owner The owner (customizer instance) for
* this configurator.
* @param {Object} element The DOM element that is considered to
* be the target for the configurator, it's going to have its own
* inner HTML changed.
* @param {Object} options The options to be used to configure the
* configurator instance to be created.
*/
ripe.ConfiguratorPrc = function(owner, element, options) {
this.type = this.type || "ConfiguratorPrc";
ripe.Visual.call(this, owner, element, options);
};
ripe.ConfiguratorPrc.prototype = ripe.build(ripe.Visual.prototype);
ripe.ConfiguratorPrc.prototype.constructor = ripe.ConfiguratorPrc;
/**
* The Configurator initializer, which is called whenever
* the Configurator is going to become active.
*
* Sets the various values for the Configurator taking into
* owner's default values.
*/
ripe.ConfiguratorPrc.prototype.init = function() {
ripe.Visual.prototype.init.call(this);
this.id = this.options.id || 0;
this.width = this.options.width || null;
this.height = this.options.height || null;
this.format = this.options.format || null;
this.size = this.options.size || null;
this.mutations = this.options.mutations || false;
this.maxSize = this.options.maxSize || 1000;
this.pixelRatio =
this.options.pixelRatio || (typeof window !== "undefined" && window.devicePixelRatio) || 2;
this.sensitivity = this.options.sensitivity || 40;
this.verticalThreshold = this.options.verticalThreshold || 15;
this.clickThreshold = this.options.clickThreshold || 0.015;
this.duration = this.options.duration || 500;
this.preloadDelay = this.options.preloadDelay || 150;
this.maskOpacity = this.options.maskOpacity || 0.4;
this.maskDuration = this.options.maskDuration || 150;
this.noMasks = this.options.noMasks === undefined ? undefined : this.options.noMasks;
this.useMasks =
this.options.useMasks === undefined
? this.noMasks === undefined
? undefined
: !this.noMasks
: this.options.useMasks;
this.useDefaultSize =
this.options.useDefaultSize === undefined ? true : this.options.useDefaultSize;
this.view = this.options.view || "side";
this.position = this.options.position || 0;
this.configAnimate =
this.options.configAnimate === undefined ? "cross" : this.options.configAnimate;
this.viewAnimate = this.options.viewAnimate === undefined ? "cross" : this.options.viewAnimate;
this.ready = false;
this._finalize = null;
this._observer = null;
this._ownerBinds = {};
this._pending = [];
this.frameSize = null;
// registers for the selected part event on the owner
// so that we can highlight the associated part
this._ownerBinds.selected_part = this.owner.bind("selected_part", part => this.highlight(part));
// registers for the deselected part event on the owner
// so that we can remove the highlight of the associated part
this._ownerBinds.deselected_part = this.owner.bind("deselected_part", part => this.lowlight());
// creates a structure the store the last presented
// position of each view, to be used when returning
// to a view for better user experience
this._lastFrame = {};
// creates the necessary DOM elements and runs
// the initial layout update operation if the
// owner has a model and brand set (is ready)
this._initLayout();
if (this.owner.brand && this.owner.model) {
this._updateConfig();
}
// registers for the pre config to be able to set the configurator
// into a not ready state (update operations blocked)
this._ownerBinds.pre_config = this.owner.bind("pre_config", () => {
this.ready = false;
});
// registers for the post config change request event to
// be able to properly update the internal structures
this._ownerBinds.post_config = this.owner.bind("post_config", config => {
if (config) this._updateConfig();
});
};
/**
* The Configurator deinitializer, to be called (by the owner) when
* it should stop responding to updates so that any necessary
* cleanup operations can be executed.
*/
ripe.ConfiguratorPrc.prototype.deinit = async function() {
await this.cancel();
while (this.element.firstChild) {
this.element.removeChild(this.element.firstChild);
}
for (const bind in this._ownerBinds) {
this.owner.unbind(bind, this._ownerBinds[bind]);
}
this._removeElementHandlers();
if (this._observer) this._observer.disconnect();
this._finalize = null;
this._observer = null;
ripe.Visual.prototype.deinit.call(this);
};
/**
* Updates configurator current options with the ones provided.
*
* @param {Object} options Set of optional parameters to adjust the Configurator, such as:
* - 'sensitivity' - Rotation sensitivity to the user mouse drag action.
* - 'duration' - The duration in milliseconds that the transition should take.
* - 'useMasks' - Usage of masks in the current model, necessary for the part highlighting action.
* - 'configAnimate' - The configurator animation style: 'simple' (fade in), 'cross' (crossfade) or 'null'.
* @param {Boolean} update If an update operation should be executed after
* the options updated operation has been performed.
*/
ripe.ConfiguratorPrc.prototype.updateOptions = async function(options, update = true) {
ripe.Visual.prototype.updateOptions.call(this, options);
this.width = options.width === undefined ? this.width : options.width;
this.height = options.height === undefined ? this.height : options.height;
this.format = options.format === undefined ? this.format : options.format;
this.size = options.size === undefined ? this.size : options.size;
this.mutations = options.mutations === undefined ? this.mutations : options.mutations;
this.maxSize = options.maxSize === undefined ? this.maxSize : this.maxSize;
this.pixelRatio = options.pixelRatio === undefined ? this.pixelRatio : options.pixelRatio;
this.sensitivity = options.sensitivity === undefined ? this.sensitivity : options.sensitivity;
this.verticalThreshold =
options.verticalThreshold === undefined
? this.verticalThreshold
: options.verticalThreshold;
this.clickThreshold =
options.clickThreshold === undefined ? this.clickThreshold : options.clickThreshold;
this.duration = options.duration === undefined ? this.duration : options.duration;
this.preloadDelay =
options.preloadDelay === undefined ? this.preloadDelay : options.preloadDelay;
this.maskOpacity = options.maskOpacity === undefined ? this.maskOpacity : options.maskOpacity;
this.maskDuration =
options.maskDuration === undefined ? this.maskDuration : options.maskDuration;
this.noMasks = options.noMasks === undefined ? this.noMasks : options.noMasks;
this.useMasks =
this.options.useMasks === undefined
? this.noMasks === undefined
? this.noMasks
: !this.noMasks
: this.options.useMasks;
this.useDefaultSize =
options.useDefaultSize === undefined ? this.useDefaultSize : options.useDefaultSize;
this.configAnimate =
options.configAnimate === undefined ? this.configAnimate : options.configAnimate;
this.viewAnimate = options.viewAnimate === undefined ? this.viewAnimate : options.viewAnimate;
if (update) await this.update();
};
/**
* This function is called (by the owner) whenever its state changes
* so that the Configurator can update itself for the new state.
*
* This method is "protected" by unique signature validation in order
* to avoid extra render and frame loading operations. Operations are
* available to force the update operation even if the signature is the
* same as the one previously set.
*
* @param {Object} state An object containing the new state of the owner.
* @param {Object} options Set of optional parameters to adjust the Configurator update, such as:
* - 'animate' - If it's to animate the update (defaults to 'false').
* - 'duration' - The duration in milliseconds that the transition should take.
* - 'callback' - The callback to be called at the end of the update.
* - 'preload' - If it's to execute the pre-loading process.
* - 'force' - If the updating operation should be forced (ignores signature).
* @returns {Boolean} If an effective operation has been performed by the
* update operation.
*/
ripe.ConfiguratorPrc.prototype.update = async function(state, options = {}) {
// in case the configurator is currently nor ready for an
// update none is performed and the control flow is returned
// with the false value (indicating a no-op, nothing was done)
if (this.ready === false) {
this.trigger("not_loaded");
return false;
}
const _update = async () => {
// allocates space for the possible promise that is going
// to be responsible for the preloading of the frames, populating
// the cache buffer for the complete set of frames associated with
// the currently loaded model configuration
let preloadPromise = null;
const view = this.element.dataset.view;
const position = this.element.dataset.position;
const force = options.force || false;
const duration = options.duration;
const preload = options.preload;
// checks if the parts drawn on the target have
// changed and animates the transition if they did
let previous = this.signature || "";
const signature = this._buildSignature();
const changed = signature !== previous;
let animate = options.animate;
if (animate === undefined) {
// if its the first update after a config change
// uses the config animate, else it uses a simple
// animation if there were changes in the parts
if (previous) animate = changed ? "simple" : false;
else animate = this.configAnimate === undefined ? "simple" : this.configAnimate;
}
this.signature = signature;
// if the parts and the position haven't changed
// since the last frame load then ignores the
// load request and returns immediately
previous = this.unique;
const unique = `${signature}&view=${String(view)}&position=${String(position)}`;
if (previous === unique && !force) {
this.trigger("not_loaded");
return false;
}
this.unique = unique;
// removes the highlight support from the matched object as a new
// frame is going to be "calculated" and rendered (not same mask)
this.lowlight();
// runs the pre-loading process so that the remaining frames are
// loaded for a smother experience when dragging the element,
// note that this is only performed in case this is not a single
// based update (not just the loading of the current position)
// and the current signature has changed
const preloaded = this.element.classList.contains("preload");
const mustPreload = preload !== undefined ? preload : changed || !preloaded;
if (mustPreload) preloadPromise = this._preload(this.options.useChain);
// runs the load operation for the current frame, taking into
// account the multiple requirements for such execution
await this._loadFrame(view, position, {
draw: true,
animate: animate,
duration: duration
});
// initializes the result value with the default valid value
// indicating that the operation was a success
let result = true;
// in case the preload was requested then waits for the preload
// operation of the frames to complete (wait on promise), keep
// in mind that if the preload operation was requested this is
// a "hard" flush on the underlying images buffer (most of the times
// representing a change in the configuration)
if (preloadPromise) {
// waits for the preload promise as the result of it is
// going to be considered the result of the operation
result = await preloadPromise;
}
// verifies if the operation has been successful, it's considered
// successful in case there's a result and the result is not marked
// with the canceled flag (result of a cancel operation)
const success = result && !result.canceled;
if (success) {
// updates the signature of the loaded set of frames, so that for
// instance the cancel operation can be properly controlled avoiding
// the usage of extra resources and duplicated (cancel operations)
this.signatureLoaded = signature;
// after the update operation is finished the loaded event
// should be triggered indicating the end of the visual
// operations for the current configuration on the configurator
this.trigger("loaded");
} else {
// unsets both the signature and the unique value because the update
// operation has been marked as not successful so the visuals have
// not been properly updated
this.signature = null;
this.unique = null;
}
// returns the resulting value indicating if the loading operation
// as been triggered with success (effective operation)
return result;
};
// cancels any pending operation in the configurator and waits
// for the update promise to be finished (in case an update is
// currently running)
await this.cancel();
if (this._updatePromise) await this._updatePromise;
let result = null;
this._updatePromise = _update();
try {
result = await this._updatePromise;
} finally {
this._updatePromise = null;
}
// flushes the complete set of operations that were waiting
// for the end of the pre-loading operation, notice that this
// operation is only performed in case the result of the operation
// execution is positive (finishes in success, avoiding possible
// dead-lock "like" collisions)
if (result && !result.canceled) await this.flushPending(true);
// returns the final result of the underlying update execution
// to the caller method (may contain the canceled field)
return result;
};
/**
* This function is called (by the owner) whenever the current operation
* in the child should be canceled this way a Configurator is not updated.
*
* @param {Object} options Set of optional parameters to adjust the Configurator.
* @returns {Boolean} If an effective operation has been performed or if
* instead no cancel logic was executed.
*/
ripe.ConfiguratorPrc.prototype.cancel = async function(options = {}) {
if (this._buildSignature() === this.signatureLoaded || "") return false;
if (this._finalize) this._finalize({ canceled: true });
return true;
};
/**
* Resizes the configurator's DOM element to 'size' pixels.
* This action is performed by setting both the attributes from
* the HTML elements and the style.
*
* @param {Number} size The number of pixels to resize to.
*/
ripe.ConfiguratorPrc.prototype.resize = async function(size, width, height) {
if (this.element === undefined) {
return;
}
// tries to obtain the best possible size for the configurator
// defaulting to the client with of the element as fallback
size = size || this.size || this.element.clientWidth;
width = width || this.element.dataset.width || this.width || size;
height = height || this.element.dataset.height || this.height || size;
// in case the current size of the configurator ignores the
// request to avoid usage of unneeded resources
if (this.currentSize === size && this.currentWidth === width && this.currentHeight === height) {
return;
}
const area = this.element.querySelector(".area");
const frontMask = this.element.querySelector(".front-mask");
const back = this.element.querySelector(".back");
const mask = this.element.querySelector(".mask");
area.width = width * this.pixelRatio;
area.height = height * this.pixelRatio;
area.style.width = width + "px";
area.style.height = height + "px";
frontMask.width = width;
frontMask.height = height;
frontMask.style.width = width + "px";
frontMask.style.height = height + "px";
frontMask.style.marginLeft = `-${String(width)}px`;
back.width = width * this.pixelRatio;
back.height = height * this.pixelRatio;
back.style.width = width + "px";
back.style.height = height + "px";
back.style.marginLeft = `-${String(width)}px`;
mask.width = width;
mask.height = height;
mask.style.width = width + "px";
mask.style.height = height + "px";
this.currentSize = size;
this.currentWidth = width;
this.currentHeight = height;
await this.update(
{},
{
force: true
}
);
};
/**
* Executes pending operations that were not performed so as
* to not conflict with the tasks already being executed.
*
* The main reason for collision is the pre-loading operation
* being executed (long duration operation).
*
* @param {Boolean} tail If only the last pending operation should
* be flushed, meaning that the others are discarded.
*/
ripe.ConfiguratorPrc.prototype.flushPending = async function(tail = false) {
const pending =
tail && this._pending.length > 0
? [this._pending[this._pending.length - 1]]
: this._pending;
this._pending = [];
while (pending.length > 0) {
const { operation, args } = pending.shift();
switch (operation) {
case "changeFrame":
await this.changeFrame(...args);
break;
default:
break;
}
}
};
/**
* Displays a new frame, with an animation from the starting frame
* proper animation should be performed.
*
* This function is meant to be executed using a recursive approach
* and each run represents a "tick" of the animation operation.
*
* @param {Object} frame The new frame to display using the extended and canonical
* format for the frame description (eg: side-3).
* @param {Object} options Set of optional parameters to adjust the change frame, such as:
* - 'type' - The animation style: 'simple' (fade in), 'cross' (crossfade) or 'null'
* (without any style).
* - 'duration' - The duration of the animation in milliseconds (defaults to 'null').
* - 'stepDuration' - If defined the total duration of the animation is
* calculated using the amount of steps times the number of steps, instead of
* using the 'duration' field (defaults to 'null').
* - 'revolutionDuration' - If defined the step duration is calculated by dividing
* the revolution duration by the number of frames in the view (defaults to 'null').
* - 'preventDrag' - If drag actions during an animated change of frames should be
* ignored (defaults to 'true').
* - 'safe' - If requested then the operation is only performed in case the configurator
* is not in the an equivalent state (default to 'true').
*/
ripe.ConfiguratorPrc.prototype.changeFrame = async function(frame, options = {}) {
// parses the requested frame value according to the pre-defined
// standard (eg: side-3) and then unpacks it as view and position
const _frame = ripe.parseFrameKey(frame);
const nextView = _frame[0];
const nextPosition = parseInt(_frame[1]);
// in case the next position value was not properly parsed (probably undefined)
// then it's not possible to change frame (throws exception)
if (isNaN(nextPosition)) {
throw new RangeError("Frame position is not defined");
}
// unpacks the other options to the frame change defaulting their values
// in case undefined values are found
let duration = options.duration === undefined ? null : options.duration;
let stepDurationRef = options.stepDuration === this.stepDuration ? null : options.stepDuration;
const revolutionDuration =
options.revolutionDuration === undefined
? this.revolutionDuration
: options.revolutionDuration;
const type = options.type === undefined ? null : options.type;
let preventDrag = options.preventDrag === undefined ? true : options.preventDrag;
const safe = options.safe === undefined ? true : options.safe;
const first = options.first === undefined ? true : options.first;
// updates the animation start timestamp with the current timestamp in
// case no start time is currently defined
options._start = options._start === undefined ? new Date().getTime() : options._start;
options._step = options._step === undefined ? 0 : options._step;
// normalizes both the (current) view and position values
const view = this.element.dataset.view;
const position = parseInt(this.element.dataset.position);
// tries to retrieve the amount of frames for the target view and
// validates that the target view exists and that the target position
// (frame) does not overflow the amount of frames in for the view
const viewFrames = this.frames[nextView];
if (!viewFrames || nextPosition >= viewFrames) {
throw new RangeError("Frame " + frame + " is not supported");
}
// in case the safe mode is enabled and the current configuration is
// still under the preloading situation the change frame is saved and
// will be executed after the preloading
if (safe && this.element.classList.contains("preloading")) {
this._pending = [{ operation: "changeFrame", args: [frame, options] }];
return;
}
// in case the safe mode is enabled and there's an animation running
// then this request is going to be ignored (not possible to change
// frame when another animation is running)
if (safe && this.element.classList.contains("animating")) {
return;
}
// in case the current view and position are already set then returns
// the control flow immediately (animation safeguard)
if (safe && this.element.dataset.view === nextView && position === nextPosition) {
this.element.classList.remove("no-drag", "animating");
return;
}
// removes any part highlight in case it is set
// to replicate the behaviour of dragging the product
this.lowlight();
// saves the position of the current view
// so that it returns to the same position
// when coming back to the same view
this._lastFrame[view] = position;
this.position = nextPosition;
this.element.dataset.position = nextPosition;
// if there is a new view and the product supports
// it then animates the transition with a crossfade
// and ignores all drag movements while it lasts
let animate = false;
if (view !== nextView && viewFrames !== undefined) {
this.view = nextView;
this.element.dataset.view = nextView;
this.element.dataset.frames = viewFrames;
animate = type === null ? this.viewAnimate : type;
duration = duration || this.duration;
}
// runs the defaulting values for the current step duration
// and the next position that is going to be rendered
let stepDuration = null;
let stepPosition = nextPosition;
// sets the initial time reduction to be applied for the frame
// based animation (rotation), this value should be calculated
// taking into account the delay in the overall animation
let reducedTime = 0;
// in case any kind of duration was provided a timed animation
// should be performed and as such a proper calculus should be
// performed to determine the current step duration an the position
// associated with the current step operation
if (view === nextView && (duration || stepDurationRef || revolutionDuration)) {
// ensures that no animation on a pre-frame render exists
// the animation itself is going to be "managed" by the
// the change frame tick logic
animate = null;
// calculates the number of steps as the shortest path
// between the current and the next position, this should
// choose the proper way for the "rotation"
const stepCount =
view !== nextView
? 1
: Math.min(
Math.abs(position - nextPosition),
viewFrames - Math.abs(position - nextPosition)
);
options._stepCount = options._stepCount === undefined ? stepCount : options._stepCount;
// in case the (total) revolution time for the view is defined a
// step timing based animation is calculated based on the total
// number of frames for the view
if (revolutionDuration && first) {
// makes sure that we're able to find out the number of frames
// for the next view from the current loaded model, only then
// can the step duration be calculated, by dividing the total
// duration of the revolution by the number of frames of the view
if (viewFrames) {
stepDurationRef = parseInt(revolutionDuration / viewFrames);
}
// otherwise runs a fallback operation where the total duration
// of the animation is the revolution time (sub optimal fallback)
else {
duration = duration || revolutionDuration;
}
}
// in case the options contain the step duration (reference) field
// then it's used to calculate the total duration of the animation
// (step driven animation timing)
if (stepDurationRef && first) {
duration = stepDurationRef * stepCount;
}
// in case the end (target) timestamp is not yet defined then
// updates the value with the target duration
options._end = options._end === undefined ? options._start + duration : options._end;
// determines the duration (in seconds) for each step taking
// into account the complete duration and the number of steps
stepDuration = duration / Math.abs(stepCount);
options.duration = duration - stepDuration;
// in case no step duration has been defined defines one as that's relevant
// to be able to calculate expected time at this point in time and then
// calculate the amount of time and frames to skip
options._stepDuration =
options._stepDuration === undefined ? stepDuration : options._stepDuration;
// calculates the expected timestamp for the current position in
// time and then the delay against it (for proper frame dropping)
const expected = options._start + options._step * options._stepDuration;
const delay = Math.max(new Date().getTime() - expected, 0);
// calculates the number of frames that have to be skipped to re-catch
// the animation back to the expect time-frame
const frameSkip = Math.floor(delay / stepDuration);
reducedTime = delay % stepDuration;
const stepSize = frameSkip + 1;
// calculates the delta in terms of steps taking into account
// if any frame should be skipped in the animation
const nextStep = Math.min(options._stepCount, options._step + stepSize);
const delta = Math.min(stepSize, nextStep - options._step);
options._step = nextStep;
// checks if it should rotate in the positive or negative direction
// according to the current view definition and then calculates the
// next position taking into account that definition
const goPositive = (position + stepCount) % viewFrames === nextPosition;
stepPosition =
stepCount !== 0 ? (goPositive ? position + delta : position - delta) : position;
// wrap around as needed (avoiding index overflow)
stepPosition = stepPosition < 0 ? viewFrames + stepPosition : stepPosition;
stepPosition = stepPosition % viewFrames;
// updates the position according to the calculated one on
// the dataset, the next update operation should trigger
// the appropriate update on the visual resources
this.position = stepPosition;
this.element.dataset.position = stepPosition;
}
// sets the initial values for the start of the animation, allows
// control of the current animation (and exclusive lock)
this.element.classList.add("animating");
// if the prevent drag is set and there's an animation then
// ignores drag movements until the animation is finished
preventDrag = preventDrag && (animate || duration);
if (preventDrag) this.element.classList.add("no-drag");
// calculates the amount of time that the current operation is
// going to sleep to able to correctly address the animation sequence
// (valid only for no animation scenarios, no cross fade) if this
// sleep time is valid (greater than zero) tuns the async based
// await operation for the amount of time
const sleepTime = animate ? 0 : stepDuration - reducedTime;
if (sleepTime > 0) {
await new Promise(resolve => setTimeout(() => resolve(), sleepTime));
}
// computes the frame key (normalized) and then triggers an event
// notifying any listener about the new frame that was set
const newFrame = ripe.getFrameKey(this.element.dataset.view, this.element.dataset.position);
this.trigger("changed_frame", newFrame);
try {
// runs the update operation that should sync the visuals of the
// configurator according to the current internal state (in data)
// this operation waits for the proper drawing of the image (takes
// some time and resources to be completed)
const result = await this.update(
{},
{
animate: animate,
duration: animate ? duration : 0
}
);
// in case the update operation has finished, but the result is a
// canceled one, then the internal state is stored as if the change
// frame operation has succeeded and the proper data is set, so that
// from a logical point of view the operation has succeeded
if (result && result.canceled) {
[this.view, this.position] = [nextView, nextPosition];
[this.element.dataset.view, this.element.dataset.position] = [nextView, nextPosition];
this.element.classList.remove("no-drag", "animating");
const newFrame = ripe.getFrameKey(
this.element.dataset.view,
this.element.dataset.position
);
this.trigger("changed_frame", newFrame);
return;
}
} catch (err) {
// removes the locking classes as the current operation has been
// finished, as sets the position of the element to the last
// position of the animation (assumes it has ended)
[this.view, this.position] = [nextView, nextPosition];
[this.element.dataset.view, this.element.dataset.position] = [nextView, nextPosition];
this.element.classList.remove("no-drag", "animating");
const newFrame = ripe.getFrameKey(this.element.dataset.view, this.element.dataset.position);
this.trigger("changed_frame", newFrame);
throw err;
}
// in case the change frame operation has been completed
// target view and position has been reached, then it's
// time collect the garbage and return control flow
if (view === nextView && stepPosition === nextPosition) {
this.element.classList.remove("no-drag", "animating");
return;
}
// creates a new options instance that is going to be used in the
// possible next tick of the operation
options = Object.assign({}, options, { safe: false, first: false });
// runs the next tick operation to change the frame of the current
// configurator to the next one (iteration cycle)
await this.changeFrame(frame, options);
};
/**
* Highlights a model's part, showing a dark mask on top of the such referred
* part identifying its borders.
*
* @param {String} part The part of the model that should be highlighted.
* @param {Object} options Set of optional parameters to adjust the highlighting.
*/
ripe.ConfiguratorPrc.prototype.highlight = function(part, options = {}) {
// in case the element is no longer available (possible due to async
// nature of execution) returns the control flow immediately
if (!this.element) return;
// if the 'useMasks' options is not set then it's the
// "no_masks" tag that indicates mask presence, otherwise
// the usage of masks is controlled by 'useMasks' (defaulting
// to 'true')
const useMasks = this.useMasks === undefined ? !this.owner.hasTag("no_masks") : this.useMasks;
// verifiers if masks are meant to be used for the current model
// and if that's not the case returns immediately
if (!useMasks) return;
// captures the current context to be used by clojure callbacks
const self = this;
// determines the current position of the configurator so that
// the proper mask URL may be created and properly loaded
const view = this.element.dataset.view;
const position = this.element.dataset.position;
const frame = ripe.getFrameKey(view, position);
const size = this.element.dataset.size || this.size;
const width = this.element.dataset.width || this.width || size;
const height = this.element.dataset.height || this.height || size;
const maskOpacity = this.element.dataset.mask_opacity || this.maskOpacity;
const maskDuration = this.element.dataset.mask_duration || this.maskDuration;
// adds the highlight class to the current target configurator meaning
// that the front mask is currently active and showing info
this.element.classList.add("highlight");
// constructs the full URL of the mask image that is going to be
// set for the current highlight operation (to be determined)
const url = this.owner._getMaskURL({
frame: frame,
size: size,
width: width,
height: height,
part: part
});
// gathers the front mask element and the associated source URL
// and in case it's the same as the one in request returns immediately
// as the mask is considered to be already loaded
const frontMask = this.element.querySelector(".front-mask");
const src = frontMask.getAttribute("src");
if (src === url) {
self.trigger("highlighted_part", part);
return;
}
// in case there's a front mask handler defined for a series
// of events then unregister the event listener
if (this.frontMaskLoad) frontMask.removeEventListener("load", this.frontMaskLoad);
if (this.frontMaskError) frontMask.removeEventListener("error", this.frontMaskError);
frontMask.classList.remove("loaded");
this.frontMaskLoad = function() {
this.classList.add("loaded");
self.trigger("highlighted_part", part);
};
this.frontMaskError = function() {
this.removeAttribute("src");
};
frontMask.addEventListener("load", this.frontMaskLoad);
frontMask.addEventListener("error", this.frontMaskError);
frontMask.setAttribute("src", url);
ripe.cancelAnimation(frontMask);
ripe.animateProperty(frontMask, "opacity", 0, maskOpacity, maskDuration, false);
};
/**
* Removes the a highlighting of a model's part, meaning that no masks
* are going to be presented on screen.
*
* @param {String} part The part to lowlight.
* @param {Object} options Set of optional parameters to adjust the lowlighting.
*/
ripe.ConfiguratorPrc.prototype.lowlight = function(options) {
// in case the element is no longer available (possible due to async
// nature of execution) returns the control flow immediately
if (!this.element) return;
// if the 'useMasks' options is not set then it's the
// "no_masks" tag that indicates mask presence, otherwise
// the usage of masks is controlled by 'useMasks' (defaulting
// to 'true')
const useMasks = this.useMasks === undefined ? !this.owner.hasTag("no_masks") : this.useMasks;
// verifies if masks are meant to be used for the current model
// and if that's not the case returns immediately
if (!useMasks) return;
// retrieves the reference to the current front mask and removes
// the highlight associated classes from it and the configurator
const frontMask = this.element.querySelector(".front-mask");
frontMask.classList.remove("highlight");
this.element.classList.remove("highlight");
// triggers an event indicating that a lowlight operation has been
// performed on the current configurator
this.trigger("lowlighted");
};
/**
* Changes the currently displayed frame in the current view to the
* previous one according to pre-defined direction.
*/
ripe.ConfiguratorPrc.prototype.previousFrame = function() {
const view = this.element.dataset.view;
const position = parseInt(this.element.dataset.position || 0);
const viewFrames = this.frames[view];
let nextPosition = (position - 1) % viewFrames;
nextPosition = nextPosition >= 0 ? nextPosition : viewFrames + nextPosition;
const nextFrame = ripe.getFrameKey(view, nextPosition);
this.changeFrame(nextFrame);
};
/**
* Changes the currently displayed frame in the current view to the
* next one according to pre-defined direction.
*/
ripe.ConfiguratorPrc.prototype.nextFrame = function() {
const view = this.element.dataset.view;
const position = parseInt(this.element.dataset.position || 0);
const viewFrames = this.frames[view];
let nextPosition = (position + 1) % viewFrames;
nextPosition = nextPosition >= 0 ? nextPosition : viewFrames + nextPosition;
const nextFrame = ripe.getFrameKey(view, nextPosition);
this.changeFrame(nextFrame);
};
/**
* Resizes the Configurator to the defined maximum size.
*
* @param {Object} options Set of optional parameters to adjust the resizing.
*/
ripe.ConfiguratorPrc.prototype.enterFullscreen = async function(options) {
if (this.element === undefined) {
return;
}
this.element.classList.add("fullscreen");
const maxSize = this.element.dataset.max_size || this.maxSize;
await this.resize(maxSize);
};
/**
* Resizes the Configurator to the prior defined size.
*
* @param {Object} options Set of optional parameters to adjust the resizing.
*/
ripe.ConfiguratorPrc.prototype.leaveFullscreen = async function(options) {
if (this.element === undefined) {
return;
}
this.element.classList.remove("fullscreen");
await this.resize();
};
/**
* Turns on (enables) the masks on selection/highlight.
*/
ripe.ConfiguratorPrc.prototype.enableMasks = function() {
this.useMasks = true;
};
/**
* Turns off (disables) the masks on selection/highlight.
*/
ripe.ConfiguratorPrc.prototype.disableMasks = function() {
this.useMasks = false;
};
/**
* Syncs the PRC configurator state to a CSR configurator state.
*
* @param {ConfiguratorCsr} csrConfigurator The CSR configurator.
*/
ripe.ConfiguratorPrc.prototype.syncFromCSR = async function(csrConfigurator) {
// sets the PRC configurator state
await this.updateOptions({
width: csrConfigurator.width,
height: csrConfigurator.height,
size: csrConfigurator.size,
pixelRatio: csrConfigurator.pixelRatio,
sensitivity: csrConfigurator.sensitivity,
verticalThreshold: csrConfigurator.verticalThreshold,
duration: csrConfigurator.duration
});
// resizes the PRC configurator to match the CSR size
await this.resize();
// changes the PRC frame to match CSR configurator visuals
const frame = await csrConfigurator.prcFrame();
await this.changeFrame(frame, { duration: 0 });
};
/**
* Initializes the layout for the configurator element by
* constructing all te child elements required for the proper
* configurator functionality to work.
*
* From a DOM perspective this is a synchronous operation,
* meaning that after its execution the configurator is ready
* to be manipulated.
*
* @private
*/
ripe.ConfiguratorPrc.prototype._initLayout = function() {
// in case the element is no longer available (possible due to async
// nature of execution) returns the control flow immediately
if (!this.element) return;
// clears the elements children by iterating over them
while (this.element.firstChild) {
this.element.removeChild(this.element.firstChild);
}
// sets the element's style so that it supports two canvas
// on top of each other so that double buffering can be used
this.element.classList.add("configurator");
// creates the area canvas and adds it to the element
const area = ripe.createElement("canvas", "area");
const context = area.getContext("2d");
context.globalCompositeOperation = "multiply";
this.element.appendChild(area);
// adds the front mask element to the element,
// this will be used to highlight parts
const frontMask = ripe.createElement("img", "front-mask");
this.element.appendChild(frontMask);
// creates the back canvas and adds it to the element,
// placing it on top of the area canvas
const back = ripe.createElement("canvas", "back");
const backContext = back.getContext("2d");
backContext.globalCompositeOperation = "multiply";
this.element.appendChild(back);
// creates the mask element that will de used to display
// the mask on top of an highlighted or selected part
const mask = ripe.createElement("canvas", "mask");
this.element.appendChild(mask);
// adds the framesBuffer placeholder element that will be used to
// temporarily store the images of the product's frames
const framesBuffer = ripe.createElement("div", "frames-buffer");
// creates a masksBuffer element that will be used to store the continuous
// mask images to be used during highlight and select operation
const masksBuffer = ripe.createElement("div", "masks-buffer");
// adds both buffer elements (frames and masks) to the base elements
// they are going to be used as placeholders for the "img" elements
// that are going to be loaded with the images
this.element.appendChild(framesBuffer);
this.element.appendChild(masksBuffer);
// set the size of area, frontMask, back and mask
this.resize();
// sets the initial view and position
this.element.dataset.view = this.view;
this.element.dataset.position = this.position;
// register for all the necessary DOM events
this._registerHandlers();
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._initPartsList = async function() {
// creates a set of sorted parts to be used on the
// highlight operation (considers only the default ones)
this.partsList = [];
const config = this.owner.loadedConfig
? this.owner.loadedConfig
: await this.owner.getConfigP();
const defaults = config.defaults || {};
this.hiddenParts = config.hidden || [];
this.partsList = Object.keys(defaults);
this.partsList.sort();
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._populateBuffers = function() {
// in case the element is no longer available (possible due to async
// nature of execution) returns the control flow immediately
if (!this.element) return;
const framesBuffer = this.element.getElementsByClassName("frames-buffer");
const masksBuffer = this.element.getElementsByClassName("masks-buffer");
let buffer = null;
for (let index = 0; index < framesBuffer.length; index++) {
buffer = framesBuffer[index];
this._populateBuffer(buffer);
}
for (let index = 0; index < masksBuffer.length; index++) {
buffer = masksBuffer[index];
this._populateBuffer(buffer);
}
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._populateBuffer = function(buffer) {
while (buffer.firstChild) {
buffer.removeChild(buffer.firstChild);
}
// creates two image elements for each frame and
// appends them to the frames and masks buffers
for (const view in this.frames) {
const viewFrames = this.frames[view];
for (let index = 0; index < viewFrames; index++) {
const frameBuffer = ripe.createElement("img");
frameBuffer.dataset.frame = ripe.getFrameKey(view, index);
buffer.appendChild(frameBuffer);
}
}
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._updateConfig = async function(animate) {
// in case the element is no longer available (possible due to async
// nature of execution) returns the control flow immediately
if (!this.element) return;
// sets ready to false to temporarily block
// update requests while the new config
// is being loaded
this.ready = false;
// removes the highlight from any part
this.lowlight();
// updates the parts list for the new product
this._initPartsList();
// retrieves the new product frame object and sets it
// under the current state, adapting then the internal
// structures to accommodate the possible changes in the
// frame structure
this.frames = await this.owner.getFrames();
// populates the buffers taking into account
// the frames of the model
this._populateBuffers();
// tries to keep the current view and position
// if the new model supports it otherwise
// changes to a supported frame
let view = this.element.dataset.view;
let position = parseInt(this.element.dataset.position);
const maxPosition = this.frames[view];
if (!maxPosition) {
view = Object.keys(this.frames)[0];
position = 0;
} else if (position >= maxPosition) {
position = 0;
}
// gets the dimensions of the current frame being shown
this.frameSize = await this.owner.getSize("$base", view);
// checks the last viewed frames of each view
// and deletes the ones not supported
const lastFrameViews = Object.keys(this._lastFrame);
for (const _view of lastFrameViews) {
const _position = this._lastFrame[_view];
const _maxPosition = this.frames[_view];
if (!_maxPosition || _position >= _maxPosition) {
delete this._lastFrame[_view];
}
}
// updates the instance values for the configurator view
// and position so that they reflect the current visuals
this.view = view;
this.position = position;
// updates the number of frames in the initial view
// taking into account the requested frames data
const viewFrames = this.frames[view];
this.element.dataset.frames = viewFrames;
// updates the attributes related with both the view
// and the position for the current model
this.element.dataset.view = view;
this.element.dataset.position = position;
// marks the current configurator as ready and triggers
// the associated ready event to any event listener
this.ready = true;
this.trigger("ready", { id: this.id, origin: "configurator-prc" });
// adds the config visual class indicating that
// a configuration already exists for the current
// interactive configurator (meta-data)
this.element.classList.add("ready");
// computes the frame key for the current frame to
// be shown and triggers the changed frame event
const frame = ripe.getFrameKey(this.element.dataset.view, this.element.dataset.position);
this.trigger("changed_frame", frame);
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._loadFrame = async function(view, position, options = {}) {
// triggers the initial frame event that indicates that a
// new frame is going to be loaded into the img buffers
this.trigger("pre_frame", {
view: view,
position: position,
options: options
});
// runs the defaulting operation on all of the parameters
// sent to the load frame operation (defaulting)
view = view || this.element.dataset.view || "side";
position = position || this.element.dataset.position || 0;
const frame = ripe.getFrameKey(view, position);
const format = this.element.dataset.format || this.format;
let size = this.element.dataset.size || this.size;
let width = this.element.dataset.width || this.width || size;
let height = this.element.dataset.height || this.height || size;
const backgroundColor = this.element.dataset.background_color || this.backgroundColor;
// if enabled, uses the width and height of the frame of the
// model size instead of the size of the container, this should
// provide a legacy compatibility layer as this is considered
// to be the historic default behaviour of the configurator
if (this.useDefaultSize) {
size = this.frameSize[0];
width = this.frameSize[0];
height = this.frameSize[1];
}
const draw = options.draw === undefined || options.draw;
const animate = options.animate;
const duration = options.duration;
const framesBuffer = this.element.querySelector(".frames-buffer");
const masksBuffer = this.element.querySelector(".masks-buffer");
const area = this.element.querySelector(".area");
let image = framesBuffer.querySelector(`img[data-frame='${String(frame)}']`);
const front = area.querySelector(`img[data-frame='${String(frame)}']`);
const maskImage = masksBuffer.querySelector(`img[data-frame='${String(frame)}']`);
image = image || front;
// in case there's no images for the frames that are meant
// to be loaded, then throws an error indicating that it's
// not possible to load the requested frame
if (image === null || maskImage === null) {
throw new RangeError("Frame " + frame + " is not supported");
}
// in case masks are set to be used, triggers the async loading of
// the "master" mask for the current frame, this should imply some level
// of cache usage
if (this.useMasks) this._loadMask(maskImage, view, position, options);
// apply pixel ratio to image dimensions so that the image obtained
// reflects the target pixel density
size = size ? parseInt(size * this.pixelRatio) : size;
width = width ? parseInt(width * this.pixelRatio) : width;
height = height ? parseInt(height * this.pixelRatio) : height;
// does not allow requesting an image with dimensions bigger than
// the dimensions defined by the build for the current face, only
// applies this logic in case the frame size is available
if (this.frameSize) {
size = size ? Math.min(size, this.frameSize[0]) : size;
width = width ? Math.min(width, this.frameSize[0]) : width;
height = height ? Math.min(width, this.frameSize[1]) : height;
}
// builds the URL that will be set on the image, notice that both
// the full URL mode is avoided so that no extra parameters are
// added to the image composition (not required)
const url = this.owner._getImageURL({
frame: frame,
format: format,
size: size,
width: width,
height: height,
background: backgroundColor,
full: false
});
// verifies if the loading of the current image
// is considered redundant (already loaded or
// loading) and avoids for performance reasons
const isRedundant = image.dataset.src === url;
if (isRedundant) {
// in case no draw is required returns the control flow
// immediately, nothing to be done
if (!draw) return;
// check if the image on the buffer is already loaded
// nad if that's the case draws the frame
const isReady = image.dataset.loaded === "1";
if (isReady) await this._drawFrame(image, animate, duration);
// triggers the post frame event indicating the end
// of the image preloading under cache match situation
this.trigger("post_frame", {
view: view,
position: position,
options: options,
result: false
});
// returns immediately there's nothing remaining to
// be done as the image is already loaded
return;
}
// adds load callback to the image to draw the frame
// when it is available from the "remote" source
const imagePromise = new Promise((resolve, reject) => {
image.onload = async () => {
image.dataset.loaded = "1";
image.dataset.canceled = "0";
if (!draw) {
resolve();
return;
}
try {
await this._drawFrame(image, animate, duration);
} catch (err) {
reject(err);
}
resolve();
};
image.onerror = () => {
reject(new Error("Problem loading image"));
};
});
// sets the src of the image to trigger the request
// and sets loaded to false to indicate that the
// image is not yet loading
image.src = url;
image.dataset.src = url;
image.dataset.loaded = "0";
image.dataset.canceled = "0";
// in case the cancel callback function has been provided
// via options, then a function must be created allowing
// the consumer to cancel the image loading in the middle
// of the process, this allows breaking loading processes
if (options.cancelCallback) {
const cancel = () => {
if (image.dataset.loaded === "1") {
return;
}
image.src = "";
image.dataset.src = "";
image.dataset.loaded = "0";
image.dataset.canceled = "1";
};
options.cancelCallback(cancel);
}
try {
// waits until the image promise is resolved so that
// we're sure everything is currently loaded
await imagePromise;
} catch (err) {
// triggers the post frame operation indicating a no
// loading operation and providing the original error
this.trigger("post_frame", {
view: view,
position: position,
options: options,
result: false,
error: err
});
throw err;
}
// triggers the post frame event indicating that the
// frame has been buffered into the img element with
// no cache operation activated
this.trigger("post_frame", {
view: view,
position: position,
options: options,
result: true
});
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._loadMask = function(maskImage, view, position, options) {
// constructs the URL for the mask and then at the end of the
// mask loading process runs the final update of the mask canvas
// operation that will allow new highlight and selection operation
// to be performed according to the new frame value
const draw = options.draw === undefined || options.draw;
const size = this.element.dataset.size || this.size;
const width = this.element.dataset.width || this.width || size;
const height = this.element.dataset.height || this.height || size;
const frame = ripe.getFrameKey(view, position);
const url = this.owner._getMaskURL({
frame: frame,
size: size,
width: width,
height: height
});
if (draw && maskImage.dataset.src === url) {
setTimeout(() => {
this._drawMask(maskImage);
}, 150);
} else {
maskImage.onload = draw
? () => {
setTimeout(() => {
this._drawMask(maskImage);
}, 150);
}
: null;
maskImage.onerror = () => {
maskImage.removeAttribute("src");
};
maskImage.crossOrigin = "anonymous";
maskImage.dataset.src = url;
maskImage.setAttribute("src", url);
}
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._drawMask = function(maskImage) {
// in case the element is no longer available (possible due to async
// nature of execution) returns the control flow immediately
if (!this.element) return;
const mask = this.element.querySelector(".mask");
const maskContext = mask.getContext("2d");
maskContext.clearRect(0, 0, mask.width, mask.height);
maskContext.drawImage(maskImage, 0, 0, mask.width, mask.height);
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._drawFrame = async function(image, animate, duration) {
// in case the element is no longer available (possible due to async
// nature of execution) returns the control flow immediately
if (!this.element) return;
const area = this.element.querySelector(".area");
const back = this.element.querySelector(".back");
const visible = area.dataset.visible === "true";
const current = visible ? area : back;
const target = visible ? back : area;
const context = target.getContext("2d");
// retrieves the animation identifiers for both the current
// canvas and the target one and cancels any previous animation
// that might exist in such canvas (as a new one is coming)
ripe.cancelAnimation(current);
ripe.cancelAnimation(target);
// clears the canvas context rectangle and then draws the image from
// the buffer to the target canvas (back buffer operation)
context.clearRect(0, 0, target.width, target.height);
context.drawImage(image, 0, 0, target.width, target.height);
// switches the visibility (meta information )of the target and the
// current canvas elements (this is just logic information)
target.dataset.visible = true;
current.dataset.visible = false;
// in case no animation is requested the z index and opacity switch
// is immediate, this is consider a fast double buffer switch
if (!animate) {
current.style.zIndex = 1;
current.style.opacity = 0;
target.style.zIndex = 1;
target.style.opacity = 1;
return;
}
// "calculates" the duration for the animate operation taking into
// account the passed parameter and the "kind" of animation, falling
// back to the instance default if required
duration = duration || (animate === "immediate" ? 0 : this.duration);
// creates an array of promises that are going to be waiting for so that
// the animation on the draw is considered finished
const promises = [];
if (animate === "cross") {
promises.push(ripe.animateProperty(current, "opacity", 1, 0, duration));
}
promises.push(ripe.animateProperty(target, "opacity", 0, 1, duration));
// waits for both animations to finish so that the final update on
// the current settings can be performed (changing it's style)
await Promise.all(promises);
// updates the style to its final state for both the current and the
// target canvas elements
current.style.opacity = 0;
current.style.zIndex = 1;
target.style.zIndex = 1;
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._preload = async function(useChain = false) {
// retrieves the current position of the configurator from its
// data defaulting to the zero one (reference) in case no position
// is currently defined in the configurator
const view = this.element.dataset.view || "side";
const position = parseInt(this.element.dataset.position) || 0;
// updates the internal index state that is going to be
// used to control the state of te preload update, discarding
// single image load operations in case their outdated
let index = this.index || 0;
index++;
this.index = index;
this.element.classList.add("preload");
// adds all the frames available for all the views to the
// list of work to be performed on pre-loading
const work = [];
for (const _view of Object.keys(this.frames)) {
const viewFrames = this.frames[_view];
for (let _index = 0; _index < viewFrames; _index++) {
if (_index === position && view === _view) {
continue;
}
const frame = ripe.getFrameKey(_view, _index);
work.push(frame);
}
}
work.reverse();
// "saves" space for the sequence of functions that will allow
// canceling the loading of the images in the middle of the process
// "releasing" the underlying promises
const cancels = [];
// saves space to store the complete set of promises that are
// going to be created for the preloading operation of the images,
// each image will have its own promise for loading
const promises = [];
// stores the default resolve result that is going to be returned
// in the underlying promise for the loading of the image, this value
// may be changed by the `finalize()` method call, it's safe to store
// this value at the instance level because no two parallel preload
// operations may exist (otherwise major problems would arise)
this._resolveResult = true;
// waits for the pre loading promise so that at the end of this
// execution all the work required for loading is processed
const result = await new Promise((resolve, reject) => {
this._finalize = (result = true) => {
// invalidates the work queue by setting its
// length value to zero (clears array)
work.length = 0;
// calls the complete set of cancel operations on
// the image loading process, in case they've not
// be yet loaded (otherwise no-op is run)
cancels.forEach(cancel => cancel());
// unsets the finalize clojure from the current instance
// effectively disallowing further usage of it
this._finalize = null;
// updates the resolve result value with the one received
// via parameter, this is going to be used latter on to
// update the promise return value accordingly
this._resolveResult = result;
// in case no promises have yet been created for the loading
// of the images then calls the terminate immediately to
// put an end to the current global promise
if (promises.length === 0) terminate();
};
const terminate = (result = undefined) => {
// unsets the finalize clojure from the current instance
// effectively disallowing further usage of it
this._finalize = null;
// removes the pending classes that indicate that
// there's some kind of preloading happening
this.element.classList.remove("preloading");
this.element.classList.remove("no-drag");
// terminates the promise by resolving it with
// the result stored in the current instance
resolve(result === undefined ? this._resolveResult : result);
};
/**
* Callback function to be called whenever a new frame is
* pre-loaded (end of step), should be able to trigger the end
* of the loading if it represents the last image loading operation.
*
* @param {DOMElement} element The reference to the element that is
* considered to be the configurator.
* @param {Boolean} canceled If the image loading has been canceled
* in the middle of the loading process.
*/
const mark = element => {
// in case the current index of operation (unique auto-increment
// value) is no longer the current in process then ignores this
// marking operation as it's considered outdated
const _index = this.index;
if (index !== _index) return;
if (!this.element) return;
// removes the preloading class from the image element
// this is considered the default operation
element.classList.remove("preloading");
// retrieves all the images still preloading from the frames
// buffer to determine if this was the final image loading
const framesBuffer = this.element.querySelector(".frames-buffer");
const pending = framesBuffer.querySelectorAll("img.preloading") || [];
// if there are still images preloading then adds the preloading
// class to the target element and prevents drag movements to
// avoid flickering else and if there are no images preloading and no
// frames yet to be preloaded then the preload is considered finished
// so drag movements are allowed again and the loaded event is triggered
if (pending.length > 0) {
this.element.classList.add("preloading");
this.element.classList.add("no-drag");
} else if (work.length === 0) {
terminate();
}
};
const render = async () => {
const _index = this.index;
if (index !== _index) {
return;
}
// in case there's no more work pending returns immediately
// (nothing is remaining to be done)
if (work.length === 0) {
return;
}
// retrieves the next frame to be loaded and its
// corresponding image element and adds the preloading
// class to it (indicating that preloading will take place)
const frame = work.pop();
const framesBuffer = this.element.querySelector(".frames-buffer");
const reference = framesBuffer.querySelector(`img[data-frame='${String(frame)}']`);
reference.classList.add("preloading");
// determines if a chain based loading (sequential vs parallel
// loading) should be used for the pre-loading process of the
// continuous image resources to be loaded
const _frame = ripe.parseFrameKey(frame);
const view = _frame[0];
const position = _frame[1];
const promise = this._loadFrame(view, position, {
draw: false,
cancelCallback: cancel => {
cancels.push(cancel);
}
});
promises.push(promise);
promise.then(
() => mark(reference),
() => mark(reference, true)
);
if (useChain) await promise;
await render();
};
// adds the preloading flag and then prevents mouse drag
// movements by setting proper classes
this.element.classList.add("preloading");
this.element.classList.add("no-drag");
if (work.length > 0) {
// schedule the timeout operation in order to trigger
// the pre-loading of the remaining frames, the delay
// is meant to provide some time buffer to the current
// frame (higher priority) to be processes in the server
// effectively allowing selective QoS (Quality of Service)
setTimeout(async () => {
try {
await render();
} catch (err) {
reject(err);
}
}, this.preloadDelay);
} else {
terminate();
}
});
// returns the final result coming from the preload promise
// that should indicate the status on the preloading operation
return result;
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._registerHandlers = function() {
// captures the current context to be used inside clojures
const self = this;
// retrieves the reference to the multiple elements that
// are going to be used for event handler operations
const area = this.element.querySelector(".area");
const back = this.element.querySelector(".back");
// binds the mousedown event on the element to prepare
// it for drag movements
this._addElementHandler("mousedown", function(event) {
const _element = this;
_element.dataset.view = _element.dataset.view || "side";
self.base = parseInt(_element.dataset.position) || 0;
self.down = true;
self.referenceX = event.pageX;
self.referenceY = event.pageY;
self.percent = 0;
_element.classList.add("drag");
});
// listens for mouseup events and if it occurs then
// stops reacting to mouse move events has drag movements
this._addElementHandler("mouseup", function(event) {
const _element = this;
self.down = false;
self.previous = self.percent;
self.percent = 0;
_element.classList.remove("drag");
});
// listens for mouse leave events and if it occurs then
// stops reacting to mousemove events has drag movements
this._addElementHandler("mouseleave", function(event) {
const _element = this;
self.down = false;
self.previous = self.percent;
self.percent = 0;
_element.classList.remove("drag");
});
// if a mouse move event is triggered while the mouse is
// pressed down then updates the position of the drag element
this._addElementHandler("mousemove", function(event) {
if (!this.classList.contains("ready") || this.classList.contains("no-drag")) {
return;
}
const down = self.down;
self.mousePosX = event.pageX;
self.mousePosY = event.pageY;
if (down) self._parseDrag();
});
area.addEventListener("click", function(event) {
// verifies if the previous drag operation (if any) has exceed
// the minimum threshold to be considered drag (click avoided)
if (Math.abs(self.previous) > self.clickThreshold) {
event.stopImmediatePropagation();
event.stopPropagation();
return;
}
const preloading = self.element.classList.contains("preloading");
const animating = self.element.classList.contains("animating");
if (preloading || animating) {
return;
}
event = ripe.fixEvent(event);
const index = self._getCanvasIndex(this, event.offsetX, event.offsetY);
if (index === 0) {
return;
}
// retrieves the reference to the part name by using the index
// extracted from the masks image (typical strategy for retrieval)
const part = self.partsList[index - 1];
const isVisible = self.hiddenParts.indexOf(part) === -1;
if (part && isVisible) self.owner.selectPart(part);
event.stopPropagation();
});
area.addEventListener("mouseleave", function(event) {
// in case the mouse leaves the area then the
// part highlight must be removed
self.lowlight();
});
area.addEventListener("mousemove", function(event) {
const preloading = self.element.classList.contains("preloading");
const animating = self.element.classList.contains("animating");
if (preloading || animating) {
return;
}
event = ripe.fixEvent(event);
// tries to retrieve the layer/part index associated with current
// mouse coordinates to better act in the mouse move operation, as
// this may represent a possible highlight operation
const index = self._getCanvasIndex(this, event.offsetX, event.offsetY);
// in case the index that was found is the zero one this is a special
// position and the associated operation is the removal of the highlight
// also if the target is being dragged the highlight should be removed
if (index === 0 || self.down === true) {
self.lowlight();
return;
}
// retrieves the reference to the part name by using the index
// extracted from the masks image (typical strategy for retrieval)
const part = self.partsList[index - 1];
const isVisible = self.hiddenParts.indexOf(part) === -1;
if (part && isVisible) self.highlight(part);
else self.lowlight();
});
area.addEventListener("dragstart", function(event) {
event.preventDefault();
});
area.addEventListener("dragend", function(event) {
event.preventDefault();
});
back.addEventListener("click", function(event) {
// verifies if the previous drag operation (if any) has exceed
// the minimum threshold to be considered drag (click avoided)
if (Math.abs(self.previous) > self.clickThreshold) {
event.stopImmediatePropagation();
event.stopPropagation();
return;
}
const preloading = self.element.classList.contains("preloading");
const animating = self.element.classList.contains("animating");
if (preloading || animating) {
return;
}
event = ripe.fixEvent(event);
const index = self._getCanvasIndex(this, event.offsetX, event.offsetY);
if (index === 0) {
return;
}
// retrieves the reference to the part name by using the index
// extracted from the masks image (typical strategy for retrieval)
const part = self.partsList[index - 1];
const isVisible = self.hiddenParts.indexOf(part) === -1;
if (part && isVisible) self.owner.selectPart(part);
event.stopPropagation();
});
back.addEventListener("mousemove", function(event) {
const preloading = self.element.classList.contains("preloading");
const animating = self.element.classList.contains("animating");
if (preloading || animating) {
return;
}
event = ripe.fixEvent(event);
// tries to retrieve the layer/part index associated with current
// mouse coordinates to better act in the mouse move operation, as
// this may represent a possible highlight operation
const index = self._getCanvasIndex(this, event.offsetX, event.offsetY);
// in case the index that was found is the zero one this is a special
// position and the associated operation is the removal of the highlight
// also if the target is being dragged the highlight should be removed
if (index === 0 || self.down === true) {
self.lowlight();
return;
}
// retrieves the reference to the part name by using the index
// extracted from the masks image (typical strategy for retrieval)
const part = self.partsList[index - 1];
const isVisible = self.hiddenParts.indexOf(part) === -1;
if (part && isVisible) self.highlight(part);
else self.lowlight();
});
back.addEventListener("dragstart", function(event) {
event.preventDefault();
});
back.addEventListener("dragend", function(event) {
event.preventDefault();
});
// verifies if mutation should be "observed" for this visual
// and in such case registers for the observation of any DOM
// mutation (eg: attributes) for the configurator element, triggering
// a new update operation in case that happens
if (this.mutations) {
// listens for attribute changes to redraw the configurator
// if needed, this makes use of the mutation observer, the
// redraw should be done for width and height style and attributes
const Observer =
(typeof MutationObserver !== "undefined" && MutationObserver) ||
(typeof WebKitMutationObserver !== "undefined" && WebKitMutationObserver) || // eslint-disable-line no-undef
null;
this._observer = Observer
? new Observer(mutations => {
for (let index = 0; index < mutations.length; index++) {
const mutation = mutations[index];
if (mutation.type === "style") self.resize();
if (mutation.type === "attributes") self.update();
}
})
: null;
if (this._observer) {
this._observer.observe(this.element, {
attributes: true,
subtree: false,
characterData: true,
attributeFilter: ["style", "data-format", "data-size", "data-width", "data-height"]
});
}
}
// adds handlers for the touch events so that they get
// parsed to mouse events for the configurator element,
// taking into account that there may be a touch handler
// already defined
ripe.touchHandler(this.element);
};
/**
* @ignore
*/
ripe.ConfiguratorPrc.prototype._parseDrag = function() {
// retrieves the last recorded mouse position
// and the current one and calculates the
// drag movement made by the user
const child = this.element.querySelector("*:first-child");
const referenceX = this.referenceX;
const referenceY = this.referenceY;
const mousePosX = this.mousePosX;
const mousePosY = this.mousePosY;
const base = this.base;
const deltaX = referenceX - mousePosX;
const deltaY = referenceY - mousePosY;
const elementWidth = this.element.clientWidth;
const elementHeight = this.element.clientHeight || child.clientHeight;
const percentX = deltaX / elementWidth;
const percentY = deltaY / elementHeight;
this.percent = percentX;
const sensitivity = this.element.dataset.sensitivity || this.sensitivity;
const verticalThreshold = this.element.dataset.verticalThreshold || this.verticalThreshold;
// if the drag was vertical then alters the
// view if it is supported by the product
const view = this.element.dataset.view;
let nextView = view;
if (sensitivity * percentY > verticalThreshold) {
nextView = view === "top" ? "side" : "bottom";
this.referenceY = mousePosY;
} else if (sensitivity * percentY < verticalThreshold * -1) {
nextView = view === "bottom" ? "side" : "top";
this.referenceY = mousePosY;
}
if (this.frames[nextView] === undefined) {
nextView = view;
}
// retrieves the current view and its frames
// and determines which one is the next frame
const viewFrames = this.frames[nextView];
const offset = Math.round((sensitivity * percentX * viewFrames) / 24);
let nextPosition = (base - offset) % viewFrames;
nextPosition = nextPosition >= 0 ? nextPosition : viewFrames + nextPosition;
// if the view changes then uses the last
// position presented in that view, if not
// then shows the next position according
// to the drag
nextPosition = view === nextView ? nextPosition : this._lastFrame[nextView] || 0;
const nextFrame = ripe.getFrameKey(nextView, nextPosition);
this.changeFrame(nextFrame);
};
/**
* Obtains the offset index (from red color) for the provided coordinates
* and taking into account the aspect ration of the canvas.
*
* @param canvas {Canvas} The canvas to be used as reference for the
* calculus of offset red color index.
* @param x {Number} The x coordinate within the canvas to obtain index.
* @param y {Number} The y coordinate within the canvas to obtain index.
* @returns {Number} The offset index using as reference the main mask
* of the current configurator.
*/
ripe.ConfiguratorPrc.prototype._getCanvasIndex = function(canvas, x, y) {
const canvasRealWidth = canvas.getBoundingClientRect().width;
const mask = this.element.querySelector(".mask");
const ratio = mask.width && canvasRealWidth && mask.width / canvasRealWidth;
x = parseInt(x * ratio);
y = parseInt(y * ratio);
const maskContext = mask.getContext("2d");
const pixel = maskContext.getImageData(x, y, 1, 1);
const r = pixel.data[0];
const index = parseInt(r);
return index;
};
/**
* Builds the signature string for the current internal state
* allowing a single unique representation of the current frame.
*
* This signature should allow dirty control for the configurator.
*
* @returns {String} The unique signature for the configurator state.
*/
ripe.ConfiguratorPrc.prototype._buildSignature = function() {
const dataset = this.element ? this.element.dataset : {};
const format = dataset.format || this.format;
const size = dataset.size || this.size;
const width = dataset.width || this.width || size;
const height = dataset.height || this.height || size;
const backgroundColor = dataset.background_color || this.backgroundColor;
return `${this.owner._getQuery({ full: false })}&width=${String(width)}&height=${String(
height
)}&format=${String(format)}&background=${String(backgroundColor)}`;
};