1
Fork 0

Add the Markdown Toolbar Editor.

This commit is contained in:
Bauke 2023-11-25 12:37:55 +01:00
parent e752853a58
commit 75cd843d27
Signed by: Bauke
GPG Key ID: C1C0F29952BCF558
7 changed files with 673 additions and 5 deletions

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tildes ReExtended</title>
<link rel="shortcut icon" href="/tildes-reextended.png"
type="image/png">
</head>
<body>
<noscript>
This web extension does not work without JavaScript, sorry. :(
</noscript>
<script type="module" src="/options/markdown-toolbar-editor.js"></script>
</body>
</html>

View File

@ -78,6 +78,7 @@ const options: esbuild.BuildOptions = {
},
entryPoints: [
path.join(sourceDir, "background/setup.ts"),
path.join(sourceDir, "options/markdown-toolbar-editor.tsx"),
path.join(sourceDir, "options/setup.tsx"),
path.join(sourceDir, "options/user-label-editor.tsx"),
path.join(sourceDir, "content-scripts/setup.tsx"),

View File

@ -15,11 +15,12 @@ export function MarkdownToolbarSetting(props: SettingProps): JSX.Element {
/>
/spoilerbox syntax. If you have text selected, the Markdown will be
inserted around your text.
<br />A full list of the snippets is available{" "}
<Link
url="https://gitlab.com/tildes-community/tildes-reextended/-/issues/12"
text="on GitLab"
/>
<br />
You can edit the available snippets and their position in the toolbar
using the{" "}
<a href="/options/markdown-toolbar-editor.html">
Markdown Toolbar Editor
</a>
.
</p>
</Setting>

View File

@ -0,0 +1,465 @@
import {Component, type JSX, render, type RefObject, createRef} from "preact";
import {type Value} from "@holllo/webextension-storage";
import {initializeGlobals, log, Link} from "../utilities/exports.js";
import {
type MarkdownSnippet,
collectMarkdownSnippets,
createValueMarkdownSnippet,
newMarkdownSnippetId,
} from "../storage/exports.js";
import {runMarkdownToolbarFeature} from "../content-scripts/features/markdown-toolbar.js";
import "../scss/index.scss";
import "../scss/markdown-toolbar-editor.scss";
window.addEventListener("load", async () => {
initializeGlobals();
render(<App />, document.body);
});
type Props = Record<string, unknown>;
type State = {
/**
* Snippets are stored as an array of tuples with the {@linkcode Value}-wrapped
* {@linkcode MarkdownSnippet} as the first item and a {@linkcode RefObject} to
* the {@linkcode SnippetEditor} component. This is done so we can have the
* "Save All" button in the main component but have the logic for saving in
* the editor components.
*/
snippets: Array<[Value<MarkdownSnippet>, RefObject<SnippetEditor>]>;
};
class App extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
snippets: [],
};
}
async componentDidMount() {
const snippets = await collectMarkdownSnippets();
this.setState({
snippets: snippets.map((snippet) => [snippet, createRef()]),
});
}
componentDidUpdate() {
// Each time the main component updates we want to re-run the toolbar setup
// so the snippets are all updated and in their correct places.
runMarkdownToolbarFeature(
this.state.snippets
.map(([snippet, _ref]) => snippet)
.filter((snippet) => snippet.value.enabled),
);
}
addSnippet = async () => {
const id = await newMarkdownSnippetId();
const snippet = await createValueMarkdownSnippet({
enabled: true,
id,
inDropdown: false,
markdown: "",
name: `Snippet ${id}`,
position: 1,
});
await snippet.save();
const {snippets} = this.state;
snippets.push([snippet, createRef()]);
this.setState({snippets});
};
applyAndReload = async () => {
for (const [snippet, ref] of this.state.snippets) {
if (ref.current === null) {
throw new Error(
"SnippetEditor reference is null, this should be unreachable!",
);
}
const editor = ref.current;
if (editor.state.toBeRemoved) {
await snippet.remove();
continue;
}
if (editor.state.hasUnsavedChanges) {
await ref.current.save();
}
}
await this.componentDidMount();
};
render() {
const {snippets} = this.state;
return (
<>
<header class="page-header">
<h1>
<img src="/tildes-reextended.png" />
Markdown Toolbar Editor
</h1>
</header>
<main class="page-main markdown-toolbar-editor">
<h2>Toolbar Preview</h2>
<p class="info">
The Toolbar Preview lets you test out your snippets here directly
without having to go to Tildes, with the only difference being that
rendering the Markdown isn't possible here.
</p>
{/* The key attribute makes it so the mock re-renders on every update. */}
<MockMarkdownTextarea key={`mock-${Date.now()}`} />
<div class="snippets-title">
<h2>Snippets</h2>
<button class="add-new-snippet" onClick={this.addSnippet}>
New Snippet
</button>
<button
class="apply-and-reload-snippets"
onClick={this.applyAndReload}
>
Apply & Reload
</button>
</div>
<details class="snippet-usage-guide">
<summary>Usage Guide</summary>
<div class="inner">
<p>
Here you can create your own snippets and customize your
toolbar, each snippet has a number of configurable values:
</p>
<ul>
<li>
<b>Position</b>, the number next to the snippet name
determines in what order they will be placed in the toolbar.
Snippets with the same position will be sorted alphabetically.
</li>
<li>
<b>Name</b>, the name of the snippet to display in the
toolbar.
</li>
<li>
<b>Enable</b>, whether the snippet should be added to the
toolbar.
</li>
<li>
<b>Display in the "More..." dropdown</b>, with this enabled
the snippet will be placed in the "More..." dropdown following
the same sorting rules as normal.
</li>
<li>
<b>Snippet (Markdown)</b>, the snippet text itself in
Markdown.
</li>
</ul>
<p>
There are also a few markers that will do special things when
used in a snippet:
</p>
<ul>
<li>
The <code>&lt;cursor&gt;</code> marker indicates where the
cursor should be positioned after inserting the snippet. If
this marker isn't used the cursor will be placed at the end of
the snippet.
</li>
<li>
The <code>&lt;selected-cursor&gt;</code> marker is used for
when you have text selected, placing the cursor at this
location and inserting the selected text in the snippet at the{" "}
<code>&lt;cursor&gt;</code> position. There is currently{" "}
<Link
text="a known bug"
url="https://gitlab.com/tildes-community/tildes-reextended/-/issues/47"
/>{" "}
when this marker is placed before the{" "}
<code>&lt;cursor&gt;</code> marker, causing the cursor
position to be incorrectly placed after inserting the snippet.
</li>
</ul>
<p>
To reload the toolbar after you've made changes click the Apply
& Reload button. This will save all the snippets and recreate
the toolbar with your changes. Any snippets with unsaved changes
will have a yellow border and snippets that are going to be
removed will have a red border.
</p>
<p>
To remove a snippet click the Remove button, this will remove it
from storage but keep it loaded in the page. You can then click
the Apply & Reload button to permanently remove it or click the
Save or Undo buttons to get it back into storage.
</p>
</div>
</details>
{snippets.map(([snippet, ref]) => (
<SnippetEditor key={snippet.value.id} ref={ref} snippet={snippet} />
))}
</main>
</>
);
}
}
type SnippetEditorProps = {
snippet: Value<MarkdownSnippet>;
};
type SnippetEditorState = {
enabled: MarkdownSnippet["enabled"];
hasUnsavedChanges: boolean;
inDropdown: MarkdownSnippet["inDropdown"];
markdown: MarkdownSnippet["markdown"];
name: MarkdownSnippet["name"];
position: MarkdownSnippet["position"];
toBeRemoved: boolean;
};
class SnippetEditor extends Component<SnippetEditorProps, SnippetEditorState> {
constructor(props: SnippetEditorProps) {
super(props);
const {enabled, inDropdown, markdown, name, position} = props.snippet.value;
this.state = {
enabled,
hasUnsavedChanges: false,
inDropdown,
markdown,
name,
position,
toBeRemoved: false,
};
}
edit = <K extends keyof SnippetEditorState>(
key: K,
input: HTMLInputElement | HTMLTextAreaElement,
) => {
const unsavedChanges: Partial<SnippetEditorState> = {
hasUnsavedChanges: true,
};
if (key === "position") {
this.setState({
...unsavedChanges,
[key]: Number(input.value),
});
} else if (
["enabled", "inDropdown"].includes(key) &&
input instanceof HTMLInputElement
) {
this.setState({
...unsavedChanges,
[key]: input.checked,
});
} else {
this.setState({
...unsavedChanges,
[key]: input.value,
});
}
};
save = async () => {
let {snippet} = this.props;
const {enabled, inDropdown, markdown, name, position, toBeRemoved} =
this.state;
snippet.value.enabled = enabled;
snippet.value.inDropdown = inDropdown;
snippet.value.markdown = markdown;
snippet.value.name = name;
snippet.value.position = position;
const isBuiltin = snippet.value.id < 0;
if (isBuiltin || toBeRemoved) {
// If the snippet is a builtin one, then remove it from storage and assign
// it a new ID indicating it was edited.
// If it was marked for removal then we also need to assign a new ID
// because it's possible a new snippet was assigned the old ID while this
// one was removed.
const id = await newMarkdownSnippetId();
if (isBuiltin) {
await snippet.remove();
}
snippet = await createValueMarkdownSnippet({
...snippet.value,
id,
});
}
this.props.snippet = snippet;
await this.props.snippet.save();
this.setState({hasUnsavedChanges: false, toBeRemoved: false});
};
remove = async () => {
const toBeRemoved = !this.state.toBeRemoved;
if (toBeRemoved) {
await this.props.snippet.remove();
this.setState({toBeRemoved});
} else {
await this.save();
}
};
render() {
const {
enabled,
hasUnsavedChanges,
inDropdown,
markdown,
name,
position,
toBeRemoved,
} = this.state;
const onEdit = <K extends keyof SnippetEditorState>(
event: Event,
key: K,
) => {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
this.edit(key, event.target);
} else {
log("Tried to edit field with unknown event target type", true);
log(event, true);
}
};
const editorClasses = [
"snippet-editor",
hasUnsavedChanges ? "unsaved-changes" : "",
toBeRemoved ? "to-be-removed" : "",
].join(" ");
return (
<div class={editorClasses}>
<div class="top-controls">
<input
class="snippet-position"
type="number"
placeholder="Snippet Position"
title="Snippet Position"
value={position}
onInput={(event) => {
onEdit(event, "position");
}}
/>
<input
class="snippet-name"
type="text"
placeholder="Snippet Name"
title="Snippet Name"
value={name}
onInput={(event) => {
onEdit(event, "name");
}}
/>
<label class="snippet-enabled">
Enable{" "}
<input
type="checkbox"
checked={enabled}
onClick={(event) => {
onEdit(event, "enabled");
}}
/>
</label>
<label class="snippet-in-dropdown">
Display in the "More..." dropdown{" "}
<input
type="checkbox"
checked={inDropdown}
onClick={(event) => {
onEdit(event, "inDropdown");
}}
/>
</label>
</div>
<textarea
class="snippet-markdown"
placeholder="Snippet (Markdown)"
title="Snippet (Markdown)"
onInput={(event) => {
onEdit(event, "markdown");
}}
>
{markdown}
</textarea>
<button class="snippet-save" onClick={this.save}>
Save
</button>
<button
class={`snippet-remove destructive ${
toBeRemoved ? "to-be-removed" : ""
}`}
onClick={this.remove}
>
{toBeRemoved ? "Undo" : "Remove"}
</button>
</div>
);
}
}
/**
* Create a mocked version of the Markdown `<textarea>` for topics and comments.
* The HTML is a stripped down version of the `markdown_textarea` Jinja macro
* from the Tildes source (link below). If you end up changing this make sure
* that the Markdown Toolbar content script code is adapted too, since that's
* what is used for both attaching the toolbar here and on Tildes itself.
* https://gitlab.com/tildes/tildes/-/blob/d0d6b6d3dc8e31c94cb3c0cab7aecdd835b3836b/tildes/tildes/templates/macros/forms.jinja2#L4-33
*/
function MockMarkdownTextarea(): JSX.Element {
return (
<div class="form-markdown">
<header>
<menu class="tab tab-markdown-mode">
<li class="tab-item">
<button class="btn active">Edit</button>
</li>
<li class="tab-item">
<button class="btn" disabled>
Preview
</button>
</li>
</menu>
<Link
text="Formatting help"
url="https://docs.tildes.net/instructions/text-formatting"
/>
</header>
<textarea
class="form-input"
name="markdown"
placeholder="Text (Markdown)"
></textarea>
</div>
);
}

