1const route = location.pathname.match(/^\/g\/(\d+)\/report\/(page-\d+\.html)$/);
2const bar = document.createElement('aside');
3bar.className = 'diagnostic';
4bar.innerHTML = `<strong>Notebook copy</strong>
5 <button id="edit-mode" type="button" aria-pressed="false">Edit text</button>
6 <label>Text action <select id="text-action"><option value="text">Replace</option><option value="format">Format</option></select></label>
7 <button id="add-paragraph" type="button">Add paragraph</button>
8 <button id="add-outline" type="button">Add outline</button>
9 <a href="/latest">Latest notebook</a>
10 <span id="editor-status" role="status"></span>`;
11document.querySelector('main').prepend(bar);
12const status = document.querySelector('#editor-status');
13const mode = document.querySelector('#edit-mode');
14for (const control of bar.querySelectorAll('button, select')) control.disabled = !route;
15const dialog = document.createElement('dialog');
16dialog.setAttribute('aria-labelledby', 'edit-heading');
17dialog.innerHTML = `<form><h2 id="edit-heading">Edit text</h2>
18 <p id="edit-hint"></p>
19 <fieldset id="placement"><legend>Paragraph placement</legend>
20 <label>Parent container <select id="parent"></select></label>
21 <label>Insert before <select id="before"></select></label>
22 </fieldset>
23 <fieldset id="coordinates"><legend>Outline position</legend>
24 <label>X (points) <input id="outline-x" type="number" step="any" value="144"></label>
25 <label>Y (points) <input id="outline-y" type="number" step="any" value="144"></label>
26 </fieldset>
27 <label for="replacement">Text</label><textarea id="replacement" rows="6"></textarea>
28 <label id="author-label">Author <input id="author" value="Diagnostic"></label>
29 <fieldset id="formatting"><legend>Character formatting</legend>
30 <p id="format-range" role="status"></p>
31 <div class="format-grid"></div>
32 <label>Font <input id="font" placeholder="Keep current font"></label>
33 <label>Size (points) <input id="font-size" type="number" min="6" max="130" step="0.5" placeholder="Keep current size"></label>
34 <label for="color-mode">Text color</label><select id="color-mode"><option value="keep">Keep</option><option value="clear">Automatic</option><option value="set">Set color</option></select><input id="color" type="color" value="#123456" aria-label="Text color value">
35 <label for="highlight-mode">Highlight</label><select id="highlight-mode"><option value="keep">Keep</option><option value="clear">Clear</option><option value="set">Set color</option></select><input id="highlight" type="color" value="#ffff00" aria-label="Highlight color value">
36 </fieldset>
37 <p id="save-status" role="status"></p>
38 <a id="inspect-latest" target="_blank" rel="noopener" hidden>Open latest page</a>
39 <div class="edit-actions"><button type="button" id="cancel-edit">Cancel</button><button id="save-edit">Save</button></div>
40 </form>`;
41document.body.append(dialog);
42for (const name of ['Bold', 'Italic', 'Underline', 'Strike', 'Superscript', 'Subscript']) {
43 const label = document.createElement('label');
44 label.textContent = name === 'Strike' ? 'Strikethrough' : name;
45 const select = document.createElement('select');
46 select.dataset.attribute = name;
47 for (const [value, text] of [['', 'Keep'], ['true', 'On'], ['false', 'Off']]) select.add(new Option(text, value));
48 label.append(select);
49 dialog.querySelector('.format-grid').append(label);
50}
51const draft = document.querySelector('#replacement');
52const save = document.querySelector('#save-edit');
53const cancel = document.querySelector('#cancel-edit');
54const message = document.querySelector('#save-status');
55const inspect = document.querySelector('#inspect-latest');
56const parent = document.querySelector('#parent');
57const before = document.querySelector('#before');
58let selected = null;
59let opening = false;
60let submitting = false;
61let initial = '';
62
63mode.addEventListener('click', () => {
64 const editing = document.body.classList.toggle('editing');
65 mode.setAttribute('aria-pressed', String(editing));
66 mode.textContent = editing ? 'Stop editing' : 'Edit text';
67 for (const element of document.querySelectorAll('[data-text-object]')) {
68 if (editing) {
69 element.tabIndex = 0;
70 element.setAttribute('role', 'button');
71 element.setAttribute('aria-label', 'Edit text: ' + element.textContent);
72 } else {
73 element.removeAttribute('tabindex');
74 element.removeAttribute('role');
75 element.removeAttribute('aria-label');
76 }
77 }
78 status.textContent = editing ? 'Select a text run.' : '';
79});
80
81function showEdit(selection, text) {
82 dialog.querySelector('form').reset();
83 selected = selection;
84 const action = selection.action;
85 const titles = {text: 'Replace text', format: 'Format text', paragraph: 'Add paragraph', outline: 'Add outline'};
86 document.querySelector('#edit-heading').textContent = titles[action];
87 document.querySelector('#edit-hint').textContent = {
88 text: 'Replacement text keeps this run’s formatting.',
89 format: 'Select part of the text or leave the whole run selected. Unchanged attributes keep their current values.',
90 paragraph: 'Append to a container or insert before one of its children. Line breaks stay inside the new paragraph.',
91 outline: 'Add a text container to this page. Line breaks stay inside its first paragraph.'
92 }[action];
93 for (const [id, visible] of [['placement', action === 'paragraph'], ['coordinates', action === 'outline'], ['formatting', action === 'format']]) {
94 const field = document.getElementById(id);
95 field.hidden = !visible;
96 field.disabled = !visible;
97 }
98 document.querySelector('#author-label').hidden = !['paragraph', 'outline'].includes(action);
99 draft.value = text;
100 draft.readOnly = action === 'format';
101 save.disabled = false;
102 cancel.disabled = false;
103 inspect.hidden = true;
104 message.textContent = '';
105 status.textContent = '';
106 dialog.showModal();
107 draft.focus();
108 draft.setSelectionRange(0, text.length);
109 document.querySelector('#format-range').textContent = 'Selected: ' + text.length + ' UTF-16 units';
110 initial = JSON.stringify(requestBody());
111}
112
113async function openEdit(element) {
114 if (opening) return;
115 opening = true;
116 status.textContent = 'Checking…';
117 const selection = {generation: Number(route[1]), page: route[2],
118 object: element.dataset.textObject, run: Number(element.dataset.run)};
119 try {
120 const result = await (await fetch('/api/run?' + new URLSearchParams(selection))).json();
121 if (!result.ok) {
122 status.textContent = 'Edit not supported. ' + result.error;
123 return;
124 }
125 showEdit({...selection, action: document.querySelector('#text-action').value}, result.text);
126 } catch {
127 status.textContent = 'Unable to check this text. Reconnect to the diagnostic server.';
128 } finally {
129 opening = false;
130 }
131}
132
133for (const action of ['paragraph', 'outline']) document.querySelector('#add-' + action).addEventListener('click', async () => {
134 if (opening) return;
135 opening = true;
136 status.textContent = 'Loading…';
137 const selection = {generation: Number(route[1]), page: route[2], action};
138 try {
139 const result = await (await fetch('/api/page?' + new URLSearchParams(selection))).json();
140 if (!result.ok) throw new Error(result.error);
141 if (action === 'paragraph' && !result.targets.length) throw new Error('Add an outline before adding a paragraph.');
142 parent.replaceChildren(...result.targets.map(target => new Option(target.label, target.object)));
143 parent.onchange = () => {
144 const target = result.targets.find(target => target.object === parent.value);
145 before.replaceChildren(new Option('Append at end', ''), ...(target?.children || []).map(child => new Option(child.label, child.object)));
146 };
147 parent.onchange();
148 showEdit({...selection, object: result.object}, '');
149 } catch (error) {
150 status.textContent = 'Unable to add content. ' + error.message;
151 } finally {
152 opening = false;
153 }
154});
155
156function requestBody() {
157 const body = {...selected};
158 if (body.action === 'text') body.replacement = draft.value;
159 if (body.action === 'format') {
160 body.start = draft.selectionStart;
161 body.end = draft.selectionEnd;
162 body.attributes = [...dialog.querySelectorAll('[data-attribute]')].filter(select => select.value !== '')
163 .map(select => ({[select.dataset.attribute]: select.value === 'true'}));
164 const font = document.querySelector('#font').value;
165 const size = document.querySelector('#font-size').value;
166 if (font) body.attributes.push({Font: font});
167 if (size) body.attributes.push({FontSize: Number(size)});
168 for (const [field, attribute] of [['color', 'Color'], ['highlight', 'Highlight']]) {
169 const mode = document.getElementById(field + '-mode').value;
170 if (mode !== 'keep') body.attributes.push({[attribute]: mode === 'clear' ? null : document.getElementById(field).value.slice(1).match(/../g).map(hex => parseInt(hex, 16))});
171 }
172 }
173 if (['paragraph', 'outline'].includes(body.action)) {
174 body.text = draft.value.replace(/\n/g, '\r');
175 body.author = document.querySelector('#author').value;
176 if (body.action === 'paragraph') {
177 body.object = parent.value;
178 body.before = before.value || null;
179 } else {
180 body.x = document.querySelector('#outline-x').valueAsNumber;
181 body.y = document.querySelector('#outline-y').valueAsNumber;
182 }
183 }
184 return body;
185}
186
187draft.addEventListener('select', () => {
188 document.querySelector('#format-range').textContent = 'Selected: ' + (draft.selectionEnd - draft.selectionStart) + ' UTF-16 units';
189});
190document.addEventListener('click', event => {
191 const element = event.target.closest('[data-text-object]');
192 if (element && document.body.classList.contains('editing')) {
193 event.preventDefault();
194 openEdit(element);
195 }
196});
197document.addEventListener('keydown', event => {
198 if (event.target.matches('[data-text-object]') && document.body.classList.contains('editing') && ['Enter', ' '].includes(event.key)) {
199 event.preventDefault();
200 openEdit(event.target);
201 }
202});
203cancel.addEventListener('click', () => dialog.close());
204dialog.addEventListener('cancel', event => { if (submitting) event.preventDefault(); });
205dialog.addEventListener('close', () => { selected = null; });
206window.addEventListener('beforeunload', event => {
207 if (selected && JSON.stringify(requestBody()) !== initial) event.preventDefault();
208});
209dialog.querySelector('form').addEventListener('submit', async event => {
210 event.preventDefault();
211 if (save.disabled || !selected) return;
212 const body = requestBody();
213 save.disabled = true;
214 cancel.disabled = true;
215 submitting = true;
216 for (const control of dialog.querySelectorAll('input, select, textarea')) control.disabled = true;
217 message.textContent = 'Saving…';
218 let result;
219 try {
220 result = await (await fetch('/api/save', {method: 'POST', headers: {
221 'Content-Type': 'application/json', 'X-OneNote-Diagnostic': '1'
222 }, body: JSON.stringify(body)})).json();
223 } catch {
224 result = {ok: false, state: 'Unknown'};
225 }
226 submitting = false;
227 cancel.disabled = false;
228 for (const control of dialog.querySelectorAll('input, select, textarea')) control.disabled = false;
229 if (result.ok) {
230 selected = null;
231 location.assign(result.location);
232 return;
233 }
234 inspect.href = '/latest?' + new URLSearchParams({generation: selected.generation, page: selected.page});
235 inspect.hidden = false;
236 if (result.state === 'Unknown') {
237 message.textContent = 'Save outcome unknown. Keep this draft and inspect the latest page before trying again.';
238 } else if (result.state === 'Committed') {
239 message.textContent = 'Saved, but cleanup or refresh did not finish. Inspect the latest page; do not save this draft again.';
240 } else if (result.kind === 'ResourceBusy') {
241 message.textContent = 'This section changed. Open the latest page before editing again. Your draft is still here.';
242 } else {
243 message.textContent = 'Unable to save. ' + (result.error || result.report_error || 'Check this edit and try again.');
244 save.disabled = false;
245 }
246});