Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add improved built-in editing tools #119

Merged
merged 7 commits into from Jul 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/builtin-editing-tools.md
@@ -0,0 +1,122 @@
# Built-In Editing Tools

This document describes some of the inner workings of SweClockers' built-in editing tools.

## `insertAtCaret`

Text is inserted in the textarea by `Tanuki.Templates.Textarea.Helpers.insertAtCaret`, shown in this excerpt from `combine.min.js`, pretty-printed in Chrome and with some renamed variables for readability:

```javascript
d.insertAtCaret = function(textarea, before, after) {
textarea.focus();
var c = textarea.scrollTop;
if (after === undefined) {
after = ""
}
if (document.selection) {
// I think this is for legacy compatibility or something. /Alling
sel = document.selection.createRange();
sel.text = before + sel.text + after;
textarea.focus()
} else {
if (textarea.selectionStart || textarea.selectionStart === 0) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + before + textarea.value.substring(textarea.selectionStart, textarea.selectionEnd) + after + textarea.value.substring(end, textarea.value.length);
textarea.selectionStart = start + before.length + end - start;
textarea.selectionEnd = textarea.selectionStart;
textarea.focus();
textarea.scrollTop = c
} else {
textarea.value += before + after;
textarea.scrollTop = c
}
}
}
;
```

## `setSelection`

Text can also be inserted by `Tanuki.Templates.Textarea.Helpers.setSelection`, shown below – again pretty-printed and with renamed variables:

```javascript
d.setSelection = function(textarea, replacement) {
var range = d.getSelectionRange(textarea);
textarea.focus();
if (document.selection) {
// I think this is for legacy compatibility or something. /Alling
sel = document.selection.createRange();
sel.text = replacement;
sel.collapse()
} else {
if (textarea.selectionStart || textarea.selectionStart === 0) {
var start = textareac.selectionStart;
var end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end, textarea.value.length);
textarea.selectionStart = start + replacement.length + end - start;
textarea.selectionEnd = textarea.selectionStart
} else {
textarea.value += replacement
}
}
d.setSelectionRange(textarea, range[0], range[0] + replacement.length)
}
;
```

## The URL/hyperlink button

The URL button seems to be a bit of a special case:

```javascript
Main.Forms.Toolbar.Buttons = (function(e) {
var d = Main.Forms.Toolbar;
function f(a, b) {
d.LabelButton.call(this);
this.setTitle(a);
this.addClass(b);
this.setCallback(Taiga.Fn.proxy(this.insertTemplate, this))
}
Taiga.Fn.inherit(f, d.LabelButton);
(function(a) {
a.insertTemplate = function() {
var b = this.getSelection();
var o = b.split("\n");
if (b.length > 0 && o.length > 1) {
var m = [];
for (var i in o) {
var p = Taiga.Strings.trim(o[i]);
var p = Taiga.Strings.sprintf("[url]%s[/url]", p);
m.push(p)
}
this.setSelection(m.join("\n"));
this.clearSelection()
} else {
var n = "";
var c = "";
if (b.match(/^([a-z]+:\/\/|www\.)/i)) {
n = b
} else {
c = b
}
n = prompt("Skriv adressen (URL)", n);
c = prompt("Beskriv länken (valfritt)", c);
if (n === null || n.length < 1) {
return
} else {
if (c === null || c.length < 1) {
c = n
}
this.setSelection("[url=" + n + "]" + c + "[/url]", "");
this.clearSelection()
}
}
}
}
)(f.prototype);
e.HyperlinkButton = f;
return e
}
)(Main.Forms.Toolbar.Buttons || {});
```
19 changes: 19 additions & 0 deletions src/operations.ts
Expand Up @@ -25,6 +25,8 @@ import * as DarkTheme from "./operations/dark-theme";
import insertDraftModeToggle from "./operations/draft-mode-toggle";
import insertEditingTools from "./operations/editing-tools";
import insertHeadingToolbarButton from "./operations/heading-toolbar-button";
import enableImprovedBuiltinEditingTools from "./operations/improved-builtin-editing-tools";
import enableImprovedBuiltinEditingToolsUrl from "./operations/improved-builtin-editing-tools-url";
import adaptCorrectionsLink from "./operations/improved-corrections";
import * as keyboardShortcutsEditMode from "./operations/keyboard-shortcuts/edit-mode";
import insertLinkToTop from "./operations/link-to-top";
Expand All @@ -47,6 +49,9 @@ import insertWebSearchButton from "./operations/web-search-button";