View File

@ -52,6 +52,7 @@ details {
}
.main-wrapper,
.markdown-toolbar-editor,
.page-header,
.page-footer,
.user-label-editor {

View File

@ -0,0 +1,177 @@
@use "button";
.markdown-toolbar-editor {
h2 {
margin-bottom: 4px;
}
.info {
border: 1px solid var(--blue);
margin-bottom: 4px;
padding: 8px;
}
.form-markdown {
border: 1px solid var(--blue);
margin-bottom: 4px;
padding: 8px;
header {
align-items: center;
display: flex;
}
select {
background-color: var(--background-primary);
border: 1px solid var(--foreground);
color: var(--foreground);
margin-left: 4px;
margin-right: auto;
padding: 8px;
}
textarea {
background-color: var(--background-primary);
border: 1px solid var(--foreground);
color: var(--foreground);
height: 12rem;
padding: 4px;
width: 100%;
}
.btn {
background-color: transparent;
border: none;
color: var(--blue);
padding: 8px;
&:hover {
cursor: pointer;
}
&[disabled] {
cursor: not-allowed;
filter: grayscale(100%);
}
&.active {
border-bottom: 3px solid var(--blue);
}
}
.tab.tab-markdown-mode {
align-items: center;
border-bottom: 1px solid var(--foreground);
display: inline-flex;
flex-wrap: wrap;
list-style: none;
margin-bottom: 4px;
margin-top: 4px;
padding: 0;
& + a {
margin-left: auto;
}
}
}
.snippets-title {
align-items: center;
display: flex;
margin-bottom: 8px;
margin-top: 8px;
h2 {
margin-right: auto;
}
}
.snippet-usage-guide {
code {
background-color: var(--background-primary);
}
p {
margin-bottom: 4px;
}
ul {
margin-left: 2rem;
&:not(:last-child) {
margin-bottom: 8px;
}
}
}
.add-new-snippet,
.apply-and-reload-snippets {
@include button.button;
}
.add-new-snippet {
margin-right: 8px;
}
.snippet-editor {
--save-status-color: var(--blue);
border: 1px solid var(--save-status-color);
margin-top: 8px;
padding: 8px;
&.unsaved-changes {
--save-status-color: var(--yellow);
}
&.to-be-removed {
--save-status-color: var(--red);
}
input,
textarea {
background-color: var(--background-primary);
border: 1px solid var(--blue);
color: var(--foreground);
padding: 8px;
}
.top-controls {
display: grid;
gap: 8px;
grid-template-columns: 6rem auto max-content max-content;
margin-bottom: 8px;
}
.snippet-enabled,
.snippet-in-dropdown {
border: 1px solid var(--blue);
padding: 8px;
}
.snippet-markdown {
height: 12rem;
margin-bottom: 8px;
width: 100%;
}
.snippet-remove,
.snippet-save {
@include button.button;
}
.snippet-save {
--button-accent: var(--yellow);
margin-right: 8px;
}
.snippet-remove {
&.to-be-removed {
--button-color: var(--foreground);
color: var(--background-primary);
}
}
}
}

View File

@ -8,5 +8,9 @@
margin-right: auto;
width: auto;
}
.tab.tab-markdown-mode + a {
margin-left: auto;
}
}
}