Skip to content

Commit

Permalink
Allow marker thumbnails outside of edit mode
Browse files Browse the repository at this point in the history
Previously, the only way to see thumbnails of an existing marker was to enter
edit mode. With this change, a thumbnail toggle is added to existing markers'
options, which, like edit mode, will show/hide the thumbnails for that marker.

Core changes:
* Split out the logic that handles marker edit thumbnail previews and the
  preview toggle into its own class to be shared by other components.
* Use said class to add static thumbnails to existing marker rows, initially
  collapsed, but toggled via the new image icon.
* Dynamically adjust the width of static thumbnails when the window resizes to
  avoid horizontal scrolling as much as possible.
* Generalize longpress handling to allow anyone to set up a longpress listener.
* Using the generalized longpress handling, allow a long press to show/hide all
  static thumbnails in a marker table.
* Similarly, Ctrl+Click to show/hide all thumbnails in the table, and
  Ctrl+Shift+Click to show/hide thumbnails in all unhidden marker rows.

Minor changes:
* Add window resize listeners to marker rows to adjust more elements.
* Before entering an edit session, make sure any static thumbnails are hidden.
* Adjust TableElements.timeData to account for potential thumbnail elements.
* Adjust options column width based on whether thumbnails are enabled.
* Adjust how display height is caculated/set, including removing the
  DisplayHeight attribute, as everything can be calculated based on the
  thumbnail's naturalWidth/Height.

Tangential changes:
* When animating images, use the height attribute directly if the style height.
  isn't set. Also use the height attribute when resetting after animating.
* Allow additional events to be set on buttons.
* Export _buildNode's event attachment logic so it can be shared with
  ButtonCreator's attribute parser.
* Refactor result row window resize listeners to ensure all relevant elements
  get notified as expected.
* Make sure smallScreenCached is set correctly on load.
* Don't launch purge overlay if the click target was the marker info icon.
* Adjust padding of topAlignedPlainText
* More flex tweaks to keep marker info on a single row in season results.
  • Loading branch information
