Skip to content

Commit

Permalink
Rate-limit editor storage writes.
Browse files Browse the repository at this point in the history
 - Propagate editor storage changes to other tabs.
  • Loading branch information
rblank committed Dec 14, 2024
1 parent af1f776 commit 7f53874
Show file tree
Hide file tree
Showing 3 changed files with 157 additions and 94 deletions.
115 changes: 55 additions & 60 deletions tdoc/common/scripts/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import * as commands from '@codemirror/commands';
import * as language from '@codemirror/language';
import * as lint from '@codemirror/lint';
import * as search from '@codemirror/search';
import * as state from '@codemirror/state';
import * as view from '@codemirror/view';
import * as cmstate from '@codemirror/state';
import * as cmview from '@codemirror/view';

import {oneDark} from '@codemirror/theme-one-dark';

Expand All @@ -17,11 +17,13 @@ import {javascript} from '@codemirror/lang-javascript';
import {python} from '@codemirror/lang-python';
import {sql} from '@codemirror/lang-sql';

export {cmstate, cmview};

const languages = {css, html, javascript, python, sql};

// React to theme changes and update all editor themes as well.
const theme = new state.Compartment();
const lightTheme = view.EditorView.theme({}, {dark: false});
const theme = new cmstate.Compartment();
const lightTheme = cmview.EditorView.theme({}, {dark: false});
const darkTheme = oneDark;

function currentTheme() {
Expand All @@ -43,66 +45,59 @@ const obs = new MutationObserver((mutations) => {
obs.observe(document.documentElement,
{attributes: true, attributeFilter: ['data-theme']});

// Return the editor extensions for the given config.
function extensions(config) {
const exts = [
// The default extensions appended to the user-provided ones.
const defaultExtensions = [
autocomplete.autocompletion(),
autocomplete.closeBrackets(),
commands.history(),
language.bracketMatching(),
language.foldGutter(),
language.indentOnInput(),
language.indentUnit.of(' '),
language.syntaxHighlighting(language.defaultHighlightStyle,
{fallback: true}),
search.highlightSelectionMatches(),
cmstate.EditorState.allowMultipleSelections.of(true),
cmstate.EditorState.tabSize.of(2),
cmview.crosshairCursor(),
cmview.drawSelection(),
cmview.dropCursor(),
cmview.highlightActiveLine(),
cmview.highlightActiveLineGutter(),
cmview.highlightSpecialChars(),
cmview.keymap.of([
...autocomplete.closeBracketsKeymap,
...autocomplete.completionKeymap,
...commands.defaultKeymap,
...commands.historyKeymap,
commands.indentWithTab,
...language.foldKeymap,
...lint.lintKeymap,
...search.searchKeymap,
]),
cmview.lineNumbers(),
cmview.rectangularSelection(),
cmview.EditorView.lineWrapping,
];

// Create a new editor.
export function newEditor(config) {
if (!config.extensions) config.extensions = [];
config.extensions.push(
theme.of(currentTheme()),
autocomplete.autocompletion(),
autocomplete.closeBrackets(),
commands.history(),
language.bracketMatching(),
language.foldGutter(),
language.indentOnInput(),
language.indentUnit.of(' '),
language.syntaxHighlighting(language.defaultHighlightStyle,
{fallback: true}),
search.highlightSelectionMatches(),
state.EditorState.allowMultipleSelections.of(true),
state.EditorState.tabSize.of(2),
view.crosshairCursor(),
view.drawSelection(),
view.dropCursor(),
view.highlightActiveLine(),
view.highlightActiveLineGutter(),
view.highlightSpecialChars(),
view.keymap.of([
...config.keymap || [],
...autocomplete.closeBracketsKeymap,
...autocomplete.completionKeymap,
...commands.defaultKeymap,
...commands.historyKeymap,
commands.indentWithTab,
...language.foldKeymap,
...lint.lintKeymap,
...search.searchKeymap,
]),
view.lineNumbers(),
view.rectangularSelection(),
view.EditorView.lineWrapping,
(languages[config.language] || (() => []))(),
];
if (config.onUpdate) {
exts.push(view.ViewPlugin.fromClass(class {
update(...args) { return config.onUpdate(...args); }
}));
...defaultExtensions,
);
if (config.language) {
const lang = languages[config.language];
if (lang) config.extensions.push(lang());
delete config.language;
}
return exts;
}

// Add an editor to the given element.
export function addEditor(parent, config) {
const editor = new view.EditorView({
doc: config.text || '',
extensions: extensions(config),
parent,
});
const node = parent.querySelector('div.cm-editor');
node.tdocEditor = editor;
return [editor, node];
const editor = new cmview.EditorView(config);
editor.dom.tdocEditor = editor;
return editor;
}

// Find an editor in or below the given element.
export function findEditor(el) {
const node = el.querySelector('div.cm-editor');
return [node?.tdocEditor, node];
return el.querySelector('div.cm-editor')?.tdocEditor;
}
22 changes: 22 additions & 0 deletions tdoc/common/static/tdoc/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,25 @@ export function docPath(path) {
if (path.endsWith('/')) path += 'index';
return path;
}

// Rate-limit function calls. Scheduled functions must be droppable, i.e. all
// calls in a sequence except the last one can be dropped.
export class RateLimited {
constructor(interval) { this.interval = interval; }

// Schedule a function. It will be called after "interval" at the latest.
// Scheduling a new function while the previous one hasn't been run yet
// replaces the previous one.
schedule(fn) {
const active = this.fn;
this.fn = fn;
if (!active) setTimeout(() => this.flush(), this.interval);
}

// Call the current scheduled function immediately.
flush() {
const fn = this.fn;
delete this.fn;
if (fn) fn();
}
}
114 changes: 80 additions & 34 deletions tdoc/common/static/tdoc/exec.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
// Copyright 2024 Remy Blank <remy@c-space.org>
// SPDX-License-Identifier: MIT

import {docPath, domLoaded, text, element} from './core.js';
import {addEditor, findEditor} from './editor.js';

// TODO: Listen for localStorage updates and update editors
import {docPath, domLoaded, element, RateLimited, text} from './core.js';
import {cmstate, cmview, findEditor, newEditor} from './editor.js';

// An error that is caused by the user, and that doesn't need to be logged.
export class UserError extends Error {
Expand Down Expand Up @@ -57,6 +55,9 @@ function fixLineNos(node) {
}
}

const storeUpdate = cmstate.Annotation.define();
const editorPrefix = `tdoc:editor:${docPath()}:`;

// A base class for {exec} block handlers.
export class Executor {
static next_run_id = 0;
Expand All @@ -69,6 +70,7 @@ export class Executor {
`div.tdoc-exec.highlight-${cls.lang}`)) {
fixLineNos(node);
const handler = new cls(node);
node.tdocExec = handler;
if (handler.editable) handler.addEditor();
const controls = element(`<div class="tdoc-exec-controls"></div>`);
handler.addControls(controls);
Expand All @@ -88,15 +90,12 @@ export class Executor {
// Return the text content of the editor associated with a node if an editor
// was added, or the content of the <pre> tag.
static text(node) {
const [editor, _] = findEditor(node);
return editor ? editor.state.doc.toString() : this.preText(node);
const view = findEditor(node);
return view ? view.state.doc.toString() : this.preText(node);
}

constructor(node) {
this.node = node;
if (this.editable && node.dataset.tdocEditor !== '') {
this.editorId = `${docPath()}:${node.dataset.tdocEditor}`;
}
this.when = node.dataset.tdocWhen;
this.origText = Executor.preText(this.node).trim();
}
Expand All @@ -105,37 +104,70 @@ export class Executor {
get editable() { return this.node.dataset.tdocEditor !== undefined; }

// The name of the local storage key for the editor content.
get editorKey() { return `tdoc:editor:${this.editorId}`; }
get editorKey() {
const editor = this.node.dataset.tdocEditor;
return editor ? editorPrefix + editor : undefined;
}

// Add an editor to the {exec} block.
addEditor() {
let text = this.origText;
if (this.editorId) {
const st = localStorage.getItem(this.editorKey);
if (st !== null) text = st;
const extensions = [];
if (this.when !== 'never') {
extensions.push(cmview.keymap.of([
{key: "Shift-Enter", run: () => this.doRun() || true },
]));
}
const [_, node] = addEditor(this.node.querySelector('div.highlight'), {
let doc = this.origText;
const key = this.editorKey;
if (key) {
const st = localStorage.getItem(key);
if (st !== null) doc = st;
this.storeEditor = new RateLimited(5000);
const exec = this;
extensions.push(
cmview.ViewPlugin.fromClass(class {
update(update) { return exec.onEditorUpdate(update); }
}),
cmview.EditorView.domEventObservers({
'blur': () => this.storeEditor.flush(),
}),
);
}
const view = newEditor({
extensions, doc,
language: this.constructor.lang,
keymap: this.when !== 'never' ?
[{key: "Shift-Enter", run: () => this.doRun() || true }] : [],
onUpdate: this.editorId ?
update => this.onEditorUpdate(update) : undefined,
text,
parent: this.node.querySelector('div.highlight'),
});
node.setAttribute('style',
this.node.querySelector('pre').getAttribute('style'));
view.dom.setAttribute(
'style', this.node.querySelector('pre').getAttribute('style'));
}

// Called on every editor update.
onEditorUpdate(update) {
if (update.docChanged) {
const doc = update.state.doc.toString();
if (doc !== this.origText) {
localStorage.setItem(this.editorKey, doc);
if (!update.docChanged) return;
for (const tr of update.transactions) {
if (tr.annotation(storeUpdate)) return;
}
const doc = update.state.doc;
this.storeEditor.schedule(() => {
const txt = doc.toString();
if (txt !== this.origText) {
localStorage.setItem(this.editorKey, txt);
} else {
localStorage.removeItem(this.editorKey);
}
}
});
}

// Replace the text of the editor, attaching the given annotations to the
// transaction.
setEditorText(text, annotations) {
const view = findEditor(this.node)
if (!view) return;
view.dispatch(view.state.update({
changes: {from: 0, to: view.state.doc.length, insert: text},
annotations,
}));
}

// Add controls to the {exec} block.
Expand Down Expand Up @@ -168,13 +200,7 @@ export class Executor {
const ctrl = element(`
<button class="fa-rotate-left tdoc-reset"\
title="Reset editor content"></button>`);
ctrl.addEventListener('click', () => {
const [editor, _] = findEditor(this.node), state = editor.state;
editor.dispatch(state.update({changes: {
from: 0, to: state.doc.length,
insert: this.origText,
}}));
});
ctrl.addEventListener('click', () => this.setEditorText(this.origText));
return ctrl;
}

Expand Down Expand Up @@ -278,3 +304,23 @@ export class Executor {
if (style) el.setAttribute('style', style);
}
}

// Ensure that the text of editors is stored before navigating away.
addEventListener('beforeunload', () => {
for (const node of document.querySelectorAll(
'div.tdoc-exec[data-tdoc-editor]')) {
const storer = node.tdocExec.storeEditor;
if (storer) storer.flush();
}
});

// Update the text of editors when their stored content changes.
addEventListener('storage', e => {
if (e.storageArea !== localStorage) return;
if (!e.key.startsWith(editorPrefix)) return;
const name = e.key.slice(editorPrefix.length);
const node = document.querySelector(
`div.tdoc-exec[data-tdoc-editor="${CSS.escape(name)}"]`);
if (!node) return;
node.tdocExec.setEditorText(e.newValue, [storeUpdate.of(true)]);
});

0 comments on commit 7f53874

Please sign in to comment.