diff --git a/source/assets/options/markdown-toolbar-editor.html b/source/assets/options/markdown-toolbar-editor.html new file mode 100644 index 0000000..f72799f --- /dev/null +++ b/source/assets/options/markdown-toolbar-editor.html @@ -0,0 +1,19 @@ + + + + + + + Tildes ReExtended + + + + + + + + + diff --git a/source/build.ts b/source/build.ts index a0686dc..a3081f9 100644 --- a/source/build.ts +++ b/source/build.ts @@ -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"), diff --git a/source/options/components/markdown-toolbar.tsx b/source/options/components/markdown-toolbar.tsx index a04e17e..6c6f021 100644 --- a/source/options/components/markdown-toolbar.tsx +++ b/source/options/components/markdown-toolbar.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. -
A full list of the snippets is available{" "} - +
+ You can edit the available snippets and their position in the toolbar + using the{" "} + + Markdown Toolbar Editor + .

diff --git a/source/options/markdown-toolbar-editor.tsx b/source/options/markdown-toolbar-editor.tsx new file mode 100644 index 0000000..4cc1f07 --- /dev/null +++ b/source/options/markdown-toolbar-editor.tsx @@ -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(, document.body); +}); + +type Props = Record; + +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, RefObject]>; +}; + +class App extends Component { + 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 ( + <> + + +
+

Toolbar Preview

+

+ 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. +

+ + {/* The key attribute makes it so the mock re-renders on every update. */} + + +
+

Snippets

+ + +
+ +
+ Usage Guide + +
+

+ Here you can create your own snippets and customize your + toolbar, each snippet has a number of configurable values: +

+ +
    +
  • + Position, 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. +
  • +
  • + Name, the name of the snippet to display in the + toolbar. +
  • +
  • + Enable, whether the snippet should be added to the + toolbar. +
  • +
  • + Display in the "More..." dropdown, with this enabled + the snippet will be placed in the "More..." dropdown following + the same sorting rules as normal. +
  • +
  • + Snippet (Markdown), the snippet text itself in + Markdown. +
  • +
+ +

+ There are also a few markers that will do special things when + used in a snippet: +

+
    +
  • + The <cursor> 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. +
  • +
  • + The <selected-cursor> 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{" "} + <cursor> position. There is currently{" "} + {" "} + when this marker is placed before the{" "} + <cursor> marker, causing the cursor + position to be incorrectly placed after inserting the snippet. +
  • +
+ +

+ 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. +

+ +

+ 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. +

+
+
+ + {snippets.map(([snippet, ref]) => ( + + ))} +
+ + ); + } +} + +type SnippetEditorProps = { + snippet: Value; +}; + +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 { + 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 = ( + key: K, + input: HTMLInputElement | HTMLTextAreaElement, + ) => { + const unsavedChanges: Partial = { + 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 = ( + 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 ( +
+
+ { + onEdit(event, "position"); + }} + /> + + { + onEdit(event, "name"); + }} + /> + + + + +
+ + + + + + +
+ ); + } +} + +/** + * Create a mocked version of the Markdown ` + + ); +} diff --git a/source/scss/index.scss b/source/scss/index.scss index db4d1dd..ff778cc 100644 --- a/source/scss/index.scss +++ b/source/scss/index.scss @@ -52,6 +52,7 @@ details { } .main-wrapper, +.markdown-toolbar-editor, .page-header, .page-footer, .user-label-editor { diff --git a/source/scss/markdown-toolbar-editor.scss b/source/scss/markdown-toolbar-editor.scss new file mode 100644 index 0000000..e3e5fd9 --- /dev/null +++ b/source/scss/markdown-toolbar-editor.scss @@ -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); + } + } + } +} diff --git a/source/scss/scripts/_markdown-toolbar.scss b/source/scss/scripts/_markdown-toolbar.scss index ed296f3..7131bca 100644 --- a/source/scss/scripts/_markdown-toolbar.scss +++ b/source/scss/scripts/_markdown-toolbar.scss @@ -8,5 +8,9 @@ margin-right: auto; width: auto; } + + .tab.tab-markdown-mode + a { + margin-left: auto; + } } }