danrahn committed Apr 20, 2024
1 parent 7f4f44c commit cf9d755
Show file tree
Hide file tree
Showing 22 changed files with 977 additions and 317 deletions.
27 changes: 22 additions & 5 deletions Client/Script/AnimationHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,16 @@ export function animateOpacity(ele, start, end, options, callback) {
* @param {HTMLElement} ele
* @param {string} prop */
function checkProp(ele, prop) {
const sav = ele.style[prop];
let sav = ele.style[prop];
if ((ele instanceof HTMLImageElement) && prop in ['width', 'height']) {
// For images, use the width/height property directly if
// the style isn't set, but make sure it ends with 'px'.
sav ||= ele[prop];
if (sav === 'number') {
sav += 'px';
}
}

prop = Attributes.PropReset(prop);
const isAnimating = ele.getAttribute(prop);
if (isAnimating !== null) {
Expand Down Expand Up @@ -152,10 +161,11 @@ export function slideUp(ele, options, callback) {
[{ opacity : 1, height : startingHeight }, { opacity : 0, height : '0px' }],
options,
async () => {
if (options.noReset) {
ele.style.height = '0px';
if (ele instanceof HTMLImageElement) {
ele.height = options.noReset ? 0 : heightSav;
ele.style.removeProperty('height');
} else {
ele.style.height = heightSav;
ele.style.height = options.noReset ? '0px' : heightSav;
}

removeProp(ele, 'height');
Expand All @@ -182,7 +192,14 @@ export function slideDown(ele, finalHeight, options, callback) {
if (!hasHeight && !options.noReset) {
ele.style.removeProperty('height');
} else if (options.noReset) {
ele.style.height = finalHeight;
// For images, remove the style height and
// set the height attribute directly.
if (ele instanceof HTMLImageElement) {
ele.height = parseInt(finalHeight);
ele.style.removeProperty('height');
} else {
ele.style.height = finalHeight;
}
}

removeProp(ele, 'height');
Expand Down
10 changes: 7 additions & 3 deletions Client/Script/ButtonCreator.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { $, $$, appendChildren, buildNode } from './Common.js';
import { $, $$, addEventsToElement, appendChildren, buildNode } from './Common.js';
import { ContextualLog } from '/Shared/ConsoleLog.js';

import { addWindowResizedListener, isSmallScreen } from './WindowResizeEventHandler.js';
Expand Down Expand Up @@ -128,9 +128,10 @@ class ButtonCreator {
* @param {HTMLElement} button
* @param {string} newText */
static setText(button, newText) {
// No-op if this isn't a text button
const span = $$('.buttonText', button);
if (!span) {
Log.warn('Called setText on non-text button!');
return;
}

span.innerText = newText;
Expand Down Expand Up @@ -192,7 +193,10 @@ class ButtonCreator {
}
} else if (attribute === 'tooltip') {
Tooltip.setTooltip(button, value);
} else if (attribute === 'auxclick') {
} else if (attribute === 'events') {
// Extra events outside of the standard click event
addEventsToElement(button, value);
} else if (attribute === 'auxclick' && value) {
button.addEventListener('auxclick', (e) => {
// Disabled buttons don't do anything.
if (!button.classList.contains('disabled')) {
Expand Down
58 changes: 43 additions & 15 deletions Client/Script/Common.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { addLongPressListener } from './LongPressHandler.js';

/** @typedef {(target: EventTarget) => void} CustomEventCallback */

/**
* Removes all children from the given element.
Expand Down Expand Up @@ -71,21 +74,7 @@ function _buildNode(ele, attrs, content, events, options) {
}

if (events) {
for (const [event, func] of Object.entries(events)) {
/** @type {EventListener[]} */
let handlers = func;
if (!(func instanceof Array)) {
handlers = [func];
}

for (const handler of handlers) {
if (options.thisArg) {
ele.addEventListener(event, handler.bind(options.thisArg, ele));
} else {
ele.addEventListener(event, handler);
}
}
}
addEventsToElement(ele, events, options.thisArg);
}

if (content) {
Expand All @@ -99,6 +88,44 @@ function _buildNode(ele, attrs, content, events, options) {
return ele;
}

/**
* Map of existing custom events that have additional setup via the specified method.
* @type {{ [event: string]: (ele: HTMLElement, (target: HTMLElement) => void) => void }} */
const CustomEvents = {
longpress : addLongPressListener,
};

/**
* Attach all specified events to the given element, with some custom handling around non-standard events.
* @param {Element} element The element to add events to
* @param {{[event: string]: EventListener|EventListener[]}} [events] Map of events (click/keyup/etc) to attach to the element.
* @param {Element?} thisArg */
function addEventsToElement(element, events, thisArg=null) {
for (const [event, func] of Object.entries(events)) {
/** @type {EventListener[]} */
let handlers = func;
if (!(func instanceof Array)) {
handlers = [func];
}

const customEvent = CustomEvents[event];

for (const handler of handlers) {
if (customEvent) {
if (thisArg) {
customEvent(element, handler.bind(thisArg, element));
} else {
customEvent(element, handler);
}
} else if (thisArg) {
element.addEventListener(event, handler.bind(thisArg, element));
} else {
element.addEventListener(event, handler);
}
}
}
}

/**
* Helper to append multiple children to a single element at once.
* @param {HTMLElement} parent Parent element to append children to.
Expand Down Expand Up @@ -401,6 +428,7 @@ function scrollAndFocus(e, scrollTarget, focusTarget) {
export {
$,
$$,
addEventsToElement,
appendChildren,
buildNode,
buildNodeNS,
Expand Down
2 changes: 0 additions & 2 deletions Client/Script/DataAttributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ export const Attributes = {
TooltipText : 'data-tt',
/** @readonly Time in ms to delay showing a tooltip after it gets focus/hovered. */
TooltipDelay : 'data-tt-delay',
/** @readonly Indicates the expected full height of a thumbnail. */
DisplayHeight : `data-display-height`,
/** @readonly Indicates whether the overlay can be dismissed by the user. */
OverlayDismissible : 'data-dismissible',
/** @readonly Library type of a library in the selection dropdown. */
Expand Down
106 changes: 106 additions & 0 deletions Client/Script/LongPressHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@

/** @typedef {!import('./Common').CustomEventCallback} CustomEventCallback */

/**
* Data to keep track of touch events.
*/
class TouchData {
/** @type {EventTarget} */
target = null;
/** The screen x/y during the first touch */
startCoords = { x : 0, y : 0 };
/** The current touch coordinates, updated after every touchmove. */
currentCoords = { x : 0, y : 0 };
/** Timer set after a touchstart */
timer = 0;
/** Clear out any existing touch data. */
clear() {
this.target = null;
this.startCoords = { x : 0, y : 0 };
this.currentCoords = { x : 0, y : 0 };
if (this.timer) {
clearTimeout(this.timer);
}
}
}

/** List of events we want to listen to. */
const events = [ 'touchstart', 'touchmove', 'touchend' ];

/**
* Handles a single element's "longpress" listener, triggering a callback if
* the user has a single press for one second.
*/
class LongPressHandler {
/** @type {TouchData} */
#touches;

/** @type {CustomEventCallback} */
#callback;

/**
* @param {HTMLElement} element
* @param {CustomEventCallback} callback */
constructor(element, callback) {
this.#callback = callback;
this.#touches = new TouchData();
for (const event of events) {
element.addEventListener(event, this.#handleTouch.bind(this), { passive : true });
}
}

/**
* @param {TouchEvent} e */
#handleTouch(e) {
switch (e.type) {
default:
return;
case 'touchstart':
if (e.touches.length !== 1) {
this.#touches.clear();
return;
}

this.#touches.target = e.target;
this.#touches.startCoords = { x : e.touches[0].clientX, y : e.touches[0].clientY };
this.#touches.currentCoords = { x : e.touches[0].clientX, y : e.touches[0].clientY };
this.#touches.timer = setTimeout(this.#checkCurrentTouch.bind(this), 1000);
break;
case 'touchmove':
if (!this.#touches.timer || e.touches.length !== 1) {
this.#touches.clear();
return;
}

this.#touches.currentCoords = { x : e.touches[0].clientX, y : e.touches[0].clientY };
break;
case 'touchend':
this.#touches.clear();
break;
}
}

/**
* Triggered one second after the first touch, if touchend hasn't been fired.
* If our final touch point isn't too far away from the initial point, trigger the callback. */
#checkCurrentTouch() {
const diffX = Math.abs(this.#touches.currentCoords.x - this.#touches.startCoords.x);
const diffY = Math.abs(this.#touches.currentCoords.y - this.#touches.startCoords.y);

// Allow a bit more horizontal leeway than vertical.
if (diffX < 20 && diffY < 10) {
this.#callback(this.#touches.target);
}

this.#touches.clear();
}
}

/**
* Add a "longpress" listener to the given element, triggering the callback if a
* single touch lasts for one second and hasn't moved from the original touch point.
* @param {HTMLElement} element
* @param {CustomEventCallback} callback */
export function addLongPressListener(element, callback) {
new LongPressHandler(element, callback);
}

0 comments on commit cf9d755

Please sign in to comment.