zulip/web/src/hotspots.js

337 lines
9.2 KiB
JavaScript
Raw Normal View History

import $ from "jquery";
import _ from "lodash";
2023-02-02 21:22:40 +01:00
import whale_image from "../images/hotspots/whale.svg";
import render_hotspot_icon from "../templates/hotspot_icon.hbs";
import render_hotspot_overlay from "../templates/hotspot_overlay.hbs";
import * as blueslip from "./blueslip";
import * as channel from "./channel";
import * as overlays from "./overlays";
import {page_params} from "./page_params";
import * as popovers from "./popovers";
// popover orientations
const TOP = "top";
const LEFT = "left";
const RIGHT = "right";
const BOTTOM = "bottom";
const LEFT_BOTTOM = "left_bottom";
const VIEWPORT_CENTER = "viewport_center";
// popover orientation can optionally be fixed here (property: popover),
// otherwise popovers.compute_placement is used to compute orientation
const HOTSPOT_LOCATIONS = new Map([
[
"intro_streams",
{
element: "#streams_header .sidebar-title",
offset_x: 1.35,
offset_y: 0.39,
},
],
[
"intro_topics",
{
element: ".topic-name",
offset_x: 0.8,
offset_y: 0.39,
},
],
[
"intro_gear",
{
element: "#settings-dropdown",
offset_x: -0.4,
offset_y: 1.2,
popover: LEFT_BOTTOM,
},
],
[
"intro_compose",
{
element: "#left_bar_compose_stream_button_big",
offset_x: 0,
offset_y: 0,
},
],
]);
const meta = {
opened_hotspot_name: null,
};
export function post_hotspot_as_read(hotspot_name) {
channel.post({
url: "/json/users/me/hotspots",
data: {hotspot: hotspot_name},
error(err) {
blueslip.error("Failed to fetch hotspots", {
status: err.status,
body: err.responseText,
});
2017-07-14 03:21:30 +02:00
},
});
}
2017-07-14 03:21:30 +02:00
function place_icon(hotspot) {
const $element = $(hotspot.location.element);
const $icon = $(`#hotspot_${CSS.escape(hotspot.name)}_icon`);
if (
$element.length === 0 ||
$element.css("display") === "none" ||
!$element.is(":visible") ||
$element.is(":hidden")
) {
$icon.css("display", "none");
return false;
2017-07-14 03:21:30 +02:00
}
const offset = {
top: $element.outerHeight() * hotspot.location.offset_y,
left: $element.outerWidth() * hotspot.location.offset_x,
};
const client_rect = $element.get(0).getBoundingClientRect();
const placement = {
2017-07-14 03:21:30 +02:00
top: client_rect.top + offset.top,
left: client_rect.left + offset.left,
};
$icon.css("display", "block");
$icon.css(placement);
return true;
}
2017-07-14 03:21:30 +02:00
function place_popover(hotspot) {
if (!hotspot.location.element) {
return;
}
const popover_width = $(
`#hotspot_${CSS.escape(hotspot.name)}_overlay .hotspot-popover`,
).outerWidth();
const popover_height = $(
`#hotspot_${CSS.escape(hotspot.name)}_overlay .hotspot-popover`,
).outerHeight();
const el_width = $(hotspot.location.element).outerWidth();
const el_height = $(hotspot.location.element).outerHeight();
const arrow_offset = 20;
2017-07-14 03:21:30 +02:00
let popover_offset;
let arrow_placement;
const orientation =
hotspot.location.popover ||
popovers.compute_placement(
$(hotspot.location.element),
popover_height,
popover_width,
false,
);
switch (orientation) {
case TOP:
popover_offset = {
top: -(popover_height + arrow_offset),
left: el_width / 2 - popover_width / 2,
};
arrow_placement = "bottom";
break;
case LEFT:
popover_offset = {
top: el_height / 2 - popover_height / 2,
left: -(popover_width + arrow_offset),
};
arrow_placement = "right";
break;
case BOTTOM:
popover_offset = {
top: el_height + arrow_offset,
left: el_width / 2 - popover_width / 2,
};
arrow_placement = "top";
break;
case RIGHT:
popover_offset = {
top: el_height / 2 - popover_height / 2,
left: el_width + arrow_offset,
};
arrow_placement = "left";
break;
case LEFT_BOTTOM:
popover_offset = {
top: 0,
left: -(popover_width + arrow_offset / 2),
};
arrow_placement = "";
break;
case VIEWPORT_CENTER:
popover_offset = {
top: el_height / 2,
left: el_width / 2,
};
arrow_placement = "";
break;
default:
blueslip.error("Invalid popover placement value for hotspot", {name: hotspot.name});
break;
2017-07-14 03:21:30 +02:00
}
// position arrow
arrow_placement = "arrow-" + arrow_placement;
$(`#hotspot_${CSS.escape(hotspot.name)}_overlay .hotspot-popover`)
.removeClass("arrow-top arrow-left arrow-bottom arrow-right")
2017-07-14 03:21:30 +02:00
.addClass(arrow_placement);
// position popover
let popover_placement;
if (orientation === VIEWPORT_CENTER) {
popover_placement = {
top: "45%",
left: "50%",
transform: "translate(-50%, -50%)",
};
} else {
const client_rect = $(hotspot.location.element).get(0).getBoundingClientRect();
popover_placement = {
top: client_rect.top + popover_offset.top,
left: client_rect.left + popover_offset.left,
transform: "",
};
}
2017-07-14 03:21:30 +02:00
$(`#hotspot_${CSS.escape(hotspot.name)}_overlay .hotspot-popover`).css(popover_placement);
2017-07-14 03:21:30 +02:00
}
function insert_hotspot_into_DOM(hotspot) {
const hotspot_overlay_HTML = render_hotspot_overlay({
2017-07-14 03:21:30 +02:00
name: hotspot.name,
title: hotspot.title,
description: hotspot.description,
2023-02-02 21:22:40 +01:00
img: whale_image,
});
2017-07-14 03:21:30 +02:00
const hotspot_icon_HTML = render_hotspot_icon({
name: hotspot.name,
});
2017-07-14 03:21:30 +02:00
setTimeout(() => {
$("body").prepend(hotspot_icon_HTML);
$("body").prepend(hotspot_overlay_HTML);
if (place_icon(hotspot)) {
place_popover(hotspot);
}
2017-07-14 03:21:30 +02:00
// reposition on any event that might update the UI
for (const event_name of ["resize", "scroll", "onkeydown", "click"]) {
window.addEventListener(
event_name,
_.debounce(() => {
if (place_icon(hotspot)) {
place_popover(hotspot);
}
}, 10),
true,
);
}
}, hotspot.delay * 1000);
2017-07-14 03:21:30 +02:00
}
export function is_open() {
return meta.opened_hotspot_name !== null;
}
export function close_hotspot_icon(elem) {
$(elem).animate(
{opacity: 0},
{
duration: 300,
done: function () {
$(elem).css({display: "none"});
}.bind(elem),
},
);
}
function close_read_hotspots(new_hotspots) {
const unwanted_hotspots = _.difference(
[...HOTSPOT_LOCATIONS.keys()],
new_hotspots.map((hotspot) => hotspot.name),
);
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
for (const hotspot_name of unwanted_hotspots) {
close_hotspot_icon($(`#hotspot_${CSS.escape(hotspot_name)}_icon`));
js: Automatically convert _.each to for…of. This commit was automatically generated by the following script, followed by lint --fix and a few small manual lint-related cleanups. import * as babelParser from "recast/parsers/babel"; import * as recast from "recast"; import * as tsParser from "recast/parsers/typescript"; import { builders as b, namedTypes as n } from "ast-types"; import { Context } from "ast-types/lib/path-visitor"; import K from "ast-types/gen/kinds"; import { NodePath } from "ast-types/lib/node-path"; import assert from "assert"; import fs from "fs"; import path from "path"; import process from "process"; const checkExpression = (node: n.Node): node is K.ExpressionKind => n.Expression.check(node); const checkStatement = (node: n.Node): node is K.StatementKind => n.Statement.check(node); for (const file of process.argv.slice(2)) { console.log("Parsing", file); const ast = recast.parse(fs.readFileSync(file, { encoding: "utf8" }), { parser: path.extname(file) === ".ts" ? tsParser : babelParser, }); let changed = false; let inLoop = false; let replaceReturn = false; const visitLoop = (...args: string[]) => function(this: Context, path: NodePath) { for (const arg of args) { this.visit(path.get(arg)); } const old = { inLoop }; inLoop = true; this.visit(path.get("body")); inLoop = old.inLoop; return false; }; recast.visit(ast, { visitDoWhileStatement: visitLoop("test"), visitExpressionStatement(path) { const { expression, comments } = path.node; let valueOnly; if ( n.CallExpression.check(expression) && n.MemberExpression.check(expression.callee) && !expression.callee.computed && n.Identifier.check(expression.callee.object) && expression.callee.object.name === "_" && n.Identifier.check(expression.callee.property) && ["each", "forEach"].includes(expression.callee.property.name) && [2, 3].includes(expression.arguments.length) && checkExpression(expression.arguments[0]) && (n.FunctionExpression.check(expression.arguments[1]) || n.ArrowFunctionExpression.check(expression.arguments[1])) && [1, 2].includes(expression.arguments[1].params.length) && n.Identifier.check(expression.arguments[1].params[0]) && ((valueOnly = expression.arguments[1].params[1] === undefined) || n.Identifier.check(expression.arguments[1].params[1])) && (expression.arguments[2] === undefined || n.ThisExpression.check(expression.arguments[2])) ) { const old = { inLoop, replaceReturn }; inLoop = false; replaceReturn = true; this.visit( path .get("expression") .get("arguments") .get(1) .get("body") ); inLoop = old.inLoop; replaceReturn = old.replaceReturn; const [right, { body, params }] = expression.arguments; const loop = b.forOfStatement( b.variableDeclaration("let", [ b.variableDeclarator( valueOnly ? params[0] : b.arrayPattern([params[1], params[0]]) ), ]), valueOnly ? right : b.callExpression( b.memberExpression(right, b.identifier("entries")), [] ), checkStatement(body) ? body : b.expressionStatement(body) ); loop.comments = comments; path.replace(loop); changed = true; } this.traverse(path); }, visitForStatement: visitLoop("init", "test", "update"), visitForInStatement: visitLoop("left", "right"), visitForOfStatement: visitLoop("left", "right"), visitFunction(path) { this.visit(path.get("params")); const old = { replaceReturn }; replaceReturn = false; this.visit(path.get("body")); replaceReturn = old.replaceReturn; return false; }, visitReturnStatement(path) { if (replaceReturn) { assert(!inLoop); // could use labeled continue if this ever fires const { argument, comments } = path.node; if (argument === null) { const s = b.continueStatement(); s.comments = comments; path.replace(s); } else { const s = b.expressionStatement(argument); s.comments = comments; path.replace(s, b.continueStatement()); } return false; } this.traverse(path); }, visitWhileStatement: visitLoop("test"), }); if (changed) { console.log("Writing", file); fs.writeFileSync(file, recast.print(ast).code, { encoding: "utf8" }); } } Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-06 06:19:47 +01:00
}
}
export function load_new(new_hotspots) {
close_read_hotspots(new_hotspots);
for (const hotspot of new_hotspots) {
hotspot.location = HOTSPOT_LOCATIONS.get(hotspot.name);
insert_hotspot_into_DOM(hotspot);
}
}
export function initialize() {
load_new(page_params.hotspots);
// open
$("body").on("click", ".hotspot-icon", function (e) {
// hide icon
close_hotspot_icon(this);
// show popover
const [, hotspot_name] = /^hotspot_(.*)_icon$/.exec(
$(e.target).closest(".hotspot-icon").attr("id"),
);
const overlay_name = "hotspot_" + hotspot_name + "_overlay";
overlays.open_overlay({
name: overlay_name,
$overlay: $(`#${CSS.escape(overlay_name)}`),
on_close: function () {
// close popover
$(this).css({display: "block"});
$(this).animate(
{opacity: 1},
{
duration: 300,
},
);
meta.opened_hotspot_name = null;
}.bind(this),
});
meta.opened_hotspot_name = hotspot_name;
e.preventDefault();
e.stopPropagation();
});
// confirm
$("body").on("click", ".hotspot.overlay .hotspot-confirm", function (e) {
e.preventDefault();
e.stopPropagation();
const overlay_name = $(this).closest(".hotspot.overlay").attr("id");
const [, hotspot_name] = /^hotspot_(.*)_overlay$/.exec(overlay_name);
// Comment below to disable marking hotspots as read in production
post_hotspot_as_read(hotspot_name);
overlays.close_overlay(overlay_name);
$(`#hotspot_${CSS.escape(hotspot_name)}_icon`).remove();
});
// stop propagation
$("body").on("click", ".hotspot.overlay .hotspot-popover", (e) => {
e.stopPropagation();
});
}