Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Keyboard Shortcuts — shortcut definitions, cheatsheet modal, find bar UI,
3 * and global keyboard event handlers.
4 *
5 * Extracted from main.ts for decomposition.
6 */
7
8// ── Types ───────────────────────────────────────────────────
9
10export interface ShortcutCategory {
11 category: string;
12 shortcuts: Array<{ keys: string[]; label: string }>;
13}
14
15export interface KeyboardShortcutDeps {
16 editor: any;
17 provider: any;
18 $: (id: string) => HTMLElement;
19 exportHTML: () => void;
20 doExportPdf: () => void;
21 printDocument: () => void;
22 mdToggle: { toggle: () => void };
23 showShortcutModal: () => void;
24}
25
26export interface FindBarDeps {
27 editor: any;
28 $: (id: string) => HTMLElement;
29}
30
31// ── Shortcut definitions ────────────────────────────────────
32
33export const DOCS_SHORTCUTS: ShortcutCategory[] = [
34 { category: 'Formatting', shortcuts: [
35 { keys: ['\u2318', 'B'], label: 'Bold' },
36 { keys: ['\u2318', 'I'], label: 'Italic' },
37 { keys: ['\u2318', 'U'], label: 'Underline' },
38 { keys: ['\u2318', 'E'], label: 'Inline code' },
39 { keys: ['\u2318', '\u21e7', 'X'], label: 'Strikethrough' },
40 ]},
41 { category: 'Structure', shortcuts: [
42 { keys: ['\u2318', '\u21e7', '7'], label: 'Ordered list' },
43 { keys: ['\u2318', '\u21e7', '8'], label: 'Bullet list' },
44 { keys: ['\u2318', '\u21e7', '9'], label: 'Task list' },
45 { keys: ['\u2318', '\u21e7', 'B'], label: 'Blockquote' },
46 { keys: ['\u2318', '\u2325', 'C'], label: 'Code block' },
47 { keys: ['Tab'], label: 'Indent' },
48 { keys: ['\u21e7', 'Tab'], label: 'Outdent' },
49 { keys: ['\u2318', 'Enter'], label: 'Page break' },
50 ]},
51 { category: 'Markdown Autoformat', shortcuts: [
52 { keys: ['#', 'Space'], label: 'Heading 1' },
53 { keys: ['##', 'Space'], label: 'Heading 2' },
54 { keys: ['###', 'Space'], label: 'Heading 3' },
55 { keys: ['```', 'Space'], label: 'Code block' },
56 { keys: ['>', 'Space'], label: 'Blockquote' },
57 { keys: ['-', 'Space'], label: 'Bullet list' },
58 { keys: ['1.', 'Space'], label: 'Numbered list' },
59 { keys: ['[]', 'Space'], label: 'Task list' },
60 { keys: ['---'], label: 'Horizontal rule' },
61 { keys: ['**text**'], label: 'Bold' },
62 { keys: ['*text*'], label: 'Italic' },
63 { keys: ['~~text~~'], label: 'Strikethrough' },
64 { keys: ['`text`'], label: 'Inline code' },
65 { keys: ['[text](url)'], label: 'Link' },
66 ]},
67 { category: 'Navigation', shortcuts: [
68 { keys: ['\u2318', 'A'], label: 'Select all' },
69 { keys: ['\u2318', 'Z'], label: 'Undo' },
70 { keys: ['\u2318', '\u21e7', 'Z'], label: 'Redo' },
71 ]},
72 { category: 'Document', shortcuts: [
73 { keys: ['\u2318', 'S'], label: 'Save snapshot' },
74 { keys: ['\u2318', '\u21e7', 'S'], label: 'Export HTML' },
75 { keys: ['\u2318', '\u21e7', 'P'], label: 'Export PDF' },
76 { keys: ['\u2318', '\u21e7', 'M'], label: 'Toggle markdown source' },
77 { keys: ['\u2318', 'P'], label: 'Print' },
78 { keys: ['\u2318', '/'], label: 'Keyboard shortcuts' },
79 ]},
80];
81
82// ── Shortcut Modal ──────────────────────────────────────────
83
84export function buildShortcutModal(shortcuts: ShortcutCategory[]): HTMLElement {
85 const overlay = document.createElement('div');
86 overlay.className = 'modal-overlay';
87 const modal = document.createElement('div');
88 modal.className = 'modal shortcuts-modal';
89 let html = '<h2>Keyboard Shortcuts <button class="shortcuts-modal-close" title="Close (Escape)">\u2715</button></h2>';
90 for (const cat of shortcuts) {
91 html += '<div class="shortcut-category"><div class="shortcut-category-title">' + cat.category + '</div>';
92 for (const sc of cat.shortcuts) {
93 html += '<div class="shortcut-row"><span class="shortcut-label">' + sc.label + '</span><span class="shortcut-keys">';
94 html += sc.keys.map(k => '<span class="shortcut-key">' + k + '</span>').join('');
95 html += '</span></div>';
96 }
97 html += '</div>';
98 }
99 modal.innerHTML = html;
100 overlay.appendChild(modal);
101 const close = () => overlay.remove();
102 modal.querySelector('.shortcuts-modal-close')!.addEventListener('click', close);
103 overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
104 const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); } };
105 document.addEventListener('keydown', handler);
106 return overlay;
107}
108
109export function showShortcutModal(): void {
110 if (document.querySelector('.shortcuts-modal')) return;
111 document.body.appendChild(buildShortcutModal(DOCS_SHORTCUTS));
112}
113
114// ── Find Bar ────────────────────────────────────────────────
115
116const FIND_BAR_HTML = '<div class="find-bar" id="find-bar" style="display:none">' +
117 '<div class="find-bar-row">' +
118 '<input class="find-bar-input" id="find-input" type="text" placeholder="Find..." spellcheck="false">' +
119 '<span class="find-bar-count" id="find-count"></span>' +
120 '<button class="btn-icon find-bar-btn" id="find-prev" title="Previous">\u25b2</button>' +
121 '<button class="btn-icon find-bar-btn" id="find-next" title="Next (Enter)">\u25bc</button>' +
122 '<button class="btn-icon find-bar-btn" id="find-case" title="Case sensitive">Aa</button>' +
123 '<button class="btn-icon find-bar-btn" id="find-replace-toggle" title="Toggle replace">\u21c4</button>' +
124 '<button class="btn-icon find-bar-btn" id="find-close" title="Close (Esc)">\u00d7</button>' +
125 '</div>' +
126 '<div class="find-bar-row find-bar-replace" id="find-replace-row" style="display:none">' +
127 '<input class="find-bar-input" id="replace-input" type="text" placeholder="Replace..." spellcheck="false">' +
128 '<button class="btn-icon find-bar-btn" id="replace-one" title="Replace">Replace</button>' +
129 '<button class="btn-icon find-bar-btn" id="replace-all" title="Replace all">All</button>' +
130 '</div>' +
131'</div>';
132
133export function insertFindBar(): void {
134 const editorContainerEl = document.querySelector('.editor-container');
135 if (editorContainerEl) {
136 editorContainerEl.insertAdjacentHTML('afterbegin', FIND_BAR_HTML);
137 }
138}
139
140export function wireFindBar(deps: FindBarDeps): { updateFindBar: () => void } {
141 const { editor, $ } = deps;
142 const findBar = $('find-bar');
143 const findInput = $('find-input') as HTMLInputElement;
144 const replaceInput = $('replace-input') as HTMLInputElement;
145 const findCount = $('find-count');
146 const findReplaceRow = $('find-replace-row');
147
148 function updateFindBar() {
149 const storage = editor.storage.searchReplace;
150 findBar.style.display = storage.isOpen ? '' : 'none';
151 findReplaceRow.style.display = storage.showReplace ? '' : 'none';
152
153 if (storage.matches.length > 0) {
154 findCount.textContent = (storage.activeIndex + 1) + ' / ' + storage.matches.length;
155 } else if (storage.searchTerm) {
156 findCount.textContent = 'No results';
157 } else {
158 findCount.textContent = '';
159 }
160
161 $('find-case').classList.toggle('active', storage.caseSensitive);
162
163 if (storage.isOpen && document.activeElement !== findInput && document.activeElement !== replaceInput) {
164 findInput.focus();
165 findInput.select();
166 }
167 }
168
169 findInput.addEventListener('input', () => {
170 editor.commands.setSearchTerm(findInput.value);
171 });
172
173 findInput.addEventListener('keydown', (e: KeyboardEvent) => {
174 if (e.key === 'Enter') {
175 e.preventDefault();
176 if (e.shiftKey) {
177 editor.commands.prevMatch();
178 } else {
179 editor.commands.nextMatch();
180 }
181 } else if (e.key === 'Escape') {
182 e.preventDefault();
183 editor.commands.closeSearch();
184 editor.commands.focus();
185 }
186 });
187
188 replaceInput.addEventListener('input', () => {
189 editor.commands.setReplaceTerm(replaceInput.value);
190 });
191
192 replaceInput.addEventListener('keydown', (e: KeyboardEvent) => {
193 if (e.key === 'Enter') {
194 e.preventDefault();
195 editor.commands.replaceCurrent();
196 } else if (e.key === 'Escape') {
197 e.preventDefault();
198 editor.commands.closeSearch();
199 editor.commands.focus();
200 }
201 });
202
203 $('find-next').addEventListener('click', () => editor.commands.nextMatch());
204 $('find-prev').addEventListener('click', () => editor.commands.prevMatch());
205 $('find-case').addEventListener('click', () => editor.commands.toggleCaseSensitive());
206 $('find-close').addEventListener('click', () => {
207 editor.commands.closeSearch();
208 editor.commands.focus();
209 });
210 $('find-replace-toggle').addEventListener('click', () => {
211 const storage = editor.storage.searchReplace;
212 storage.showReplace = !storage.showReplace;
213 updateFindBar();
214 });
215 $('replace-one').addEventListener('click', () => editor.commands.replaceCurrent());
216 $('replace-all').addEventListener('click', () => editor.commands.replaceAll());
217
218 return { updateFindBar };
219}
220
221// ── Global keyboard shortcuts ───────────────────────────────
222
223export function wireGlobalShortcuts(deps: KeyboardShortcutDeps): void {
224 const { editor, provider, $, exportHTML, doExportPdf, printDocument, mdToggle, showShortcutModal: showModal } = deps;
225
226 document.addEventListener('keydown', (e: KeyboardEvent) => {
227 const mod = e.metaKey || e.ctrlKey;
228 if (mod && e.key === '/') {
229 e.preventDefault();
230 showModal();
231 return;
232 }
233 if (mod && !e.shiftKey && e.key.toLowerCase() === 'f') {
234 e.preventDefault();
235 editor.commands.openSearch();
236 return;
237 }
238 if (mod && e.key.toLowerCase() === 'h') {
239 e.preventDefault();
240 editor.commands.openSearchReplace();
241 return;
242 }
243 if (mod && e.shiftKey && e.key.toLowerCase() === 'g') {
244 if (editor.storage.searchReplace.isOpen) {
245 e.preventDefault();
246 editor.commands.prevMatch();
247 return;
248 }
249 }
250 if (mod && e.key.toLowerCase() === 'g') {
251 if (editor.storage.searchReplace.isOpen) {
252 e.preventDefault();
253 editor.commands.nextMatch();
254 return;
255 }
256 }
257 if (mod && e.shiftKey && e.key.toLowerCase() === 's') {
258 e.preventDefault();
259 exportHTML();
260 } else if (mod && e.key.toLowerCase() === 's') {
261 e.preventDefault();
262 provider._saveSnapshot();
263 } else if (mod && e.shiftKey && e.key.toLowerCase() === 'p') {
264 e.preventDefault();
265 doExportPdf();
266 } else if (mod && e.key.toLowerCase() === 'p') {
267 e.preventDefault();
268 printDocument();
269 } else if (mod && e.shiftKey && e.key.toLowerCase() === 'm') {
270 e.preventDefault();
271 mdToggle.toggle();
272 }
273 });
274}