574 lines
18 KiB
JavaScript
574 lines
18 KiB
JavaScript
// Outcut — Keyboard Navigation & Multi-Select
|
|
// Adds keyboard focus cursor and multi-select to OWA's message list.
|
|
// Gated by the keyboardMultiSelect setting.
|
|
//
|
|
// Tracks selections by stable message ID (data attribute or aria-label)
|
|
// rather than DOM element reference, since OWA frequently re-renders rows.
|
|
|
|
window.OutlookRelook = window.OutlookRelook || {};
|
|
|
|
// Keyboard shortcut presets
|
|
var KEY_PRESETS = {
|
|
gmail: {
|
|
nextMessage: [{key: 'j'}, {key: 'ArrowDown'}],
|
|
prevMessage: [{key: 'k'}, {key: 'ArrowUp'}],
|
|
selectExtendDown: [{key: 'j', shift: true}, {key: 'ArrowDown', shift: true}],
|
|
selectExtendUp: [{key: 'k', shift: true}, {key: 'ArrowUp', shift: true}],
|
|
toggleSelect: [{key: 'x'}, {key: ' '}],
|
|
delete: [{key: '#'}],
|
|
archive: [{key: 'e'}],
|
|
readUnread: [{key: 'I', shift: true}, {key: 'U', shift: true}],
|
|
move: [{key: 'v'}],
|
|
flag: [{key: 'f'}],
|
|
pin: [{key: 'p'}],
|
|
deselect: [{key: 'Escape'}],
|
|
open: [{key: 'Enter'}, {key: 'o'}],
|
|
label: 'Gmail-style: j/k nav, x select, # del, e archive'
|
|
},
|
|
outlook: {
|
|
nextMessage: [{key: 'ArrowDown'}, {key: 'j'}],
|
|
prevMessage: [{key: 'ArrowUp'}, {key: 'k'}],
|
|
selectExtendDown: [{key: 'ArrowDown', shift: true}],
|
|
selectExtendUp: [{key: 'ArrowUp', shift: true}],
|
|
toggleSelect: [{key: ' '}],
|
|
delete: [{key: 'Delete'}, {key: 'Backspace'}],
|
|
archive: [{key: 'e'}],
|
|
readUnread: [{key: 'q'}, {key: 'I', shift: true}],
|
|
move: [{key: 'v'}],
|
|
flag: [{key: 'Insert'}, {key: 'f'}],
|
|
pin: [{key: 'p'}],
|
|
deselect: [{key: 'Escape'}],
|
|
open: [{key: 'Enter'}],
|
|
label: 'Outlook-style: arrows nav, Space select, Del delete'
|
|
}
|
|
};
|
|
|
|
function matchesAction(e, actionBindings) {
|
|
if (!actionBindings) return false;
|
|
for (var i = 0; i < actionBindings.length; i++) {
|
|
var b = actionBindings[i];
|
|
if (e.key === b.key && !!e.shiftKey === !!b.shift && !!e.ctrlKey === !!b.ctrl) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
window.OutlookRelook.Keyboard = (function () {
|
|
'use strict';
|
|
|
|
var OR = window.OutlookRelook;
|
|
var currentSettings = {};
|
|
var cleanupFns = [];
|
|
|
|
// State — track by ID strings, not DOM references
|
|
var focusedId = null; // ID of the focused message
|
|
var selectedIds = new Set(); // Set of selected message IDs
|
|
var countBadge = null;
|
|
|
|
// --- Message ID extraction ---
|
|
// OWA messages have various attributes we can use as stable IDs.
|
|
// Try data-convid, data-itemid, id, or fall back to aria-label.
|
|
|
|
function getMessageId(el) {
|
|
return el.getAttribute('data-convid')
|
|
|| el.getAttribute('data-itemid')
|
|
|| el.getAttribute('data-tid')
|
|
|| el.getAttribute('id')
|
|
|| el.getAttribute('aria-label')
|
|
|| null;
|
|
}
|
|
|
|
function getMessageItems() {
|
|
var items = document.querySelectorAll(
|
|
'[role="listbox"] [role="option"], [role="list"] [role="listitem"]'
|
|
);
|
|
return Array.from(items);
|
|
}
|
|
|
|
function findItemById(items, id) {
|
|
if (!id) return -1;
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (getMessageId(items[i]) === id) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function isComposeOrDialogActive() {
|
|
var active = document.activeElement;
|
|
if (!active) return false;
|
|
var tag = active.tagName.toLowerCase();
|
|
if (tag === 'input' || tag === 'textarea') return true;
|
|
if (active.getAttribute('contenteditable') === 'true') return true;
|
|
if (active.closest('[role="dialog"]')) return true;
|
|
if (active.closest('[role="search"]')) return true;
|
|
if (active.closest('[aria-label*="compose" i]')) return true;
|
|
if (active.closest('[aria-label*="New message" i]')) return true;
|
|
return false;
|
|
}
|
|
|
|
// --- Visual state application ---
|
|
// Re-applies focus and selection classes to current DOM elements
|
|
// based on the ID-based state. Called after every key action and
|
|
// by the MutationObserver when OWA re-renders.
|
|
|
|
function applyVisualState(items) {
|
|
// Clear all visual markers first
|
|
var oldFocused = document.querySelectorAll('.or-kb-focused');
|
|
for (var f = 0; f < oldFocused.length; f++) oldFocused[f].classList.remove('or-kb-focused');
|
|
var oldSelected = document.querySelectorAll('.or-kb-selected');
|
|
for (var s = 0; s < oldSelected.length; s++) oldSelected[s].classList.remove('or-kb-selected');
|
|
|
|
if (!items) items = getMessageItems();
|
|
|
|
// Apply focus
|
|
if (focusedId) {
|
|
var focusIdx = findItemById(items, focusedId);
|
|
if (focusIdx >= 0) {
|
|
items[focusIdx].classList.add('or-kb-focused');
|
|
}
|
|
}
|
|
|
|
// Apply selections
|
|
selectedIds.forEach(function (id) {
|
|
var idx = findItemById(items, id);
|
|
if (idx >= 0) {
|
|
items[idx].classList.add('or-kb-selected');
|
|
}
|
|
});
|
|
|
|
updateCountBadge();
|
|
}
|
|
|
|
// --- Focus management ---
|
|
|
|
function getFocusedIndex(items) {
|
|
if (!focusedId) return -1;
|
|
return findItemById(items, focusedId);
|
|
}
|
|
|
|
function setFocus(items, index) {
|
|
if (index < 0 || index >= items.length) return;
|
|
focusedId = getMessageId(items[index]);
|
|
applyVisualState(items);
|
|
items[index].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}
|
|
|
|
// --- Selection management ---
|
|
|
|
function toggleSelect(items, index) {
|
|
if (index < 0 || index >= items.length) return;
|
|
var id = getMessageId(items[index]);
|
|
if (!id) return;
|
|
|
|
if (selectedIds.has(id)) {
|
|
selectedIds.delete(id);
|
|
} else {
|
|
selectedIds.add(id);
|
|
}
|
|
applyVisualState(items);
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedIds.clear();
|
|
var oldSelected = document.querySelectorAll('.or-kb-selected');
|
|
for (var i = 0; i < oldSelected.length; i++) oldSelected[i].classList.remove('or-kb-selected');
|
|
updateCountBadge();
|
|
}
|
|
|
|
// Returns an array of message IDs to act on.
|
|
// We return IDs (not DOM elements) because OWA re-renders rows after each
|
|
// action, invalidating direct element references.
|
|
function getActionTargetIds(items) {
|
|
var ids = [];
|
|
if (selectedIds.size > 0) {
|
|
selectedIds.forEach(function (id) { ids.push(id); });
|
|
} else {
|
|
if (focusedId) ids.push(focusedId);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// Re-resolve an ID to the current DOM element (or null if it's gone).
|
|
function resolveTargetById(id) {
|
|
var items = getMessageItems();
|
|
var idx = findItemById(items, id);
|
|
return idx >= 0 ? items[idx] : null;
|
|
}
|
|
|
|
// --- Selection count badge ---
|
|
|
|
function createCountBadge() {
|
|
if (!document.body) return null;
|
|
var badge = document.createElement('div');
|
|
badge.className = 'or-kb-selection-count';
|
|
badge.style.opacity = '0';
|
|
document.body.appendChild(badge);
|
|
return badge;
|
|
}
|
|
|
|
function updateCountBadge() {
|
|
if (!countBadge) countBadge = createCountBadge();
|
|
if (!countBadge) return;
|
|
var count = selectedIds.size;
|
|
if (count === 0) {
|
|
countBadge.style.opacity = '0';
|
|
} else {
|
|
countBadge.textContent = count + ' selected';
|
|
countBadge.style.opacity = '1';
|
|
}
|
|
}
|
|
|
|
// --- Actions ---
|
|
|
|
// --- Find a button by aria-label, searching within a target element first,
|
|
// then the global toolbar, then the whole document ---
|
|
function findButton(target, label) {
|
|
// 1. Look for inline button within/near the message row
|
|
// OWA renders Delete/Archive/Flag buttons on each row on hover
|
|
var btn = target.querySelector('button[aria-label="' + label + '"]');
|
|
if (btn) return btn;
|
|
|
|
// 2. Check the parent (some buttons are siblings of the row)
|
|
if (target.parentElement) {
|
|
btn = target.parentElement.querySelector('button[aria-label="' + label + '"]');
|
|
if (btn) return btn;
|
|
}
|
|
|
|
// 3. Look in the global toolbar area
|
|
btn = document.querySelector('[role="toolbar"] button[aria-label="' + label + '"], .fui-Toolbar button[aria-label="' + label + '"]');
|
|
if (btn) return btn;
|
|
|
|
// 4. Anywhere in the document
|
|
btn = document.querySelector('button[aria-label="' + label + '"]');
|
|
return btn;
|
|
}
|
|
|
|
// Simulate hover on a message row to make OWA's inline action buttons appear
|
|
function triggerHover(target) {
|
|
var rect = target.getBoundingClientRect();
|
|
var enterEvent = new MouseEvent('mouseenter', {
|
|
bubbles: true, cancelable: true,
|
|
clientX: rect.x + rect.width - 30,
|
|
clientY: rect.y + rect.height / 2,
|
|
});
|
|
var overEvent = new MouseEvent('mouseover', {
|
|
bubbles: true, cancelable: true,
|
|
clientX: rect.x + rect.width - 30,
|
|
clientY: rect.y + rect.height / 2,
|
|
});
|
|
target.dispatchEvent(enterEvent);
|
|
target.dispatchEvent(overEvent);
|
|
}
|
|
|
|
// Find a button in the global OWA toolbar (not inline on a row).
|
|
// The toolbar button acts on OWA's currently selected messages.
|
|
function findToolbarButton(label) {
|
|
var sels = [
|
|
'[role="toolbar"] button[aria-label="' + label + '"]',
|
|
'.fui-Toolbar button[aria-label="' + label + '"]',
|
|
'[aria-label*="Quick actions" i] button[aria-label="' + label + '"]'
|
|
];
|
|
for (var i = 0; i < sels.length; i++) {
|
|
var btn = document.querySelector(sels[i]);
|
|
if (btn) return btn;
|
|
}
|
|
// Fallback: any button with that label that's NOT inside a message row
|
|
var all = document.querySelectorAll('button[aria-label="' + label + '"]');
|
|
for (var j = 0; j < all.length; j++) {
|
|
if (!all[j].closest('[role="option"], [role="listitem"]')) {
|
|
return all[j];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Ctrl+click a message row to add it to OWA's native multi-selection.
|
|
// Uses MouseEvent with ctrlKey + metaKey to handle both Mac and Win.
|
|
function ctrlClickRow(target) {
|
|
var rect = target.getBoundingClientRect();
|
|
var opts = {
|
|
bubbles: true, cancelable: true, view: window,
|
|
clientX: rect.x + 30, clientY: rect.y + rect.height / 2,
|
|
ctrlKey: true, metaKey: true, button: 0
|
|
};
|
|
target.dispatchEvent(new MouseEvent('mousedown', opts));
|
|
target.dispatchEvent(new MouseEvent('mouseup', opts));
|
|
target.dispatchEvent(new MouseEvent('click', opts));
|
|
}
|
|
|
|
// Build OWA's native selection by Ctrl+clicking each ID, then run a single
|
|
// toolbar action that operates on the entire selection.
|
|
function performToolbarAction(ids, toolbarLabel, fallbackContextPattern) {
|
|
if (!ids || ids.length === 0) return;
|
|
|
|
// First: clear any current OWA selection by clicking the first target plain
|
|
var first = resolveTargetById(ids[0]);
|
|
if (!first) {
|
|
clearSelection();
|
|
return;
|
|
}
|
|
first.click();
|
|
|
|
setTimeout(function () {
|
|
// Now Ctrl+click the rest to add to selection
|
|
var i = 1;
|
|
function addNext() {
|
|
if (i >= ids.length) {
|
|
// All selected in OWA, now click the toolbar action button
|
|
setTimeout(function () {
|
|
var btn = findToolbarButton(toolbarLabel);
|
|
if (btn) {
|
|
btn.click();
|
|
} else if (fallbackContextPattern) {
|
|
var fresh = resolveTargetById(ids[0]);
|
|
if (fresh) triggerContextMenuAction(fresh, fallbackContextPattern);
|
|
} else {
|
|
console.warn('[Outcut] Toolbar button not found: ' + toolbarLabel);
|
|
}
|
|
setTimeout(clearSelection, 200);
|
|
}, 100);
|
|
return;
|
|
}
|
|
var t = resolveTargetById(ids[i]);
|
|
i++;
|
|
if (t) ctrlClickRow(t);
|
|
setTimeout(addNext, 60);
|
|
}
|
|
addNext();
|
|
}, 100);
|
|
}
|
|
|
|
function actionDelete(ids) {
|
|
performToolbarAction(ids, 'Delete', /^delete$/i);
|
|
}
|
|
|
|
function actionArchive(ids) {
|
|
performToolbarAction(ids, 'Archive', /^archive$/i);
|
|
}
|
|
|
|
function actionMarkReadUnread(ids) {
|
|
performToolbarAction(ids, 'Read / Unread', /mark as read|mark as unread|read \/ unread/i);
|
|
}
|
|
|
|
function actionMove(ids) {
|
|
performToolbarAction(ids, 'Move to', /move to/i);
|
|
}
|
|
|
|
function actionFlag(ids) {
|
|
// Try multiple labels
|
|
if (!ids || ids.length === 0) return;
|
|
var first = resolveTargetById(ids[0]);
|
|
if (!first) return;
|
|
first.click();
|
|
setTimeout(function () {
|
|
var i = 1;
|
|
function addNext() {
|
|
if (i >= ids.length) {
|
|
setTimeout(function () {
|
|
var btn = findToolbarButton('Flag / Unflag') || findToolbarButton('Flag this message');
|
|
if (btn) btn.click();
|
|
setTimeout(clearSelection, 200);
|
|
}, 100);
|
|
return;
|
|
}
|
|
var t = resolveTargetById(ids[i]);
|
|
i++;
|
|
if (t) ctrlClickRow(t);
|
|
setTimeout(addNext, 60);
|
|
}
|
|
addNext();
|
|
}, 100);
|
|
}
|
|
|
|
function actionPin(ids) {
|
|
performToolbarAction(ids, 'Pin / Unpin', /pin/i);
|
|
}
|
|
|
|
function triggerContextMenuAction(target, pattern) {
|
|
var el = target || document.querySelector('.or-kb-focused');
|
|
if (!el) return;
|
|
var rect = el.getBoundingClientRect();
|
|
var contextEvent = new MouseEvent('contextmenu', {
|
|
bubbles: true, cancelable: true,
|
|
clientX: rect.x + 10, clientY: rect.y + 10,
|
|
});
|
|
el.dispatchEvent(contextEvent);
|
|
setTimeout(function () {
|
|
var menuItems = document.querySelectorAll('[role="menuitem"]');
|
|
for (var j = 0; j < menuItems.length; j++) {
|
|
if (pattern.test(menuItems[j].textContent || menuItems[j].getAttribute('aria-label') || '')) {
|
|
menuItems[j].click();
|
|
return;
|
|
}
|
|
}
|
|
document.body.click();
|
|
}, 300);
|
|
}
|
|
|
|
// --- Key handler ---
|
|
|
|
function handleKeydown(e) {
|
|
if (!currentSettings.keyboardMultiSelect) return;
|
|
if (isComposeOrDialogActive()) return;
|
|
|
|
var items = getMessageItems();
|
|
if (items.length === 0) return;
|
|
|
|
var presetName = currentSettings.keyboardPreset || 'gmail';
|
|
var preset = KEY_PRESETS[presetName] || KEY_PRESETS.gmail;
|
|
|
|
// Initialize focus if not set
|
|
var currentIdx = getFocusedIndex(items);
|
|
if (currentIdx < 0) {
|
|
for (var f = 0; f < items.length; f++) {
|
|
if (items[f].getAttribute('aria-selected') === 'true' ||
|
|
items[f].classList.contains('is-selected')) {
|
|
currentIdx = f;
|
|
focusedId = getMessageId(items[f]);
|
|
break;
|
|
}
|
|
}
|
|
if (currentIdx < 0) {
|
|
currentIdx = 0;
|
|
focusedId = getMessageId(items[0]);
|
|
}
|
|
applyVisualState(items);
|
|
}
|
|
|
|
var handled = true;
|
|
var targets;
|
|
|
|
if (matchesAction(e, preset.selectExtendDown)) {
|
|
toggleSelect(items, currentIdx);
|
|
if (currentIdx < items.length - 1) {
|
|
setFocus(items, currentIdx + 1);
|
|
toggleSelect(items, currentIdx + 1);
|
|
}
|
|
} else if (matchesAction(e, preset.selectExtendUp)) {
|
|
toggleSelect(items, currentIdx);
|
|
if (currentIdx > 0) {
|
|
setFocus(items, currentIdx - 1);
|
|
toggleSelect(items, currentIdx - 1);
|
|
}
|
|
} else if (matchesAction(e, preset.nextMessage)) {
|
|
if (currentIdx < items.length - 1) setFocus(items, currentIdx + 1);
|
|
} else if (matchesAction(e, preset.prevMessage)) {
|
|
if (currentIdx > 0) setFocus(items, currentIdx - 1);
|
|
} else if (matchesAction(e, preset.toggleSelect)) {
|
|
e.preventDefault();
|
|
toggleSelect(items, currentIdx);
|
|
} else if (matchesAction(e, preset.delete)) {
|
|
targets = getActionTargetIds(items);
|
|
actionDelete(targets);
|
|
} else if (matchesAction(e, preset.archive)) {
|
|
targets = getActionTargetIds(items);
|
|
actionArchive(targets);
|
|
} else if (matchesAction(e, preset.readUnread)) {
|
|
targets = getActionTargetIds(items);
|
|
actionMarkReadUnread(targets);
|
|
} else if (matchesAction(e, preset.move)) {
|
|
targets = getActionTargetIds(items);
|
|
actionMove(targets);
|
|
} else if (matchesAction(e, preset.flag)) {
|
|
targets = getActionTargetIds(items);
|
|
actionFlag(targets);
|
|
} else if (matchesAction(e, preset.pin)) {
|
|
targets = getActionTargetIds(items);
|
|
actionPin(targets);
|
|
} else if (matchesAction(e, preset.deselect)) {
|
|
clearSelection();
|
|
} else if (matchesAction(e, preset.open)) {
|
|
if (currentIdx >= 0 && currentIdx < items.length) items[currentIdx].click();
|
|
} else {
|
|
handled = false;
|
|
}
|
|
|
|
if (handled) {
|
|
e.stopPropagation();
|
|
if (e.key !== 'Enter') e.preventDefault();
|
|
}
|
|
}
|
|
|
|
// --- Re-apply visual state when OWA re-renders the message list ---
|
|
|
|
function setupListObserver() {
|
|
var listObserver = new MutationObserver(function () {
|
|
// Prune IDs that no longer exist in DOM
|
|
var items = getMessageItems();
|
|
var currentIds = new Set();
|
|
for (var i = 0; i < items.length; i++) {
|
|
var id = getMessageId(items[i]);
|
|
if (id) currentIds.add(id);
|
|
}
|
|
selectedIds.forEach(function (id) {
|
|
if (!currentIds.has(id)) selectedIds.delete(id);
|
|
});
|
|
if (focusedId && !currentIds.has(focusedId)) focusedId = null;
|
|
|
|
// Re-apply classes to new DOM elements
|
|
applyVisualState(items);
|
|
});
|
|
|
|
var checkInterval = setInterval(function () {
|
|
var list = document.querySelector('[role="listbox"], [role="list"]');
|
|
if (list) {
|
|
clearInterval(checkInterval);
|
|
listObserver.observe(list, { childList: true, subtree: true });
|
|
}
|
|
}, 1000);
|
|
|
|
cleanupFns.push(function () {
|
|
clearInterval(checkInterval);
|
|
listObserver.disconnect();
|
|
});
|
|
}
|
|
|
|
// --- Public API ---
|
|
|
|
function start(settings) {
|
|
currentSettings = settings;
|
|
if (!settings.keyboardMultiSelect) return;
|
|
|
|
document.addEventListener('keydown', handleKeydown, true);
|
|
setupListObserver();
|
|
|
|
console.log('[Outcut] Keyboard navigation started');
|
|
cleanupFns.push(function () {
|
|
document.removeEventListener('keydown', handleKeydown, true);
|
|
});
|
|
}
|
|
|
|
function updateSettings(settings) {
|
|
var wasEnabled = currentSettings.keyboardMultiSelect;
|
|
var isEnabled = settings.keyboardMultiSelect;
|
|
var oldPreset = currentSettings.keyboardPreset;
|
|
var newPreset = settings.keyboardPreset;
|
|
currentSettings = settings;
|
|
|
|
if (wasEnabled !== isEnabled || oldPreset !== newPreset) {
|
|
stop();
|
|
start(settings);
|
|
}
|
|
}
|
|
|
|
function stop() {
|
|
for (var i = 0; i < cleanupFns.length; i++) {
|
|
try { cleanupFns[i](); } catch (e) { /* ignore */ }
|
|
}
|
|
cleanupFns = [];
|
|
|
|
clearSelection();
|
|
var focused = document.querySelector('.or-kb-focused');
|
|
if (focused) focused.classList.remove('or-kb-focused');
|
|
focusedId = null;
|
|
|
|
if (countBadge) {
|
|
countBadge.remove();
|
|
countBadge = null;
|
|
}
|
|
}
|
|
|
|
return { start: start, updateSettings: updateSettings, stop: stop };
|
|
})();
|