UNPKG

marking-menu

Version:

Marking Menu Implementation in JavaScript.

1,409 lines (1,211 loc) 54.8 kB
/*! * Marking Menu Javascript Library v0.10.0 * https://github.com/QuentinRoy/Marking-Menu * * Released under the MIT license. * https://raw.githubusercontent.com/QuentinRoy/Marking-Menu/master/LICENSE * * Marking Menus may be patented independently from this software. * * Date: Fri, 08 Apr 2022 16:01:56 GMT */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('rxjs/operators'), require('rxjs')) : typeof define === 'function' && define.amd ? define(['rxjs/operators', 'rxjs'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.MarkingMenu = factory(global.rxjs.operators, global.rxjs)); })(this, (function (operators, rxjs) { 'use strict'; /** * @param {number} a the dividend * @param {number} n the divisor * @return {number} The modulo of `a` over `n` (% is not exactly modulo but remainder). */ const mod = (a, n) => (a % n + n) % n; /** * @param {number} radians an angle in radians * @return {number} The angle in degrees. */ const radiansToDegrees = radians => radians * (180 / Math.PI); /** * @param {number} degrees an angle in degrees * @return {number} The angle in radians. */ const degreesToRadians = degrees => degrees * (Math.PI / 180); /** * @param {number} alpha a first angle (in degrees) * @param {number} beta a second angle (in degrees) * @return {number} The (signed) delta between the two angles (in degrees). */ const deltaAngle = (alpha, beta) => mod(beta - alpha + 180, 360) - 180; /** * Calculate the euclidean distance between two * points. * * @param {List<number>} point1 - The first point * @param {List<number>} point2 - The second point * @return {number} The distance between the two points. */ const dist = (point1, point2) => { const sum = point1.reduce((acc, x1i, i) => { const x2i = point2[i]; return acc + (x2i - x1i) ** 2; }, 0); return Math.sqrt(sum); }; const ANGLE_ROUNDING = 10e-8; /** * @param {number[]} a - The first point. * @param {number[]} b - The second point, center of the angle. * @param {number[]} c - The third point. * @return {number} The angle abc (in degrees) rounded at the 8th decimal. */ const angle = (a, b, c) => { const lab = dist(a, b); const lbc = dist(b, c); const lac = dist(a, c); const cos = (lab ** 2 + lbc ** 2 - lac ** 2) / (2 * lab * lbc); // Due to rounding, it can happen than cos ends up being slight > 1 or slightly < -1. // This fixes it. const adjustedCos = Math.max(-1, Math.min(1, cos)); const angleABC = radiansToDegrees(Math.acos(adjustedCos)); // Round the angle to avoid rounding issues. return Math.round(angleABC / ANGLE_ROUNDING) * ANGLE_ROUNDING; }; /** * @callback findMaxEntryComp * @param {*} item1 - A first item. * @param {*} item2 - A second item. * @return {number} A positive number if the second item should be ranked higher than the first, * a negative number if it should be ranked lower and 0 if they should be ranked * the same. */ /** * @param {List} list - A list of items. * @param {findMaxEntryComp} comp - A function to calculate a value from an item. * @return {[index, item]} The found entry. */ const findMaxEntry = (list, comp) => list.slice(0).reduce((result, item, index) => { if (comp(result[1], item) > 1) return [index, item]; return result; }, [0, list[0]]); /** * Converts the coordinates of a point in polar coordinates (angle in degrees). * * @param {number[]} point - A point. * @param {number[]} [pole=[0, 0]] - The pole of a polar coordinate * system * @return {{azymuth, radius}} The angle coordinate of the point in the polar * coordinate system in degrees. */ const toPolar = function (_ref, _temp) { let [px, py] = _ref; let [cx, cy] = _temp === void 0 ? [0, 0] : _temp; const x = px - cx; const y = py - cy; return { azymuth: radiansToDegrees(Math.atan2(y, x)), radius: Math.sqrt(x * x + y * y) }; }; // Create a custom pointer event from a touch event. const createPEventFromTouchEvent = touchEvt => { const touchList = Array.from(touchEvt.targetTouches); const sumX = touchList.reduce((acc, t) => acc + t.clientX, 0); const sumY = touchList.reduce((acc, t) => acc + t.clientY, 0); const meanX = sumX / touchList.length; const meanY = sumY / touchList.length; return { originalEvent: touchEvt, position: [meanX, meanY], timeStamp: touchEvt.timeStamp }; }; // Create a custom pointer from a mouse event. const createPEventFromMouseEvent = mouseEvt => ({ originalEvent: mouseEvt, position: [mouseEvt.clientX, mouseEvt.clientY], timeStamp: mouseEvt.timeStamp }); const mouseDrags = rootDOM => rxjs.fromEvent(rootDOM, 'mousedown').pipe(operators.map(downEvt => { // Make sure we include the first mouse down event. const drag$ = rxjs.merge(rxjs.of(downEvt), rxjs.fromEvent(rootDOM, 'mousemove')).pipe(operators.takeUntil(rxjs.fromEvent(rootDOM, 'mouseup')), // Publish it as a behavior so that any new subscription will // get the last drag position. operators.publishBehavior()); drag$.connect(); return drag$; }), operators.map(o => o.pipe(operators.map(function () { return createPEventFromMouseEvent(...arguments); })))); // Higher order observable tracking touch drags. const touchDrags = rootDOM => rxjs.fromEvent(rootDOM, 'touchstart').pipe( // Menu is supposed to have pointer-events: none so we can safely rely on // targetTouches. operators.filter(evt => evt.targetTouches.length === 1), operators.map(firstEvent => { const drag$ = rxjs.fromEvent(rootDOM, 'touchmove').pipe(operators.startWith(firstEvent), operators.takeUntil(rxjs.merge(rxjs.fromEvent(rootDOM, 'touchend'), rxjs.fromEvent(rootDOM, 'touchcancel'), rxjs.fromEvent(rootDOM, 'touchstart')).pipe(operators.filter(evt => evt.targetTouches.length !== 1))), operators.publishBehavior()); // FIXME: the line below retains the subscription until next touch end. drag$.connect(); return drag$; }), operators.map(o => o.pipe(operators.map(createPEventFromTouchEvent)))); /** * @param {HTMLElement} rootDOM - the DOM element to observe pointer events on. * @return {Observable} A higher order observable that drag observables. The sub-observables are * published as behaviors so that any new subscription immediately get the last * position. * @param {function[]} [dragObsFactories] - factory to use to observe drags. */ const watchDrags = function (rootDOM, dragObsFactories) { if (dragObsFactories === void 0) { dragObsFactories = [touchDrags, mouseDrags]; } return rxjs.merge(...dragObsFactories.map(f => f(rootDOM))); }; /** * Filter out small movements out of a drag observable. * @param {Observable} drag$ - An observable on drag movements. * @param {number} movementsThreshold - The threshold below which movements are considered * static. * @return {Observable} An observable only emitting on long enough movements. */ var longMoves = (function (drag$, movementsThreshold) { if (movementsThreshold === void 0) { movementsThreshold = 0; } return drag$.pipe(operators.scan((_ref, cur) => { let [prev] = _ref; // Initial value. if (prev == null) return [cur, false]; // End of drag can never be a long move. Such events aren't supposed to be // emitted by drag observable though. if (cur.type === 'end' || cur.type === 'cancel') return [cur, false]; // If the distance is still below the threshold, re-emit the previous // event. It will be filtered-out later, but will come back again as // prev on the next scan call. if (dist(prev.position, cur.position) < movementsThreshold) return [prev, false]; // Otherwise, emit the new event. return [cur, true]; }, []), operators.filter(_ref2 => { let [, pass] = _ref2; return pass; }), operators.map(x => x[0])); }); /** * @param {Observable} drag$ - An observable on drag movements. * @param {number} delay - The time (in ms) to wait before considering an absence of movements * as a dwell. * @param {number} [movementsThreshold=0] - The threshold below which movements are considered * static. * @param {Scheduler} [scheduler] - The scheduler to use for managing the timers that handle the timeout * for each value * @return {Observable} An observable on dwellings in the movement. */ var dwellings = (function (drag$, delay, movementsThreshold, scheduler) { if (movementsThreshold === void 0) { movementsThreshold = 0; } if (scheduler === void 0) { scheduler = undefined; } return rxjs.merge(drag$.pipe(operators.first()), longMoves(drag$, movementsThreshold)).pipe( // Emit when no long movements happend for delay time. operators.debounceTime(delay, scheduler), // debounceTime emits the last item when the source observable completes. // We don't want that here so we only take until drag is done. operators.takeUntil(drag$.pipe(operators.last())), // Make sure we do emit the last position. operators.withLatestFrom(drag$, (_, last_) => last_)); }); /** * Augment a drag$ observable so that events also include the stroke. * @param {Observable} drag$ - An observable of drag movements. * @param {List<number[]>} initStroke - Initial stroke. * @return {Observable} An observable on the gesture drawing. */ var draw = ((drag$, _ref) => { let { initStroke = [], type = undefined } = _ref; const typeOpts = type === undefined ? {} : { type }; return drag$.pipe(operators.scan((acc, notification) => ({ stroke: [...acc.stroke, notification.position], ...typeOpts, ...notification }), { stroke: initStroke })); }); const noviceMoves = (drag$, menu, _ref) => { let { menuCenter, minSelectionDist } = _ref; // Analyse local movements. const moves$ = drag$.pipe(operators.scan((last_, n) => { const { azymuth, radius } = toPolar(n.position, menuCenter); const active = radius < minSelectionDist ? null : menu.getNearestChild(azymuth); const type = last_.active === active ? 'move' : 'change'; return { active, type, azymuth, radius, ...n }; }, { active: null }), operators.startWith({ type: 'open', menu, center: menuCenter, timeStamp: performance ? performance.now() : Date.now() }), operators.share()); const end$ = moves$.pipe(operators.startWith({}), operators.last(), operators.map(n => ({ ...n, type: n.active && n.active.isLeaf() ? 'select' : 'cancel', selection: n.active }))); return rxjs.merge(moves$, end$).pipe(operators.share()); }; const menuSelection = (move$, _ref2) => { let { subMenuOpeningDelay, movementsThreshold, minMenuSelectionDist } = _ref2; return (// Wait for a pause in the movements. dwellings(move$, subMenuOpeningDelay, movementsThreshold).pipe( // Filter dwellings occurring outside of the selection area. operators.filter(n => n.active && n.radius > minMenuSelectionDist && !n.active.isLeaf())) ); }; const subMenuNavigation = (menuSelection$, drag$, subNav, navOptions) => menuSelection$.pipe(operators.map(n => subNav(drag$, n.active, { menuCenter: n.position, ...navOptions }))); /** * @param {Observable} drag$ - An observable of drag movements. * @param {MMItem} menu - The model of the menu. * @param {object} options - Configuration options. * @return {Observable} An observable on the menu navigation events. */ const noviceNavigation = (drag$, menu, _ref3) => { let { minSelectionDist, minMenuSelectionDist, movementsThreshold, subMenuOpeningDelay, menuCenter, noviceMoves: noviceMoves_ = noviceMoves, menuSelection: menuSelection_ = menuSelection, subMenuNavigation: subMenuNavigation_ = subMenuNavigation } = _ref3; // Observe the local navigation. const move$ = noviceMoves_(drag$, menu, { menuCenter, minSelectionDist }).pipe(operators.share()); // Look for (sub)menu selection. const menuSelection$ = menuSelection_(move$, { subMenuOpeningDelay, movementsThreshold, minMenuSelectionDist }); // Higher order observable on navigation inside sub-menus. const subMenuNavigation$ = subMenuNavigation_(menuSelection$, drag$, noviceNavigation, { minSelectionDist, minMenuSelectionDist, movementsThreshold, subMenuOpeningDelay, noviceMoves: noviceMoves_, menuSelection: menuSelection_, subMenuNavigation: subMenuNavigation_ }); // Start with local navigation but switch to the first sub-menu navigation // (if any). return subMenuNavigation$.pipe(operators.take(1), operators.startWith(move$), operators.switchAll()); }; /** * @param {Array.<number[]>} pointList - The list of points. * @param {number} minDist - A distance. * @param {object} options - Options. * @param {number} [options.direction=1] - The direction of the lookup: negative values means * descending lookup. * @param {number} [options.startIndex] - The index of the first point to investigate inside * pointList. If not provided, the lookup will start * from the start or the end of pointList depending * on `direction`. * @param {number[]} [options.refPoint=pointList[startIndex]] - The reference point. * @return {number} The index of the first point inside pointList that it at least `minDist` from * `refPoint`. */ const findNextPointFurtherThan = function (pointList, minDist, _temp) { let { direction = 1, startIndex = direction > 0 ? 0 : pointList.length - 1, refPoint = pointList[startIndex] } = _temp === void 0 ? {} : _temp; const step = direction / Math.abs(direction); const n = pointList.length; for (let i = startIndex; i < n && i >= 0; i += step) { if (dist(refPoint, pointList[i]) >= minDist) { return i; } } return -1; }; /** * @param {number[]} pointA - The point a. * @param {number[]} pointC - The point b. * @param {List.<number[]>} pointList - A list of points. * @param {number[]} options - Options. * @param {number} [options.startIndex=0] - The index of the first point to investigate inside * pointList. * @param {number} [options.endIndex=pointList.length - 1] - The index of the first point to * investigate inside pointList. * @return {{index, angle}} The index of the point b of pointList that maximizes the angle abc and * the angle abc. */ const findMiddlePointForMinAngle = function (pointA, pointC, pointList, _temp2) { let { startIndex = 0, endIndex = pointList.length - 1 } = _temp2 === void 0 ? {} : _temp2; let minAngle = Infinity; let maxAngleIndex = -1; for (let i = startIndex; i <= endIndex; i += 1) { const thisAngle = angle(pointA, pointList[i], pointC); if (thisAngle < minAngle) { minAngle = thisAngle; maxAngleIndex = i; } } return { index: maxAngleIndex, angle: minAngle }; }; /** * @typedef {number[]} Point */ /** * A segment. * @typedef {Point[2]} Segment */ /** * @param {Point[]} stroke - The points of a stroke. * @param {number} expectedSegmentLength - The expected length of a segment * (usually strokeLength / maxMenuDepth). * @param {number} angleThreshold - The min angle threshold in a point required for it to be * considered an articulation points. * @return {Point[]} The list of articulation points. */ const getStrokeArticulationPoints = (stroke, expectedSegmentLength, angleThreshold) => { const n = stroke.length; if (n === 0) return []; const w = expectedSegmentLength * 0.3; // Add the first point of the stroke. const articulationPoints = [stroke[0]]; let ai = 0; let a = stroke[ai]; while (ai < n) { const ci = findNextPointFurtherThan(stroke, w, { startIndex: ai + 2, refPoint: a }); if (ci < 0) break; const c = stroke[ci]; const labi = findNextPointFurtherThan(stroke, w / 8, { startIndex: ai + 1, refPoint: a }); const lbci = findNextPointFurtherThan(stroke, w / 8, { startIndex: ci - 1, refPoint: c, direction: -1 }); const { index: bi, angle: angleABC } = findMiddlePointForMinAngle(a, stroke[ci], stroke, { startIndex: labi, endIndex: lbci }); if (bi > 0 && Math.abs(180 - angleABC) > angleThreshold) { const b = stroke[bi]; articulationPoints.push(b); a = b; ai = bi; } else { ai += 1; a = stroke[ai]; } } // Add the last point of the stroke. articulationPoints.push(stroke[stroke.length - 1]); return articulationPoints; }; /** * @param {List<List<number>>} stroke - A stroke. * @return {number} The length of the stroke `stroke`. */ var strokeLength = (stroke => stroke.reduce((res, current) => { const prev = res.prev || current; return { prev: current, length: res.length + dist(prev, current) }; }, { length: 0 }).length); /** * @param {Point[]} points - A list of points. * @return {Segment[]} The list of segments joining the points of `points`. */ const pointsToSegments = points => points.slice(1).reduce((_ref, current) => { let { segments, last } = _ref; segments.push([last, current]); return { segments, last: current }; }, { last: points[0], segments: [] }).segments; /** * @param {Item} model - The marking menu model. * @param {{ angle }[]} segments - A list of segments to walk the model. * @param {number} [startIndex=0] - The start index in the angle list. * @return {Item} The corresponding item found by walking the model. */ const walkMMModel = function (model, segments, startIndex) { if (startIndex === void 0) { startIndex = 0; } if (!model || segments.length === 0 || model.isLeaf()) return null; const item = model.getNearestChild(segments[startIndex].angle); if (startIndex + 1 >= segments.length) { return item; } return walkMMModel(item, segments, startIndex + 1); }; const segmentAngle = (a, b) => radiansToDegrees(Math.atan2(b[1] - a[1], b[0] - a[0])); /** * @param {{angle, length}[]} segments - A list of segments. * @return {{angle, length}[]} A new list of segments with the longest segments divided in two. */ const divideLongestSegment = segments => { const [longestI, longest] = findMaxEntry(segments, (s1, s2) => s2.length - s1.length); return [...segments.slice(0, longestI), { length: longest.length / 2, angle: longest.angle }, { length: longest.length / 2, angle: longest.angle }, ...segments.slice(longestI + 1)]; }; /** * @param {Item} model - The marking menu model. * @param {{length, angle}[]} segments - A list of segments. * @param {number} [maxDepth=model.getMaxDepth()] - The maximum depth of the item. * @return {Item} The selected item. */ const findMMItem = function (model, segments, maxDepth) { if (maxDepth === void 0) { maxDepth = model.getMaxDepth(); } // If there is not segments, there is no selection to find. if (!segments.length) return null; // While we haven't found a leaf item, divide the longest segment and walk the model. let currentSegments = segments; let currentItem = null; while (currentSegments.length <= maxDepth) { currentItem = walkMMModel(model, currentSegments); if (currentItem && currentItem.isLeaf()) return currentItem; currentSegments = divideLongestSegment(currentSegments); } return currentItem; }; /** * @param {List.<number[]>} stroke - A list of points. * @param {Item} model - The marking menu model. * @param {object} [options] - Additional options. * @param {number} [maxDepth] - The maximum menu depth to walk. If negative, * start from the maximum depth of the model. * @param {boolean} [requireMenu=false] - Look for a menu item. This * works best with a negative value for maxDepth. * @param {boolean} [requireLeaf=!requireMenu] - Look for a leaf. * @return {Item} The item recognized by the stroke. */ const recognizeMMStroke = function (stroke, model, _temp) { let { maxDepth: maxDepth_ = model.getMaxDepth(), requireMenu = false, requireLeaf = !requireMenu } = _temp === void 0 ? {} : _temp; if (requireLeaf && requireMenu) { throw new Error('The result cannot be both a leaf and a menu'); } const maxDepth = maxDepth_ < 0 ? model.getMaxDepth() + maxDepth_ : maxDepth_; const maxMenuBreadth = model.getMaxBreadth(); const length = strokeLength(stroke); const expectedSegmentLength = length / maxDepth; const sensitivity = 0.75; const angleThreshold = 360 / maxMenuBreadth / 2 / sensitivity; const articulationPoints = getStrokeArticulationPoints(stroke, expectedSegmentLength, angleThreshold); const minSegmentSize = expectedSegmentLength / 3; // Get the segments of the marking menus. const segments = pointsToSegments(articulationPoints) // Change the representation of the segment to include its length. .map(seg => ({ points: seg, length: dist(...seg) })) // Remove the segments that are too small. .filter(seg => seg.length > minSegmentSize) // Change again the representation of the segment to include its length but not its // its points anymore. .map(seg => ({ angle: segmentAngle(...seg.points), length: seg.length })); const item = findMMItem(model, segments, maxDepth); if (requireLeaf) { return item && item.isLeaf() ? item : null; } if (requireMenu) { return item && item.isLeaf() ? item.parent : item; } return item; }; /** * @param {Observable} drag$ - An observable of drag movements. * @param {MMItem} model - The model of the menu. * @param {List<number[]>} initStroke - Initial stroke. * @return {Observable} An observable on the gesture drawing and recognition. */ var expertNavigation = (function (drag$, model, initStroke) { if (initStroke === void 0) { initStroke = []; } // Observable on gesture drawing. const draw$ = draw(drag$, { initStroke, type: 'draw' }).pipe(operators.share()); // Track the end of the drawing and attempt to recognize the gesture. const end$ = draw$.pipe(operators.startWith(null), operators.last(), operators.map(e => { if (!e) return { type: 'cancel' }; const selection = recognizeMMStroke(e.stroke, model); if (selection) { return { ...e, type: 'select', selection }; } return { ...e, type: 'cancel' }; })); return rxjs.merge(draw$, end$); }); const confirmedNoviceNavigationHOO = (drag$, start, model, options) => dwellings(drag$, options.noviceDwellingTime, options.movementsThreshold).pipe(operators.take(1), operators.map(() => (start != null ? rxjs.of(start) : drag$).pipe(operators.take(1), operators.mergeMap(start_ => noviceNavigation( // Same as before, skip the first. drag$.pipe(operators.skip(1)), model, { ...options, menuCenter: start_.position }).pipe(operators.map(n => ({ ...n, mode: 'novice' }))))))); const expertToNoviceSwitchHOO = (drag$, model, initStroke, options) => dwellings(draw(drag$, { initStroke }), options.noviceDwellingTime, options.movementsThreshold).pipe(operators.take(1), operators.map(evt => { // Look for the furthest menu (not leaf). const menu = recognizeMMStroke(evt.stroke, model, { maxDepth: -1, requireMenu: true }); if (!menu || menu.isRoot()) { return rxjs.of({ ...evt, type: 'cancel' }); } // Start a novice navigation from there. return noviceNavigation(drag$.pipe(operators.skip(1)), menu, { ...options, menuCenter: evt.position }); })); const confirmedExpertNavigationHOO = function (drag$, model, _temp) { let { expertToNoviceSwitchHOO: expertToNoviceSwitchHOO_ = expertToNoviceSwitchHOO, ...options } = _temp === void 0 ? {} : _temp; return longMoves(draw(drag$, { type: 'draw' }), options.movementsThreshold).pipe(operators.take(1), operators.map(e => { const expertNav$ = expertNavigation( // Drag always return the last value when observed, in this case we are // not interested in it as it has already been took into account. drag$.pipe(operators.skip(1)), model, e.stroke).pipe(operators.map(n => ({ ...n, mode: 'expert' }))); return expertToNoviceSwitchHOO_(drag$, model, e.stroke, options).pipe(operators.startWith(expertNav$), operators.switchAll()); })); }; const startup = (drag$, model) => expertNavigation(drag$, model).pipe(operators.map((n, i) => i === 0 ? { ...n, type: 'start', mode: 'startup' } : { ...n, mode: 'startup' })); const navigationFromDrag = function (drag$, start, model, options, _temp2) { let { confirmedExpertNavigationHOO: confirmedExpertNavigationHOO_ = confirmedExpertNavigationHOO, confirmedNoviceNavigationHOO: confirmedNoviceNavigationHOO_ = confirmedNoviceNavigationHOO, startup: startup_ = startup } = _temp2 === void 0 ? {} : _temp2; // Start up observable (while neither expert or novice are confirmed). const startUp$ = startup_(drag$, model); // Observable on confirmed expert navigation. const confirmedExpertNavigation$$ = confirmedExpertNavigationHOO_(drag$, model, options); // Observable on confirmed novice navigation. const confirmedNoviceNavigation$$ = confirmedNoviceNavigationHOO_(drag$, start, model, options); // Observable on expert or novice navigation once confirmed. const confirmedNavigation$$ = rxjs.race(confirmedExpertNavigation$$, confirmedNoviceNavigation$$); // Start with startup navigation (similar to expert) but switch to the // confirmed navigation as soon as it is settled. return confirmedNavigation$$.pipe(operators.startWith(startUp$), operators.switchAll()); }; /** * @param {Observable} drags$ - A higher order observable on drag movements. * @param {MMItem} menu - The model of the menu. * @param {object} options - Configuration options (see {@link ../index.js}). * @param {function} [navigationFromDrag_] - function to convert a drags higher * order observable to a navigation * observable. * @return {Observable} An observable on the marking menu events. */ var navigation = (function (drags$, menu, options, navigationFromDrag_) { if (navigationFromDrag_ === void 0) { navigationFromDrag_ = navigationFromDrag; } return drags$.pipe(operators.exhaustMap(drag$ => drag$.pipe(operators.take(1), operators.mergeMap(start => navigationFromDrag_(drag$, start, menu, options))))); }); function styleInject(css, ref) { if ( ref === void 0 ) ref = {}; var insertAt = ref.insertAt; if (!css || typeof document === 'undefined') { return; } var head = document.head || document.getElementsByTagName('head')[0]; var style = document.createElement('style'); style.type = 'text/css'; if (insertAt === 'top') { if (head.firstChild) { head.insertBefore(style, head.firstChild); } else { head.appendChild(style); } } else { head.appendChild(style); } if (style.styleSheet) { style.styleSheet.cssText = css; } else { style.appendChild(document.createTextNode(css)); } } var css_248z = ".marking-menu{--item-width:120px;--item-height:20px;--item-font-size:20px;--item-padding:4px;--item-background:#f2f2f2;--item-color:#333;--active-item-background:#d9d9d9;--active-item-color:#000;--item-radius:calc(var(--item-padding)*2);--menu-radius:80px;--center-radius:10px;--line-thickness:4px;--line-color:var(--item-background);--active-line-color:var(--active-item-background);--center-x:0px;--center-y:0px;left:var(--center-x);pointer-events:none;position:absolute;top:var(--center-y);transform:translate(0)}.marking-menu-item{--angle:0deg;--cosine:1;--sine:0;--item-box-width:calc(var(--item-width) + var(--item-padding)*2);--item-box-height:calc(var(--item-height) + var(--item-padding)*2);position:absolute;z-index:0}.marking-menu-item .marking-menu-label{background-color:var(--item-background);border-radius:var(--item-radius);bottom:calc(var(--sine) * var(--menu-radius) + (min(1, max(-1, var(--sine) * 2)) - 1) * var(--item-box-height) / 2);color:var(--item-color);font-size:var(--item-font-size);height:var(--item-height);left:calc(var(--cosine) * var(--menu-radius) + (min(1, max(-1, var(--cosine) * 2)) - 1) * var(--item-box-width) / 2);line-height:var(--item-height);overflow:hidden;padding:var(--item-padding);position:absolute;text-align:center;text-overflow:ellipsis;vertical-align:middle;width:var(--item-width)}.marking-menu-item .marking-menu-line{background-color:var(--line-color);height:var(--line-thickness);position:absolute;top:calc(var(--line-thickness)/-2);transform:rotate(var(--angle));transform-origin:center left;width:calc(var(--menu-radius) + var(--item-radius) + var(--line-thickness))}.marking-menu-item.active{z-index:1}.marking-menu-item.active .marking-menu-label{background-color:var(--active-item-background);color:var(--active-item-color);font-weight:bolder}.marking-menu-item.active .marking-menu-line{background-color:var(--active-line-color)}.marking-menu-item.bottom-right-item .marking-menu-label{border-top-left-radius:0}.marking-menu-item.bottom-left-item .marking-menu-label{border-top-right-radius:0}.marking-menu-item.top-left-item .marking-menu-label{border-bottom-right-radius:0}.marking-menu-item.top-right-item .marking-menu-label{border-bottom-left-radius:0}"; styleInject(css_248z); const template = (_ref, doc) => { let { items, center } = _ref; const main = doc.createElement('div'); main.className = 'marking-menu'; main.style.setProperty('--center-x', `${center[0]}px`); main.style.setProperty('--center-y', `${center[1]}px`); for (let i = 0; i < items.length; i += 1) { const item = items[i]; const elt = doc.createElement('div'); elt.className = 'marking-menu-item'; elt.dataset.itemId = item.id; elt.style.setProperty('--angle', `${item.angle}deg`); // Identify corner items as these may be styled differently. if (item.angle === 45) { elt.classList.add('bottom-right-item'); } else if (item.angle === 135) { elt.classList.add('bottom-left-item'); } else if (item.angle === 225) { elt.classList.add('top-left-item'); } else if (item.angle === 315) { elt.classList.add('top-right-item'); } const radAngle = degreesToRadians(item.angle); // Why -radAngle? I got the css math wrong at some point, but it works like // this and I could not be bothered fixing it. elt.style.setProperty('--cosine', Math.cos(-radAngle)); elt.style.setProperty('--sine', Math.sin(-radAngle)); elt.innerHTML += '<div class="marking-menu-line"></div>'; elt.innerHTML += `<div class="marking-menu-label">${item.name}</div>`; main.appendChild(elt); } return main; }; /** * Create the Menu display. * @param {HTMLElement} parent - The parent node. * @param {ItemModel} model - The model of the menu to open. * @param {[int, int]} center - The center of the menu. * @param {String} [current] - The currently active item. * @param {Document} [options] - Menu options. * @param {Document} [options.doc=document] - The root document of the menu. * Mostly useful for testing purposes. * @return {{ setActive, remove }} - The menu controls. */ const createMenu = function (parent, model, center, current, _temp) { let { doc = document } = _temp === void 0 ? {} : _temp; // Create the DOM. const main = template({ items: model.children, center }, doc); parent.appendChild(main); // Clear any active items. const clearActiveItems = () => { Array.from(main.querySelectorAll('.active')).forEach(itemDom => itemDom.classList.remove('active')); }; // Return an item DOM element from its id. const getItemDom = itemId => main.querySelector(`.marking-menu-item[data-item-id="${itemId}"]`); // Mark an item as active. const setActive = itemId => { // Clear any active items. clearActiveItems(); // Set the active class. if (itemId || itemId === 0) { getItemDom(itemId).classList.add('active'); } }; // Function to remove the menu. const remove = () => parent.removeChild(main); // Initialize the menu. if (current) setActive(current); // Create the interface. return { setActive, remove }; }; /** * @param {HTMLElement} parent - The parent node. * @param {Document} options - Options. * @param {Document} [options.doc=document] - The root document. Mostly useful for testing purposes. * @param {number} [options.lineWidth=2] - The width of the stroke. * @param {string} [options.lineColor='black'] - CSS representation of the stroke color. * @param {number} [options.startPointRadius=0] - The radius of the start point. * @param {number} [options.startPointColor=options.lineColor] - CSS representation of the start * point color. * @param {number} [options.ptSize=1 / devicePixelRatio] - The size of the canvas points * (px). * @return {{ clear, setStroke, remove }} The canvas methods. */ var createStrokeCanvas = ((parent, _ref) => { let { doc = document, lineWidth = 2, lineColor = 'black', pointRadius = 0, pointColor = lineColor, ptSize = window.devicePixelRatio ? 1 / window.devicePixelRatio : 1 } = _ref; // Create the canvas. const { width, height } = parent.getBoundingClientRect(); const canvas = doc.createElement('canvas'); canvas.width = width / ptSize; canvas.height = height / ptSize; Object.assign(canvas.style, { position: 'absolute', left: 0, top: 0, width: `${width}px`, height: `${height}px`, 'pointer-events': 'none' }); parent.appendChild(canvas); // Get the canvas' context and set it up const ctx = canvas.getContext('2d'); // Scale to the device pixel ratio. ctx.scale(1 / ptSize, 1 / ptSize); /** * @param {number[]} point - Position of the point to draw. * @return {undefined} */ const drawPoint = _ref2 => { let [x, y] = _ref2; ctx.save(); ctx.strokeStyle = 'none'; ctx.fillStyle = pointColor; ctx.beginPath(); ctx.moveTo(x + pointRadius, y); ctx.arc(x, y, pointRadius, 0, 360); ctx.fill(); ctx.restore(); }; /** * Clear the canvas. * * @return {undefined} */ const clear = () => { ctx.clearRect(0, 0, width, height); }; /** * Draw the stroke. * * @param {List<number[]>} stroke - The new stroke. * @return {undefined} */ const drawStroke = stroke => { ctx.save(); ctx.fillStyle = 'none'; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.strokeStyle = lineColor; ctx.lineWidth = lineWidth; ctx.beginPath(); stroke.forEach((point, i) => { if (i === 0) ctx.moveTo(...point);else ctx.lineTo(...point); }); ctx.stroke(); ctx.restore(); }; /** * Destroy the canvas. * @return {undefined} */ const remove = () => { canvas.remove(); }; return { clear, drawStroke, drawPoint, remove }; }); var rafSchd = function rafSchd(fn) { var lastArgs = []; var frameId = null; var wrapperFn = function wrapperFn() { for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } lastArgs = args; if (frameId) { return; } frameId = requestAnimationFrame(function () { frameId = null; fn.apply(void 0, lastArgs); }); }; wrapperFn.cancel = function () { if (!frameId) { return; } cancelAnimationFrame(frameId); frameId = null; }; return wrapperFn; }; var rafThrottle = rafSchd; /** * Connect navigation notifications to menu opening and closing. * * @param {HTMLElement} parentDOM - The element where to append the menu. * @param {Observable} navigation$ - Notifications of the navigation. * @param {Function} createMenuLayout - Menu layout factory. * @param {Function} createUpperStrokeCanvas - Upper stroke canvas factory. The * upper stroke show the user interaction on the current menu, and the movements * in expert mode. * @param {Function} createLowerStrokeCanvas - Lower stroke canvas factory. The * lower stroke is stroke drawn below the menu. It keeps track of the previous * movements. * @param {Function} createGestureFeedback - Create gesture feedback. * @param {{error}} log - Logger. * @return {Observable} `navigation$` with menu opening and closing side effects. */ var connectLayout = ((parentDOM, navigation$, createMenuLayout, createUpperStrokeCanvas, createLowerStrokeCanvas, createGestureFeedback, log) => { // The menu object. let menu = null; // A stroke drawn on top of the menu. let upperStrokeCanvas = null; // A stroke drawn below the menu. let lowerStrokeCanvas = null; // The points of the lower strokes. let lowerStroke = null; // The points of the upper stroke. let upperStroke = null; const gestureFeedback = createGestureFeedback(parentDOM); const closeMenu = () => { menu.remove(); menu = null; }; const openMenu = (model, position) => { const cbr = parentDOM.getBoundingClientRect(); menu = createMenuLayout(parentDOM, model, [position[0] - cbr.left, position[1] - cbr.top]); }; const setActive = id => { menu.setActive(id); }; const startUpperStroke = position => { upperStrokeCanvas = createUpperStrokeCanvas(parentDOM); upperStroke = [position]; upperStrokeCanvas.drawStroke(upperStroke); }; const noviceMove = rafThrottle(position => { if (upperStrokeCanvas) { upperStrokeCanvas.clear(); if (position) { upperStroke = [upperStroke[0], position]; upperStrokeCanvas.drawStroke(upperStroke); } upperStrokeCanvas.drawPoint(upperStroke[0]); } }); const expertDraw = rafThrottle(stroke => { // FIXME: Not very efficient. if (upperStrokeCanvas) { upperStrokeCanvas.clear(); upperStroke = stroke.slice(); upperStrokeCanvas.drawStroke(upperStroke); } }); const clearUpperStroke = () => { upperStrokeCanvas.remove(); upperStrokeCanvas = null; upperStroke = null; }; const swapUpperStroke = () => { lowerStroke = lowerStroke ? [...lowerStroke, ...upperStroke] : upperStroke; clearUpperStroke(); lowerStrokeCanvas = lowerStrokeCanvas || createLowerStrokeCanvas(parentDOM); lowerStrokeCanvas.drawStroke(lowerStroke); }; const clearLowerStroke = () => { if (lowerStrokeCanvas) { lowerStrokeCanvas.remove(); lowerStrokeCanvas = null; } lowerStroke = null; }; const showGestureFeedback = isCanceled => { gestureFeedback.show(lowerStroke ? [...lowerStroke, ...upperStroke] : upperStroke, isCanceled); }; const cleanUp = () => { // Make sure everything is cleaned upon completion. if (menu) closeMenu(); if (upperStrokeCanvas) clearUpperStroke(); if (lowerStrokeCanvas) clearLowerStroke(); gestureFeedback.remove(); // eslint-disable-next-line no-param-reassign parentDOM.style.cursor = ''; }; const onNotification = notification => { switch (notification.type) { case 'open': { // eslint-disable-next-line no-param-reassign parentDOM.style.cursor = 'none'; if (menu) closeMenu(); swapUpperStroke(); openMenu(notification.menu, notification.center); startUpperStroke(notification.center); noviceMove(notification.position); break; } case 'change': { setActive(notification.active && notification.active.id || null); break; } case 'select': case 'cancel': // eslint-disable-next-line no-param-reassign parentDOM.style.cursor = ''; if (menu) closeMenu(); showGestureFeedback(notification.type === 'cancel'); clearUpperStroke(); clearLowerStroke(); break; case 'start': // eslint-disable-next-line no-param-reassign parentDOM.style.cursor = 'crosshair'; startUpperStroke(notification.position); break; case 'draw': expertDraw(notification.stroke); break; case 'move': noviceMove(notification.position); break; default: throw new Error(`Invalid navigation notification type: ${notification.type}`); } }; return navigation$.pipe(operators.tap({ next(notification) { try { onNotification(notification); } catch (e) { log.error(e); throw e; } }, error(e) { log.error(e); throw e; } }), operators.finalize(() => { try { cleanUp(); } catch (e) { log.error(e); throw e; } })); }); var createGestureFeedback = ((parentDOM, _ref) => { let { duration, strokeOptions = {}, canceledStrokeOptions = {} } = _ref; let strokeTimeoutEntries = []; const show = function (stroke, isCanceled) { if (isCanceled === void 0) { isCanceled = false; } const canvas = createStrokeCanvas(parentDOM, { ...strokeOptions, ...(isCanceled ? canceledStrokeOptions : {}) }); canvas.drawStroke(stroke); const timeoutEntry = { canvas, timeout: setTimeout(() => { // Remove the entry from the strokeTimeoutEntries. strokeTimeoutEntries = strokeTimeoutEntries.filter(x => x !== timeoutEntry); // Clear the stroke canvas. canvas.remove(); }, duration) }; strokeTimeoutEntries.push(timeoutEntry); }; const remove = () => { strokeTimeoutEntries.forEach(_ref2 => { let { timeout, canvas } = _ref2; clearTimeout(timeout); canvas.remove(); }); strokeTimeoutEntries = []; }; return { show, remove }; }); const getAngleRange = items => items.length > 4 ? 45 : 90; /** * Represents an item of the Marking Menu. */ class MMItem { /** * @param {String} id - The item's id. Required except for the root item. * @param {String} name - The item's name. Required except for the root item. * @param {Integer} angle - The item's angle. Required except for the root item. * @param {object} [options] - Some additional options. * @param {ItemModel} [options.parent] - The parent menu of the item. * @param {List<ItemModel>} [options.children] - The children of the item menu. */ constructor(id, name, angle, _temp) { let { parent, children } = _temp === void 0 ? {} : _temp; this.id = id; this.name = name; this.angle = angle; this.children = children; this.parent = parent; } isLeaf() { return !this.children || this.children.length === 0; } isRoot() { return !this.parent; } /** * @param {String} childId - The identifier of the child to look for. * @return {Item} the children with the id `childId`. */ getChild(childId) { return this.children.find(child => child.id === childId); } /** * @param {String} childName - The name of the children to look for. * @return {Item} the children with the name `childName`. */ getChildrenByName(childName) { return this.children.filter(child => child.name === childName); } /** * @param {Integer} angle - An angle. * @return {Item} the closest children to the angle `angle`. */ getNearestChild(angle) { return this.children.reduce((c1, c2) => { const delta1 = Math.abs(deltaAngle(c1.angle, angle)); const delta2 = Math.abs(deltaAngle(c2.angle, angle)); return delta1 > delta2 ? c2 : c1; }); } /** * @return {number} The maximum depth of the menu. */ getMaxDepth() { return this.isLeaf() ? 0 : Math.max(0, ...this.children.map(child => child.getMaxDepth())) + 1; } /** * @return {number} The maximum breadth of the menu. */ getMaxBreadth() { return this.isLeaf() ? 0 : Math.max(this.children.length, ...this.children.map(child => child.getMaxBreadth())); } } // Create the model item from a list of items. const recursivelyCreateModelItems = function (items, baseId, parent) { if (baseId === void 0) { baseId = undefined; } if (parent === void 0) { parent = undefined; } // Calculate the angle range for each items. const angleRange = getAngleRange(items); // Create the list of model items (frozen). return Object.freeze(items.map((item, i) => { // Standard item id. const stdId = baseId ? [baseId, i].join('-') : i.toString(); // Create the item. const mmItem = new MMItem(item.id == null ? stdId : item.id, typeof item === 'string' ? item : item.name, i * angleRange, { parent }); // Add its children if any. if (item.children) { mmItem.children = recursivelyCreateModelItems(item.children, stdId, mmItem); } // Return it frozen. return Object.freeze(mmItem); })); }; /** * Create the marking menu model. * * @param {List<String|{name,children}>} itemList - The list of items. * @return {MMItem} - The root item of the model. */ const createModel = itemList => { const menu = new MMItem(null, null, null); menu.children = recursivelyCreateModelItems(itemList, undefined, menu); return Object.freeze(menu); }; const exportNotification = n => ({ type: n.type, mode: n.mode, position: n.position ? n.position.slice() : undefined, active: n.active, selection: n.selection, menuCenter: n.center ? n.center.slice() : undefined, timeStamp: n.timeStamp }); /** * Create a Marking Menu. * * @param {List<String|{name,children}>} items - The list of items. * @param {HTMLElement} parentDOM - The parent node. * @param {Object} [options] - Configuration options for the menu. * @param {number} [options.minSelectionDist] - The minimum distance from the center to select an * item. * @param {number} [options.minMenuSelectionDist] - The minimum distance from the center to open a * sub-menu. * @param {number} [options.subMenuOpeningDelay] - The dwelling delay before opening a sub-menu. * @param {number} [options.movementsThreshold] - The minimum distance between two points to be * considered a significant movements and breaking * the sub-menu dwelling delay. * @param {number} [options.noviceDwellingTime] - The dwelling time required to trigger the novice * mode (and open the menu). * @param {number} [options.strokeColor] - The color of the gesture stroke. * @param {number} [options.strokeWidth] - The width of the gesture stroke. * @param {number} [options.strokeStartPointRadius] - The radius of the start point of the stroke * (appearing at the middle of the menu in