Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/lld/06-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ What the parts do, together:

- **Layout.** Split panes (split.js), Maximize (`ToggleFunction`, Esc restores; an icon button, the first one in the app bar, before Files), Genie pinned beside the IDE (below), Stacked or Side by side (`ToggleRotateEditor`), Hide/Show editor (`ToggleEditor` + `syncEditorLayoutUI`), and the phone layout (`applyMobileLayout`).
- **Genie pinned beside the IDE** (`chat-widget/src/index.ts` "pinned beside the IDE", `02-layout.js`, `#genie-dock` in `index.html`). A pin button in the panel's header (between Peer chat and ×) docks the panel as a column at the right of the workspace row, after the editor/terminal split, like a side bar. The panel element is moved into `#genie-dock` (class `is-docked`, which drops the floating position, size and shadow); a grip on the dock's left edge resizes it (drag, arrow keys, double click for the default 380px; 300px to 680px and at most 60% of the row; `--genie-dock-w`, remembered in `localStorage` `genie-dock-width`). The pin itself is `localStorage` `genie-pinned`. While pinned, the widget fires `genie-pin` ({pinned}) on `window` and `02-layout.js` stacks the editor over the terminal (`SetEditorDirection`; a hidden editor stays hidden); unpinning puts the layout back unless the user chose one meanwhile. × hides a pinned panel and keeps the pin; Ask Genie and Ctrl+K bring it back docked. The dock is inside `#ide-shell`, so Maximize keeps it. Below 1100px or on a phone the pin button is hidden and the panel floats as before (the choice is kept, and the panel docks again when the window is wide enough). The widget tells the page to refit (a `resize` event, at most one a frame) only when the dock comes, goes or changes width: `setDockWidth` runs on every window resize, and firing one from there each time is a loop that refits the editor on every frame and closes the editor's right-click menu, which dismisses itself on a resize. A size the floating panel was dragged to does not apply in the dock (`!important`), and its corner handle is hidden there. At 800px and below the Maximize button is hidden (the phone app bar has no room); the command palette still has it.
- **Button tips** (`js/src/page/20-tips.js`, `lib-tips.mjs`, `.ide-tip` in `css/ui-refresh.css`). Hovering a button of the workspace (`#ide-shell`), of the Genie panel or the floating Ask Genie button shows a small light pane by it: the button's name, its shortcut as key caps and one line. It replaces the browser's tooltip and is made from what the button already has: `title` is the line, `data-tip-name` the name (else the button's visible words or `aria-label`), `data-tip-keys` the shortcut (`Mod` is Ctrl, or ⌘ on a Mac) and `data-tip-desc` a line written for the tip. Four buttons change with their state (Maximize or Restore, the layout switch, Pin or Unpin, Send or Stop): the `NAMES` table reads the state, so no other code keeps a tip current. While the mouse is on a button its `title` is moved to `data-tip-title`, so that the browser's tooltip does not show on top, and moved back when the mouse leaves; a title set meanwhile wins. Code that removes a title must remove `data-tip-title` too (the send button's `showAgentState` does). The pane shows 350 ms after the mouse arrives, at once when another tip was just showing, under the button or above it when there is no room, and never outside the window (`placeTip`). A press, a key, a scroll or a resize puts it away. A button reached with Tab shows its tip too (`:focus-visible`). There are no tips for a touch pointer, during the tour, for a button whose menu is open (`aria-haspopup` with `aria-expanded`), or inside the editor, the terminal and the file tree. The pane takes no pointer events and sits above the Genie panel and below the menus and the palette (`z-index` 10030). A page that loads the widget without `scribbler.js` keeps the browser's own tooltips.
- **Editor.** Ace setup, theme persistence, language mode from `data-editor`, `updateEditorContent`, download and upload. `defineOpenreplAceTheme()` registers the default "OpenREPL Dark" Ace theme (`ace/theme/openrepl_dark`, the mockup's colours); every other Ace theme stays in `#select-theme`. The terminal uses the same 14px size as the editor (set in `js/src/xterm.ts`).
- **Language switch.** `actionOnchange` fetches `/demo?q=` and renders the demo animation, usage table and links (LLD 04).
- **Run and debug.** `CompileandRun`, `RunandDebug` dispatch `optionrun` and `optiondebug`. `ToggleReconnect` reconnects.
Expand Down
247 changes: 247 additions & 0 deletions src/js/src/page/20-tips.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
// Tips for the buttons of the workspace and of the Genie panel: a small pane by
// the button with its name, its shortcut and one line about it. Part of the page
// script (T20), bundled into js/scribbler.js.
//
// A tip is made from what the button already has:
// title the line about it (the browser's own tooltip, which this replaces)
// data-tip-name its name; without one, its visible words or its aria-label
// data-tip-keys its shortcut ("Mod Enter": Mod is Ctrl, or ⌘ on a Mac)
// data-tip-desc a line written for the tip, where the title is not one
// A few buttons change with their state (Maximize or Restore, Pin or Unpin,
// Send or Stop): NAMES below reads the state, so nothing else has to keep the
// tip up to date.
//
// Nothing here changes what a button does. While the mouse is on a button its
// title is kept in data-tip-title, so that the browser does not show its own
// tooltip on top, and put back when the mouse leaves. Code that REMOVES a title
// (the Genie panel's send button does) must remove data-tip-title with it, or
// the old one would come back.
//
// Tips are for a mouse and for the keyboard (a button reached with Tab). There
// are none on a touch screen, during the tour, or for a button whose menu is open.

import { tipContent, placeTip } from "./lib-tips.mjs";

const SCOPE = "#ide-shell, #chat-widget__container, .genie-fab";
const CANDIDATE = "[title], [data-tip-title], [data-tip-name], [data-tip-desc]";
// not the editor, the terminal or the file tree: their own titles stay as they are
const NOT_IN = "#editor, .terminal, #file-browser, .genie-menu, .ctx-menu";
const SHOW_AFTER_MS = 350;
const QUICK_WITHIN_MS = 400; // from one button to the next, the tip follows at once

const NAMES = {
"togglescreen-button": function (el) {
const on = el.getAttribute("aria-pressed") === "true";
return on
? { name: "Restore", keys: "Esc", desc: "Back to the normal size." }
: { name: "Maximize", keys: "", desc: "Fill the window with the workspace. Esc restores it." };
},
"rotate-button": function (el) {
return { name: visibleWords(el) || "Layout" };
},
"chat-widget__pin": function (el) {
return { name: el.getAttribute("aria-pressed") === "true" ? "Unpin" : "Pin" };
},
"chat-widget__submit": function (el) {
return el.classList.contains("is-stop")
? { name: "Stop", keys: "", desc: "Stop the task Genie is working on." }
: { name: "Send", keys: "Enter", desc: "Send your message to Genie." };
},
};

let pane = null;
let hover = null; // { el, watch } the button the mouse is on
let shown = null; // the button the pane is shown for
let timer = 0;
let hiddenAt = 0;

function isMac() {
return typeof window.IS_MAC === "boolean" ? window.IS_MAC : /Mac|iPhone|iPad/.test(navigator.platform || "");
}

function visibleWords(el) {
const copy = el.cloneNode(true);
copy.querySelectorAll("kbd, svg, .visually-hidden, .social-count, input, select").forEach(function (n) {
n.remove();
});
const words = (copy.textContent || "").replace(/\s+/g, " ").trim();
return words.length <= 28 ? words : "";
}

function candidate(target) {
if (!(target instanceof Element)) return null;
const el = target.closest(CANDIDATE);
if (!el || !el.closest(SCOPE) || el.closest(NOT_IN)) return null;
return el;
}

function contentOf(el) {
const fromState = NAMES[el.id] ? NAMES[el.id](el) : {};
const label = el.getAttribute("aria-label") || "";
return tipContent({
name: fromState.name || el.getAttribute("data-tip-name") || visibleWords(el) || (label.length <= 28 ? label : ""),
keys: fromState.keys !== undefined ? fromState.keys : el.getAttribute("data-tip-keys"),
desc: fromState.desc || el.getAttribute("data-tip-desc") || el.getAttribute("data-tip-title") || el.getAttribute("title") || "",
isMac: isMac(),
});
}

function quiet(el) {
// not during the tour, not for a button whose menu or dialog is open (a button
// that only says a panel is shown, like Files, keeps its tip), not for one that is off
const menuOpen = el.hasAttribute("aria-haspopup") && el.getAttribute("aria-expanded") === "true";
return !!document.querySelector(".introjs-overlay") || menuOpen || el.disabled === true;
}

function ensurePane() {
if (pane) return pane;
pane = document.createElement("div");
pane.className = "ide-tip";
pane.setAttribute("role", "tooltip");
pane.hidden = true;
const arrow = document.createElement("span");
arrow.className = "ide-tip__arrow";
const head = document.createElement("div");
head.className = "ide-tip__head";
const desc = document.createElement("div");
desc.className = "ide-tip__desc";
pane.append(arrow, head, desc);
document.body.appendChild(pane);
return pane;
}

function show(el) {
if (!el.isConnected || quiet(el)) return;
const c = contentOf(el);
if (!c.name) return;
const p = ensurePane();
const head = p.querySelector(".ide-tip__head");
const desc = p.querySelector(".ide-tip__desc");
head.textContent = "";
const name = document.createElement("span");
name.textContent = c.name;
head.appendChild(name);
c.keys.forEach(function (k) {
const kbd = document.createElement("kbd");
kbd.textContent = k;
head.appendChild(kbd);
});
desc.textContent = c.desc;
desc.hidden = !c.desc;
p.hidden = false;
p.classList.remove("is-shown");
p.style.left = "0px";
p.style.top = "0px";
const at = placeTip(el.getBoundingClientRect(), { width: p.offsetWidth, height: p.offsetHeight }, { width: window.innerWidth, height: window.innerHeight });
p.style.left = at.left + "px";
p.style.top = at.top + "px";
p.classList.toggle("ide-tip--above", at.above);
p.querySelector(".ide-tip__arrow").style.left = at.arrow + "px";
void p.offsetWidth; // so that it fades in from here
p.classList.add("is-shown");
shown = el;
}

function hide() {
clearTimeout(timer);
if (pane && !pane.hidden) {
pane.hidden = true;
pane.classList.remove("is-shown");
hiddenAt = Date.now();
}
shown = null;
}

// ---- the button's own title, while the mouse is on it ---------------------------------

function keepTitle(el) {
const t = el.getAttribute("title");
if (t === null) return;
el.setAttribute("data-tip-title", t);
el.removeAttribute("title");
}

function giveTitleBack(el) {
const kept = el.getAttribute("data-tip-title");
if (kept === null) return;
el.removeAttribute("data-tip-title");
if (!el.hasAttribute("title")) el.setAttribute("title", kept);
}

function enter(el) {
leave();
keepTitle(el);
// a title set while the mouse is still there (a click that changes the button) is kept too
const watch = new MutationObserver(function () {
if (el.hasAttribute("title")) {
keepTitle(el);
if (shown === el) show(el);
}
});
watch.observe(el, { attributes: true, attributeFilter: ["title"] });
hover = { el: el, watch: watch };
clearTimeout(timer);
timer = setTimeout(function () {
if (hover && hover.el === el) show(el);
}, Date.now() - hiddenAt < QUICK_WITHIN_MS ? 0 : SHOW_AFTER_MS);
}

function leave() {
if (!hover) return;
hover.watch.disconnect();
giveTitleBack(hover.el);
if (shown === hover.el) hide();
else clearTimeout(timer);
hover = null;
}

// ---- the mouse ---------------------------------------------------------------------------

document.addEventListener("pointerover", function (ev) {
if (ev.pointerType !== "mouse") return;
const el = candidate(ev.target);
if (hover && hover.el === el) return;
if (el) enter(el);
else leave();
});

document.addEventListener("pointerout", function (ev) {
if (!hover || ev.pointerType !== "mouse") return;
const to = ev.relatedTarget;
if (to instanceof Node && hover.el.contains(to)) return;
leave();
});

// a button that is gone (a panel that closed) sends no pointerout
document.addEventListener("pointermove", function () {
if (hover && !hover.el.isConnected) leave();
});

// a tip is in the way of nothing: any press, key, scroll or resize puts it away
// (the mouse may still be on the button: the tip comes back when it comes back)
["pointerdown", "keydown", "wheel"].forEach(function (type) {
document.addEventListener(type, hide, { capture: true, passive: true });
});
window.addEventListener("scroll", hide, true);
window.addEventListener("resize", hide);
window.addEventListener("blur", hide);

// ---- the keyboard ------------------------------------------------------------------------

document.addEventListener("focusin", function (ev) {
const el = candidate(ev.target);
if (!el || el !== ev.target) return;
let byKeyboard = false;
try {
byKeyboard = el.matches(":focus-visible");
} catch (e) {
// an old browser: no tips from the keyboard
}
if (byKeyboard) show(el);
});

document.addEventListener("focusout", function (ev) {
if (shown && shown === ev.target && !(hover && hover.el === shown)) hide();
});

export {}; // an ES module: strict mode, bundled by webpack
1 change: 1 addition & 0 deletions src/js/src/page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ import "./16-language-pages";
import "./17-share-code";
import "./18-genie-review";
import "./19-genie-actions";
import "./20-tips";
57 changes: 57 additions & 0 deletions src/js/src/page/lib-tips.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// The text and the place of a button's tip (20-tips.js), without the page, so
// that node can test it (src/js/test).

// What a tip says: the button's name, its shortcut as key caps, one line about it.
// name: what the button is called ("Run"); "" if nothing names it
// keys: its shortcut, space separated ("Mod Enter"); Mod is Ctrl, or ⌘ on a Mac
// desc: the line about it (the button's own title, unless one was written for the tip)
// A shortcut written at the end of the line, in brackets, is dropped when the
// key caps show it. A line that only repeats the name is dropped.
export function tipContent(input) {
const name = clean(input.name);
const keys = clean(input.keys)
.split(" ")
.filter(Boolean)
.map(function (k) {
return k === "Mod" ? (input.isMac ? "⌘" : "Ctrl") : k;
});
let desc = clean(input.desc);
if (keys.length) desc = desc.replace(/\s*\([^()]*\)\s*$/, "");
if (desc && name && sameWords(desc, name)) desc = "";
// a tip with only a line shows it as its name
if (!name && desc) return { name: desc, keys: keys, desc: "" };
// a line reads as a sentence, whoever wrote it
if (desc && !/[.!?\u2026]$/.test(desc)) desc += ".";
return { name: name, keys: keys, desc: desc };
}

function clean(s) {
return String(s == null ? "" : s).replace(/\s+/g, " ").trim();
}

function sameWords(a, b) {
const norm = function (s) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
};
return norm(a) === norm(b);
}

// Where a tip goes: under its button, centred on it; above when there is no
// room under it; never outside the window. `arrow` is where the pointer of the
// tip sits, from the tip's left edge.
// anchor: {left, top, right, bottom} of the button, in the window
// tip: {width, height}
// view: {width, height} of the window
export function placeTip(anchor, tip, view, gap, margin) {
gap = gap === undefined ? 8 : gap;
margin = margin === undefined ? 8 : margin;
const centre = (anchor.left + anchor.right) / 2;
const maxLeft = Math.max(margin, view.width - tip.width - margin);
const left = Math.round(Math.min(maxLeft, Math.max(margin, centre - tip.width / 2)));
const roomBelow = view.height - anchor.bottom - gap - margin;
const roomAbove = anchor.top - gap - margin;
const above = roomBelow < tip.height && roomAbove > roomBelow;
const top = Math.round(above ? anchor.top - gap - tip.height : anchor.bottom + gap);
const arrow = Math.round(Math.min(tip.width - 14, Math.max(14, centre - left)));
return { left: left, top: top, above: above, arrow: arrow };
}
Loading
Loading