const ALWAYS = true;

const improvedBuiltinEditingToolsDescription = "enable improved built-in editing tools";
const shouldEnableImprovedBuiltinEditingTools = (isInEditMode || isReadingThread) && Preferences.get(P.edit_mode._.improved_builtin_editing_tools);

// True here means the user wants us to act as if there is undo support (i.e. take no precautions to protect data).
// Chrome always has actual undo support and Firefox never does.
const undoSupport = Preferences.get(P.advanced._.undo_support);
Expand All @@ -57,6 +62,14 @@ const OPERATIONS: readonly Operation<any>[] = [
condition: () => ALWAYS,
action: () => { document.documentElement.id = CONFIG.ID.document; },
}),
operation({
// I haven't managed to make this operation work at all in Firefox; the default action is just not overridden.
description: improvedBuiltinEditingToolsDescription + " (URL/hyperlink button)",
condition: () => shouldEnableImprovedBuiltinEditingTools,
// This operation cannot be deferred until DOMContentLoaded, because then it can't override the default action for the URL button.
dependencies: { body: "body" }, // Defers the operation until SweClockers' script has defined Main (needed by the operation).
action: enableImprovedBuiltinEditingToolsUrl(undoSupport),
}),
operation({
description: "insert preferences menu",
condition: () => isOnBSCPreferencesPage,
Expand Down Expand Up @@ -123,6 +136,12 @@ const OPERATIONS: readonly Operation<any>[] = [
dependencies: { textarea: SELECTOR.textarea },
action: insertEditingTools(undoSupport),
}),
operation({
description: improvedBuiltinEditingToolsDescription,
condition: () => shouldEnableImprovedBuiltinEditingTools,
deferUntil: DOMCONTENTLOADED, // because Tanuki must be defined
action: enableImprovedBuiltinEditingTools,
}),
operation({
description: "insert heading toolbar button",
condition: () => isInEditMode && Preferences.get(P.edit_mode._.insert_heading_toolbar_button),
Expand Down
2 changes: 1 addition & 1 deletion src/operations/actions/shibe.ts
Expand Up @@ -10,7 +10,7 @@ import { insertIn, selectedTextIn } from "../logic/textarea";
export default function(textarea: HTMLTextAreaElement, undoSupport: boolean): void {
const selectedText = selectedTextIn(textarea);
const selectedLines = lines(selectedText);
if (undoSupport || !shibeConfirmationNeeded(selectedLines) || confirm(T.general.shibe_confirm(selectedLines.length))) { // `confirm` is problematic in Chrome (see docs/dialogs.md), but Chrome has full undo support.
if (undoSupport || !shibeConfirmationNeeded(selectedLines) || confirm(T.general.generic_lines_confirm(selectedLines.length))) { // `confirm` is problematic in Chrome (see docs/dialogs.md), but Chrome has full undo support.
insertIn(textarea, {
string: shibeText(selectedText),
replace: true,
Expand Down
87 changes: 87 additions & 0 deletions src/operations/improved-builtin-editing-tools-url.ts
@@ -0,0 +1,87 @@
import * as BB from "bbcode-tags";
import { lines, unlines } from "lines-unlines";
import { is } from "ts-type-guards";
import { log } from "userscripter";

import { insertIn, selectRangeIn, selectedTextIn } from "~src/operations/logic/textarea";
import SELECTOR from "~src/selectors";
import * as SITE from "~src/site";
import * as T from "~src/text";

declare namespace Main { const Forms: any; }
declare namespace Taiga { const Strings: any; }

export default (undoSupport: boolean) => () => {
/*
This operation must run "early" (before DOMContentLoaded), because otherwise it can't override the default action for the URL button.
However, it should not run _too_ early, because it fails if SweClockers' script hasn't yet defined Main.

I haven't managed to make this operation work at all in Firefox; the default action is just not overridden.
*/
try { // If SweClockers change their "API", we want to fail gracefully.
Main.Forms.Toolbar.Buttons.HyperlinkButton.prototype.insertTemplate = () => {
const textarea = document.querySelector(SELECTOR.textarea);
if (!is(HTMLTextAreaElement)(textarea)) {
log.error(`Could not find textarea.`);
return;
}
const selectedText = selectedTextIn(textarea);
const selectedLines = lines(selectedText);
if (selectedLines.length > 1) {
// Multiline mode (closely resembling SweClockers' native behavior).
let n: false | number; // Lets us short-circuit with undo support.
if (undoSupport || (n = confirmationNeeded(selectedLines), n === false) || confirm(T.general.generic_lines_confirm(n))) {
const formattedLines = selectedLines.map(line => (
Taiga.Strings.sprintf(
BB.start(SITE.TAG.url) + "%s" + BB.end(SITE.TAG.url),
Taiga.Strings.trim(line),
)
));
insertIn(textarea, {
string: unlines(formattedLines),
replace: true,
});
}
} else {
const suggestedLinkUrl = isUrlLike(selectedText) ? selectedText : ""; // Same behavior as SweClockers.
const suggestedLinkText = selectedText;
const providedLinkUrl = prompt(T.general.improved_url_button_url, suggestedLinkUrl);
if (providedLinkUrl === null) {
return; // Canceling altogether if the user cancels the prompt feels intuitive.
}
const providedLinkText = prompt(T.general.improved_url_button_text, suggestedLinkText);
if (providedLinkText === null) {
return; // Canceling altogether if the user cancels the prompt feels intuitive.
}
const finalLinkUrl = providedLinkUrl;
const finalLinkText = providedLinkText === "" ? finalLinkUrl : providedLinkText;
const selectionStart = textarea.selectionStart; // Must be calculated before any modification is made.
const startTag = BB.start(SITE.TAG.url, finalLinkUrl);
// We _could_ just wrap if the final link text equals the selectedText text, but AFAICT it wouldn't give us any benefits versus inserting and then selecting.
insertIn(textarea, {
string: startTag + finalLinkText + BB.end(SITE.TAG.url),
replace: true, // Risk of accidental deletion is low because the user is given two prompts before anything is modified in the textarea.
});
const newSelectionStart = selectionStart + startTag.length;
const newSelectionEnd = newSelectionStart + finalLinkText.length;
selectRangeIn(textarea, newSelectionStart, newSelectionEnd);
}
};
} catch (err) {
return err.toString(); // String conversion is necessary for the error to be handled properly by Userscripter.
}
};

// A heuristic intended to catch cases when the user likely didn't mean to format links and/or it would be cumbersome to restore the change without undo support.
function confirmationNeeded(selectedLines: readonly string[]): false | number {
return (
selectedLines.length > 5 && !selectedLines.every(isUrlLike)
? selectedLines.length
: false
);
}

function isUrlLike(s: string): boolean {
// The regex is copied from SweClockers.
return /^([a-z]+:\/\/|www\.)/i.test(s);
}
27 changes: 27 additions & 0 deletions src/operations/improved-builtin-editing-tools.ts
@@ -0,0 +1,27 @@
import { insertIn, wrapIn } from "./logic/textarea";

declare namespace Tanuki { const Templates: any; }

export default () => {
/*
SweClockers' built-in functions use `.value`, which kills undo history.
This operation replaces them with our own `text-field-edit`-based versions, thereby reintroducing undo/redo support in supported browsers.
Our versions sort of mimic the default ones, but they feature more intuitive behavior, such as keeping the selection when a wrap tool (e.g. B/U/I) is used.

We don't care about protecting data in the absence of undo support; I assume SweClockers only ever modify textarea content "safely" (i.e. without deleting anything).
*/
try { // If SweClockers change their "API", we want to fail gracefully.
Tanuki.Templates.Textarea.Helpers.setSelection = (textarea: HTMLTextAreaElement, replacement: string) => {
insertIn(textarea, { string: replacement, replace: true });
};
Tanuki.Templates.Textarea.Helpers.insertAtCaret = (textarea: HTMLTextAreaElement, before: string, after: string) => {
if (after === undefined) {
insertIn(textarea, { string: before, replace: true });
} else {
wrapIn(textarea, { before, after, cursor: "KEEP_SELECTION" });
}
};
} catch (err) {
return err.toString(); // String conversion is necessary for the error to be handled properly by Userscripter.
}
};
2 changes: 1 addition & 1 deletion src/operations/logic/textarea.ts
Expand Up @@ -102,7 +102,7 @@ export function placeCursorIn(textarea: HTMLTextAreaElement, position: number):
selectRangeIn(textarea, position, position);
}

function selectRangeIn(textarea: HTMLTextAreaElement, start: number, end: number): void {
export function selectRangeIn(textarea: HTMLTextAreaElement, start: number, end: number): void {
textarea.setSelectionRange(start, end);
textarea.focus(); // Must be after setSelectionRange to avoid scrolling to the bottom of the textarea in Chrome.
}
6 changes: 6 additions & 0 deletions src/preferences/edit-mode.ts
Expand Up @@ -76,6 +76,12 @@ export default {
description: T.preferences.in_quick_reply_form_description,
extras: { class: CONFIG.CLASS.inlinePreference },
}),
improved_builtin_editing_tools: new BooleanPreference({
key: "improved_builtin_editing_tools",
default: true,
label: T.preferences.edit_mode.improved_builtin_editing_tools,
description: T.preferences.edit_mode.improved_builtin_editing_tools_description,
}),
insert_heading_toolbar_button: new BooleanPreference({
key: "insert_heading_toolbar_button",
default: true,
Expand Down
6 changes: 5 additions & 1 deletion src/text.ts
Expand Up @@ -37,12 +37,14 @@ export const general = {
draft_mode_toggle_label,
draft_mode_toggle_tooltip: draft_mode_description,
draft_mode_enabled_tooltip: `Kryssa ur "${draft_mode_toggle_label}" för att posta`,
improved_url_button_url: `Adress (URL):`,
improved_url_button_text: `Länktext:`,
restore_draft_label: `Återställ`,
restore_draft_tooltip: `Återställ autosparat utkast`,
restore_draft_question: `Vill du återställa följande utkast?`,
restore_draft_confirm: `Din nuvarande text kommer ersättas. Är du säker?`,
nbsps_confirm: (n: number) => `${n} mellanslag kommer ersättas med hårda mellanslag. Är du säker?`,
shibe_confirm: (n: number) => `${n} markerad${n > 1 ? "e rader" : " rad"} kommer formateras. Är du säker?`,
generic_lines_confirm: (n: number) => `${n} markerad${n > 1 ? "e rader" : " rad"} kommer formateras. Är du säker?`,
// Copied from SweClockers:
signout_error: `Ett fel har uppstått och utloggningen misslyckades. Var god ladda om sidan och försök igen. Rensa cookies i din webbläsare för att logga ut manuellt.`,
quote_signature_label: `Citera sign.`,
Expand Down Expand Up @@ -133,6 +135,8 @@ export const preferences = {
monospace_font_description: `Underlättar formatering av kod och dylikt`,
keyboard_shortcuts: `Kortkommandon för att skicka (<kbd>Ctrl</kbd> + <kbd>S</kbd>) och förhandsgranska (<kbd>Ctrl</kbd> + <kbd>P</kbd>)`,
keyboard_shortcuts_description: `Skicka och förhandsgranska med tangentbordet`,
improved_builtin_editing_tools: `Förbättrade inbyggda redigeringsverktyg`,
improved_builtin_editing_tools_description: `Mer intuitiv funktionalitet och ångra-stöd (i webbläsare som stöder det)`,
insert_heading_toolbar_button: `Knapp för att formatera som rubrik`,
insert_heading_toolbar_button_description: `Knappen infogar BB-taggen ${BB.start(SITE.TAG.h)}`,
insert_table_toolbar_button: `Knapp för att infoga tabell`,
Expand Down