diff --git a/source/content-scripts/features/comment-anchor-fix.ts b/source/content-scripts/features/comment-anchor-fix.ts new file mode 100644 index 0000000..7644a3c --- /dev/null +++ b/source/content-scripts/features/comment-anchor-fix.ts @@ -0,0 +1,70 @@ +import {log, pluralize} from "../../utilities/exports.js"; + +export function runCommentAnchorFixFeature(): void { + const count = commentAnchorFix(); + if (count > 0) { + const pluralized = `${count} ${pluralize(count, "comment")}`; + log(`Comment Anchor Fix applied, uncollapsed ${pluralized}.`); + } +} + +/** + * Apply the comment anchor fix, uncollapsing any collapsed comments and + * scrolling the linked comment into view. + */ +function commentAnchorFix(): number { + const anchor = window.location.hash; + + // Linked comments follow the `#comment-` pattern. + if (!/^#comment-[a-z\d]+$/i.test(anchor)) { + return 0; + } + + // Conveniently, the anchor including the leading hash is a valid CSS selector + // for the target comment, so we can directly select it. + const targetComment = + document.querySelector(anchor) ?? undefined; + if (targetComment === undefined) { + return 0; + } + + const count = recursiveUncollapseParent( + // Start from the comment's first child so the target comment also has the + // collapse classes removed. The collapse styling accomodates for `:target` + // so this isn't technically necessary, but this way we make sure it stays + // uncollapsed even if the `:target` changes. It also makes the returned + // count correct as otherwise this comment wouldn't be included. + targetComment.firstElementChild ?? targetComment, + 0, + ); + if (count > 0) { + targetComment.scrollIntoView({behavior: "smooth"}); + } + + return count; +} + +/** + * Recursively go up the chain of comments uncollapsing each one until it + * reaches the `ol#comments` list or no parents remain, returning how many + * comments have been uncollapsed. + * @param target The current target to select its parent from. + */ +function recursiveUncollapseParent(target: Element, count: number): number { + const parent = target.parentElement ?? undefined; + if (parent === undefined || parent.id === "comments") { + return count; + } + + for (const collapsedClass of [ + "is-comment-collapsed", + "is-comment-collapsed-individual", + ]) { + if (parent.classList.contains(collapsedClass)) { + parent.classList.remove(collapsedClass); + count++; + } + } + + return recursiveUncollapseParent(parent, count); +} diff --git a/source/content-scripts/features/exports.ts b/source/content-scripts/features/exports.ts index f27d53a..4182f3d 100644 --- a/source/content-scripts/features/exports.ts +++ b/source/content-scripts/features/exports.ts @@ -1,6 +1,7 @@ export * from "./anonymize-usernames.js"; export * from "./autocomplete.js"; export * from "./back-to-top.js"; +export * from "./comment-anchor-fix.js"; export * from "./hide-votes.js"; export * from "./jump-to-new-comment.js"; export * from "./markdown-toolbar.js"; diff --git a/source/content-scripts/setup.tsx b/source/content-scripts/setup.tsx index 35a5ff6..981c4fb 100644 --- a/source/content-scripts/setup.tsx +++ b/source/content-scripts/setup.tsx @@ -1,12 +1,18 @@ import {type JSX, render} from "preact"; import {extractGroups, initializeGlobals, log} from "../utilities/exports.js"; -import {Feature, fromStorage, Data} from "../storage/exports.js"; +import { + Data, + Feature, + MiscellaneousFeature, + fromStorage, +} from "../storage/exports.js"; import { AutocompleteFeature, BackToTopFeature, JumpToNewCommentFeature, UserLabelsFeature, runAnonymizeUsernamesFeature, + runCommentAnchorFixFeature, runHideTopicsFeature, runHideVotesFeature, runMarkdownToolbarFeature, @@ -106,6 +112,11 @@ async function initialize() { }); } + const miscEnabled = await fromStorage(Data.MiscellaneousEnabledFeatures); + if (miscEnabled.value.has(MiscellaneousFeature.CommentAnchorFix)) { + runCommentAnchorFixFeature(); + } + // Initialize all the observer-dependent features first. await Promise.all(observerFeatures.map(async (feature) => feature())); diff --git a/source/options/components/exports.ts b/source/options/components/exports.ts index aa90ddf..de6af4d 100644 --- a/source/options/components/exports.ts +++ b/source/options/components/exports.ts @@ -6,6 +6,7 @@ export {HideTopicsSetting} from "./hide-topics.js"; export {HideVotesSetting} from "./hide-votes.js"; export {JumpToNewCommentSetting} from "./jump-to-new-comment.js"; export {MarkdownToolbarSetting} from "./markdown-toolbar.js"; +export {MiscellaneousSetting} from "./miscellaneous.js"; export {ThemedLogoSetting} from "./themed-logo.js"; export {UserLabelsSetting} from "./user-labels.js"; export {UsernameColorsSetting} from "./username-colors.js"; diff --git a/source/options/components/miscellaneous.tsx b/source/options/components/miscellaneous.tsx new file mode 100644 index 0000000..f322e96 --- /dev/null +++ b/source/options/components/miscellaneous.tsx @@ -0,0 +1,100 @@ +import {Component, type JSX} from "preact"; +import { + type StorageValues, + fromStorage, + Data, + MiscellaneousFeature, +} from "../../storage/exports.js"; +import {Setting, type SettingProps} from "./index.js"; + +type State = { + enabledFeatures: Awaited; +}; + +function FeatureDescription({ + feature, +}: { + feature: MiscellaneousFeature; +}): JSX.Element { + if (feature === MiscellaneousFeature.CommentAnchorFix) { + return ( +

+ Uncollapses the linked-to comment if it is collapsed,{" "} + #256. +

+ ); + } + + return <>; +} + +export class MiscellaneousSetting extends Component { + constructor(props: SettingProps) { + super(props); + + this.state = { + enabledFeatures: undefined!, + }; + } + + async componentDidMount() { + this.setState({ + enabledFeatures: await fromStorage(Data.MiscellaneousEnabledFeatures), + }); + } + + toggleFeature = async (feature: MiscellaneousFeature) => { + const {enabledFeatures} = this.state; + if (enabledFeatures.value.has(feature)) { + enabledFeatures.value.delete(feature); + } else { + enabledFeatures.value.add(feature); + } + + this.setState({enabledFeatures}); + await enabledFeatures.save(); + }; + + render() { + const {enabledFeatures} = this.state; + if (enabledFeatures === undefined) { + return <>; + } + + const checkboxes = Object.values(MiscellaneousFeature).map((feature) => { + const enabled = enabledFeatures.value.has(feature); + const clickHandler = async () => { + await this.toggleFeature(feature); + }; + + return ( +
  • + + +
  • + ); + }); + + return ( + +

    + Miscellaneous features and fixes, each one can be toggled individually + by checking their respective checkbox. +

    + +
      {checkboxes}
    +
    + ); + } +} diff --git a/source/options/features.ts b/source/options/features.ts index c6acf25..d4f5c2a 100644 --- a/source/options/features.ts +++ b/source/options/features.ts @@ -8,6 +8,7 @@ import { HideVotesSetting, JumpToNewCommentSetting, MarkdownToolbarSetting, + MiscellaneousSetting, ThemedLogoSetting, UserLabelsSetting, UsernameColorsSetting, @@ -71,6 +72,13 @@ export const features: FeatureData[] = [ title: "Markdown Toolbar", component: MarkdownToolbarSetting, }, + { + availableSince: new Date("2023-07-16"), + index: 0, + key: Feature.Miscellaneous, + title: "Miscellaneous", + component: MiscellaneousSetting, + }, { availableSince: new Date("2022-02-27"), index: 0, diff --git a/source/scss/index.scss b/source/scss/index.scss index 379f9d6..6fad421 100644 --- a/source/scss/index.scss +++ b/source/scss/index.scss @@ -6,6 +6,7 @@ @import "shared"; @import "settings"; @import "settings/hide-topics"; +@import "settings/miscellaneous-features"; html { font-size: 62.5%; diff --git a/source/scss/settings/_miscellaneous-features.scss b/source/scss/settings/_miscellaneous-features.scss new file mode 100644 index 0000000..5478b0f --- /dev/null +++ b/source/scss/settings/_miscellaneous-features.scss @@ -0,0 +1,33 @@ +.miscellaneous-features-list { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + + li { + --border-color: var(--red); + + background-color: var(--background-primary); + border: 1px solid var(--border-color); + + &.enabled { + --border-color: var(--green); + } + } + + .description { + margin: 8px; + } + + label { + align-items: center; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + display: flex; + padding: 8px; + } + + input[type="checkbox"] { + margin-right: 1rem; + } +} diff --git a/source/storage/enums.ts b/source/storage/enums.ts index 2c5364a..650fffc 100644 --- a/source/storage/enums.ts +++ b/source/storage/enums.ts @@ -10,18 +10,27 @@ export enum Feature { HideVotes = "hide-votes", JumpToNewComment = "jump-to-new-comment", MarkdownToolbar = "markdown-toolbar", + Miscellaneous = "miscellaneous-features", ThemedLogo = "themed-logo", UserLabels = "user-labels", UsernameColors = "username-colors", } /** - * Keys of miscellaneous data stored in WebExtension storage. + * Keys of miscellaneous feature names. + */ +export enum MiscellaneousFeature { + CommentAnchorFix = "comment-anchor-fix", +} + +/** + * Keys of data stored in WebExtension storage. */ export enum Data { EnabledFeatures = "enabled-features", KnownGroups = "known-groups", LatestActiveFeatureTab = "latest-active-feature-tab", + MiscellaneousEnabledFeatures = "miscellaneous-enabled-features", RandomizeUsernameColors = "randomize-username-colors", Version = "data-version", } diff --git a/source/storage/exports.ts b/source/storage/exports.ts index aab4829..e856a5e 100644 --- a/source/storage/exports.ts +++ b/source/storage/exports.ts @@ -1,6 +1,6 @@ import {createValue, type Value} from "@holllo/webextension-storage"; import browser from "webextension-polyfill"; -import {Data, Feature} from "./enums.js"; +import {Data, Feature, MiscellaneousFeature} from "./enums.js"; import {collectHideTopicsData} from "./hide-topics.js"; import {defaultKnownGroups} from "./known-groups.js"; import {collectUsernameColors} from "./username-color.js"; @@ -51,6 +51,14 @@ export const storageValues = { value: Feature.Debug, storage: browser.storage.sync, }), + [Data.MiscellaneousEnabledFeatures]: createValue({ + deserialize: (input) => + new Set(JSON.parse(input) as MiscellaneousFeature[]), + serialize: (input) => JSON.stringify(Array.from(input)), + key: Data.MiscellaneousEnabledFeatures, + value: new Set([MiscellaneousFeature.CommentAnchorFix]), + storage: browser.storage.sync, + }), [Data.Version]: createValue({ deserialize: (input) => JSON.parse(input) as string, serialize: (input) => JSON.stringify(input), @@ -85,7 +93,7 @@ export const storageValues = { /** * Shorthand for the inferred shape of {@link storageValues}. */ -type StorageValues = typeof storageValues; +export type StorageValues = typeof storageValues; /** * Return the {@link Value}-wrapped data associated with a particular key.