diff --git a/source/storage/enums.ts b/source/storage/enums.ts index 4466691..3a30774 100644 --- a/source/storage/enums.ts +++ b/source/storage/enums.ts @@ -35,6 +35,7 @@ export enum Data { EnabledFeatures = "enabled-features", KnownGroups = "known-groups", LatestActiveFeatureTab = "latest-active-feature-tab", + MarkdownSnippet = "markdown-snippet", MiscellaneousEnabledFeatures = "miscellaneous-enabled-features", OnSiteNewLabel = "on-site-new-label", RandomizeUsernameColors = "randomize-username-colors", diff --git a/source/storage/exports.ts b/source/storage/exports.ts index 802e813..3e409a9 100644 --- a/source/storage/exports.ts +++ b/source/storage/exports.ts @@ -13,6 +13,7 @@ import {collectUserLabels} from "./user-label.js"; export * from "./anonymize-usernames.js"; export * from "./enums.js"; export * from "./hide-topics.js"; +export * from "./markdown-toolbar.js"; export * from "./username-color.js"; export * from "./user-label.js"; diff --git a/source/storage/markdown-toolbar.ts b/source/storage/markdown-toolbar.ts new file mode 100644 index 0000000..8427917 --- /dev/null +++ b/source/storage/markdown-toolbar.ts @@ -0,0 +1,216 @@ +import browser from "webextension-polyfill"; +import {createValue, type Value} from "@holllo/webextension-storage"; +import {Data} from "./enums.js"; + +/** + * Definition of a Markdown snippet to be used with the Markdown Toolbar feature. + */ +export type MarkdownSnippet = { + /** Whether the snippet is enabled and should show in the toolbar. */ + enabled: boolean; + + /** A unique identifier for the snippet. */ + id: number; + + /** Whether the snippet should be placed in the "More..." dropdown. */ + inDropdown: boolean; + + /** The Markdown content of the snippet. */ + markdown: string; + + /** The name of the snippet to be shown in the toolbar. */ + name: string; + + /** The position */ + position: number; +}; + +/** + * Markers used to specify where the cursor should be placed after the snippet + * is inserted. + */ +export enum MarkdownSnippetMarker { + /** + * The marker for the default position the cursor should be placed at. + */ + Cursor = "", + + /** + * If text was selected before the snippet was inserted, the cursor will first + * attempt to be placed at the `SelectedCursor` position, if there is no + * `SelectedCursor` in the snippet then `Cursor` is used instead. The selected + * text will be inserted at the `Cursor` position. + */ + SelectedCursor = "", +} + +/* eslint-disable-next-line @typescript-eslint/naming-convention */ +const {Cursor, SelectedCursor} = MarkdownSnippetMarker; + +export const builtinSnippets: MarkdownSnippet[] = [ + { + enabled: true, + inDropdown: false, + markdown: `[${Cursor}](${SelectedCursor})`, + name: "Link", + }, + { + enabled: true, + inDropdown: false, + markdown: `\`\`\`${SelectedCursor}\n${Cursor}\n\`\`\``, + name: "Code", + }, + { + enabled: true, + inDropdown: false, + markdown: `~~${Cursor}~~`, + name: "Strikethrough", + }, + { + enabled: true, + inDropdown: false, + markdown: `
\nClick to expand spoiler.\n\n${Cursor}\n
`, + name: "Spoilerbox", + }, + { + enabled: true, + inDropdown: true, + markdown: `**${Cursor}**`, + name: "Bold", + }, + { + enabled: true, + inDropdown: true, + markdown: `\n\n---\n\n${Cursor}`, + name: "Horizontal Divider", + }, + { + enabled: true, + inDropdown: true, + markdown: `\`${Cursor}\``, + name: "Inline Code", + }, + { + enabled: true, + inDropdown: true, + markdown: `*${Cursor}*`, + name: "Italic", + }, + { + enabled: true, + inDropdown: true, + markdown: `1. ${Cursor}`, + name: "Ordered List", + }, + { + enabled: true, + inDropdown: true, + markdown: `${Cursor}`, + name: "Small", + }, + { + enabled: true, + inDropdown: true, + markdown: `* ${Cursor}`, + name: "Unordered List", + }, +].map((snippet, index) => { + return { + ...snippet, + // Make the builtin snippets use a negative ID so there will never be a + // conflict with user-created snippets if for some reason they need to be + // reinserted into storage. + id: index - 100, + position: index + 1, + } satisfies MarkdownSnippet; +}); + +/** + * Create a {@link Value}-wrapped {@link MarkdownSnippet}. + */ +export async function createValueMarkdownSnippet( + snippet: MarkdownSnippet, +): Promise> { + return createValue({ + deserialize: (input) => JSON.parse(input) as MarkdownSnippet, + serialize: (input) => JSON.stringify(input), + key: `${Data.MarkdownSnippet}-${snippet.id}`, + value: snippet, + storage: browser.storage.sync, + }); +} + +/** + * Get all Markdown snippets from storage and combine them into a single array. + */ +export async function collectMarkdownSnippets(): Promise< + Array> +> { + const storage = await browser.storage.sync.get(); + const snippets: Array> = []; + for (const [key, value] of Object.entries(storage)) { + if (!key.startsWith(Data.MarkdownSnippet)) { + continue; + } + + snippets.push( + await createValueMarkdownSnippet( + JSON.parse(value as string) as MarkdownSnippet, + ), + ); + } + + if (snippets.length === 0) { + // If no snippets are in storage, grab all the builtin ones and save them. + snippets.push( + ...(await Promise.all( + builtinSnippets.map(async (snippet) => + createValueMarkdownSnippet(snippet), + ), + )), + ); + + for (const snippet of snippets) { + await snippet.save(); + } + } + + return sortSnippets(snippets); +} + +/** + * Create a new user label ID by getting the current highest existing ID in + * storage and adding 1 to it. Defaults to 1 when no there are no existing + * labels. + */ +export async function newMarkdownSnippetId(): Promise { + const snippets = await collectMarkdownSnippets(); + let newId = 1; + if (snippets.length > 0) { + newId = snippets.sort((a, b) => b.value.id - a.value.id)[0].value.id + 1; + } + + // Builtin snippets will have a negative ID so reset back to 1 if only builtin + // snippets are in storage. + if (newId < 0) { + newId = 1; + } + + return newId; +} + +/** + * Sort the snippets by their position first and their name second. + */ +export function sortSnippets( + snippets: Array>, +): Array> { + return snippets.sort((a, b) => { + const position = a.value.position - b.value.position; + if (position === 0) { + return a.value.name.localeCompare(b.value.name); + } + + return position; + }); +}