Files
Outcut/content/keyboard.js
Joel Brock 3de2db7d89 rename: Outlook Relook → Outcut, add keyboard presets, remove design UI from popup
- Rename extension to "Outcut" throughout all source files and console logs
- Add KEY_PRESETS object (gmail/outlook) and matchesAction() helper to keyboard.js
- Rewrite handleKeydown to use preset-based dispatch instead of hardcoded switch
- Update updateSettings to re-init when keyboardPreset changes
- Add keyboardPreset: 'gmail' default to settings-defaults.js
- Replace popup Design Tweaks UI with preset dropdown + dynamic help text
- Keep all design tweak content script logic intact (activatable via storage)
- Update manifest: version 1.0.0, remove activeTab, add homepage_url
2026-04-27 14:11:45 -07:00

538 lines
17 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();
}
function getActionTargets(items) {
var targets = [];
if (selectedIds.size > 0) {
selectedIds.forEach(function (id) {
var idx = findItemById(items, id);
if (idx >= 0) targets.push(items[idx]);
});
} else {
var fi = getFocusedIndex(items);
if (fi >= 0) targets.push(items[fi]);
}
return targets;
}
// --- 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);
}
function performAction(targets, actionFn) {
if (targets.length === 0) return;
var i = 0;
function processNext() {
if (i >= targets.length) {
clearSelection();
return;
}
var target = targets[i];
i++;
// First, try to find the inline button without clicking the row.
// Hover to make OWA's inline action buttons appear.
triggerHover(target);
setTimeout(function () {
// Try inline button first (avoids opening the email in fill-screen mode)
var inlineHandled = actionFn(target);
// If inline button wasn't found, fall back to clicking the row + toolbar
if (inlineHandled === false) {
target.click();
setTimeout(function () {
actionFn(target);
setTimeout(processNext, 200);
}, 150);
} else {
setTimeout(processNext, 200);
}
}, 100);
}
processNext();
}
function actionDelete(targets) {
performAction(targets, function (target) {
var btn = findButton(target, 'Delete');
if (btn) { btn.click(); return true; }
console.warn('[Outcut] Delete button not found');
return false;
});
}
function actionArchive(targets) {
performAction(targets, function (target) {
var btn = findButton(target, 'Archive');
if (btn) { btn.click(); return true; }
console.warn('[Outcut] Archive button not found');
return false;
});
}
function actionMarkReadUnread(targets) {
performAction(targets, function (target) {
var btn = findButton(target, 'Read / Unread');
if (btn) { btn.click(); return true; }
triggerContextMenuAction(target, /mark as read|mark as unread|read \/ unread/i);
return true; // context menu handles it
});
}
function actionMove(targets) {
if (targets.length > 0) {
triggerHover(targets[0]);
setTimeout(function () {
var btn = findButton(targets[0], 'Move to');
if (btn) {
btn.click();
} else {
triggerContextMenuAction(targets[0], /move to/i);
}
}, 150);
}
}
function actionFlag(targets) {
performAction(targets, function (target) {
var btn = findButton(target, 'Flag this message')
|| findButton(target, 'Flag / Unflag');
if (btn) { btn.click(); return true; }
console.warn('[Outcut] Flag button not found');
return false;
});
}
function actionPin(targets) {
performAction(targets, function (target) {
var btn = findButton(target, 'Pin / Unpin');
if (btn) { btn.click(); return true; }
console.warn('[Outcut] Pin button not found');
return false;
});
}
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 = getActionTargets(items);
actionDelete(targets);
} else if (matchesAction(e, preset.archive)) {
targets = getActionTargets(items);
actionArchive(targets);
} else if (matchesAction(e, preset.readUnread)) {
targets = getActionTargets(items);
actionMarkReadUnread(targets);
} else if (matchesAction(e, preset.move)) {
targets = getActionTargets(items);
actionMove(targets);
} else if (matchesAction(e, preset.flag)) {
targets = getActionTargets(items);
actionFlag(targets);
} else if (matchesAction(e, preset.pin)) {
targets = getActionTargets(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 };
})();