Add the Markdown Toolbar Editor.
This commit is contained in:
parent
e752853a58
commit
75cd843d27
|
@ -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>
|
|
@ -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"),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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><cursor></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><selected-cursor></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><cursor></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><cursor></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>
|
||||
);
|
||||
}
|
|
@ -52,6 +52,7 @@ details {
|
|||
}
|
||||
|
||||
.main-wrapper,
|
||||
.markdown-toolbar-editor,
|
||||
.page-header,
|
||||
.page-footer,
|
||||
.user-label-editor {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,5 +8,9 @@
|
|||
margin-right: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.tab.tab-markdown-mode + a {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue