FEATURE: Add CSS class generation for category colors and hashtags (#20951)

This commit adds a system to generate CSS variables and classes for categories
and hashtags, which will be used in an effort to remove baked icons for hashtags
and add color to those icons.

This is in two parts. First I added an initializer generate a category color CSS
variable style tag in the head tag that looks like this:

```css
:root {
--category-1-color: #0088CC;
--category-2-color: #808281;
--category-3-color: #E45735;
--category-4-color: #A461EF;
--category-5-color: #ee56c9;
--category-6-color: #da28c2;
--category-7-color: #ab8b0a;
--category-8-color: #45da37;
...
}
```

The number is the category ID. This only generates CSS variables for categories
the user can access based on `site.categories`. If you need the parent color variable
you can just use the `category.parentCategory.id` to get it.

Then, I added an initializer to generate a hashtag CSS style tag using these variables.
Only the category and channel hashtags need this, the category one generates the
background-gradient needed for the swatch, and the channel just generates a color
for the icon. This is done in an extendable way using the new `api.registerHashtagType`
JS plugin API:

```css
hashtag-color--category-1 {
  background: linear-gradient(90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);
}
hashtag-color--category-2 {
  background: linear-gradient(90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);
}
hashtag-color--category-5 {
  background: linear-gradient(90deg, var(--category-5-color) 50%, var(--category-4-color) 50%);
}
...
.hashtag-color--channel-4 {
  color: var(--category-12-color);
}
.hashtag-color--channel-92 {
  color: var(--category-24-color);
}
```

Note if a category has a parent, its color is used in the gradient correctly. The numbers
here are again IDs (e.g. channel ID, category ID) and the channel’s chatable ID is used
to find the category color variable.
This commit is contained in:
Martin Brennan 2023-04-05 13:02:35 +10:00 committed by GitHub
parent b72282123b
commit 6ad9e4ad06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 293 additions and 0 deletions

View File

@ -0,0 +1,29 @@
export default {
name: "category-color-css-generator",
after: "register-hashtag-types",
/**
* This generates CSS variables for each category color,
* which can be used in themes to style category-specific elements.
*
* It is also used when styling hashtag icons, since they are colored
* based on the category color.
*/
initialize(container) {
this.site = container.lookup("service:site");
const generatedCssVariables = [
":root {",
...this.site.categories.map(
(category) => `--category-${category.id}-color: #${category.color};`
),
"}",
];
const cssTag = document.createElement("style");
cssTag.type = "text/css";
cssTag.id = "category-color-css-generator";
cssTag.innerHTML = generatedCssVariables.join("\n");
document.head.appendChild(cssTag);
},
};

View File

@ -0,0 +1,34 @@
import { getHashtagTypeClasses } from "discourse/lib/hashtag-autocomplete";
export default {
name: "hashtag-css-generator",
after: "category-color-css-generator",
/**
* This generates CSS classes for each hashtag type,
* which are used to color the hashtag icons in the composer,
* cooked posts, and the sidebar.
*
* Each type has its own corresponding class, which is registered
* with the hastag type via api.registerHashtagType. The default
* ones in core are CategoryHashtagType and TagHashtagType.
*/
initialize(container) {
let generatedCssClasses = [];
Object.values(getHashtagTypeClasses()).forEach((hashtagTypeClass) => {
const hashtagType = new hashtagTypeClass(container);
hashtagType.preloadedData.forEach((model) => {
generatedCssClasses = generatedCssClasses.concat(
hashtagType.generateColorCssClasses(model)
);
});
});
const cssTag = document.createElement("style");
cssTag.type = "text/css";
cssTag.id = "hashtag-css-generator";
cssTag.innerHTML = generatedCssClasses.join("\n");
document.head.appendChild(cssTag);
},
};

View File

@ -0,0 +1,15 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import CategoryHashtagType from "discourse/lib/hashtag-types/category";
import TagHashtagType from "discourse/lib/hashtag-types/tag";
export default {
name: "register-hashtag-types",
before: "hashtag-css-generator",
initialize() {
withPluginApi("0.8.7", (api) => {
api.registerHashtagType("category", CategoryHashtagType);
api.registerHashtagType("tag", TagHashtagType);
});
},
};

View File

@ -14,6 +14,17 @@ import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe } from "@ember/template";
let hashtagTypeClasses = {};
export function registerHashtagType(type, typeClass) {
hashtagTypeClasses[type] = typeClass;
}
export function cleanUpHashtagTypeClasses() {
hashtagTypeClasses = {};
}
export function getHashtagTypeClasses() {
return hashtagTypeClasses;
}
/**
* Sets up a textarea using the jQuery autocomplete plugin, specifically
* to match on the hashtag (#) character for autocompletion of categories,

View File

@ -0,0 +1,19 @@
import { setOwner } from "@ember/application";
export default class HashtagTypeBase {
constructor(container) {
setOwner(this, container);
}
get type() {
throw "not implemented";
}
get preloadedData() {
throw "not implemented";
}
generateColorCssClasses() {
throw "not implemented";
}
}

View File

@ -0,0 +1,32 @@
import HashtagTypeBase from "./base";
import { inject as service } from "@ember/service";
export default class CategoryHashtagType extends HashtagTypeBase {
@service site;
get type() {
return "category";
}
get preloadedData() {
return this.site.categories;
}
generateColorCssClasses(model) {
const generatedCssClasses = [];
const backgroundGradient = [`var(--category-${model.id}-color) 50%`];
if (model.parentCategory) {
backgroundGradient.push(
`var(--category-${model.parentCategory.id}-color) 50%`
);
} else {
backgroundGradient.push(`var(--category-${model.id}-color) 50%`);
}
generatedCssClasses.push(`.hashtag-color--category-${model.id} {
background: linear-gradient(90deg, ${backgroundGradient.join(", ")});
}`);
return generatedCssClasses;
}
}

View File

@ -0,0 +1,15 @@
import HashtagTypeBase from "./base";
export default class TagHashtagType extends HashtagTypeBase {
get type() {
return "tag";
}
get preloadedData() {
return [];
}
generateColorCssClasses() {
return [];
}
}

View File

@ -112,6 +112,7 @@ import { registerUserMenuTab } from "discourse/lib/user-menu/tab";
import { registerModelTransformer } from "discourse/lib/model-transformers";
import { registerCustomUserNavMessagesDropdownRow } from "discourse/controllers/user-private-messages";
import { registerFullPageSearchType } from "discourse/controllers/full-page-search";
import { registerHashtagType } from "discourse/lib/hashtag-autocomplete";
// If you add any methods to the API ensure you bump up the version number
// based on Semantic Versioning 2.0.0. Please update the changelog at
@ -2137,6 +2138,17 @@ class PluginApi {
addFullPageSearchType(translationKey, searchTypeId, searchFunc) {
registerFullPageSearchType(translationKey, searchTypeId, searchFunc);
}
/**
* Registers a hastag type and its corresponding class.
* This is used when generating CSS classes in the hashtag-css-generator.
*
* @param {string} type - The type of the hashtag.
* @param {Class} typeClass - The class of the hashtag type.
*/
registerHashtagType(type, typeClass) {
registerHashtagType(type, typeClass);
}
}
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number

View File

@ -0,0 +1,32 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance("CSS Generator", function (needs) {
needs.user();
needs.site({
categories: [
{ id: 1, color: "ff0000" },
{ id: 2, color: "333" },
{ id: 4, color: "2B81AF", parentCategory: { id: 1 } },
],
});
test("category CSS variables are generated", async function (assert) {
await visit("/");
const cssTag = document.querySelector("style#category-color-css-generator");
assert.equal(
cssTag.innerHTML,
":root {\n--category-1-color: #ff0000;\n--category-2-color: #333;\n--category-4-color: #2B81AF;\n}"
);
});
test("hashtag CSS classes are generated", async function (assert) {
await visit("/");
const cssTag = document.querySelector("style#hashtag-css-generator");
assert.equal(
cssTag.innerHTML,
".hashtag-color--category-1 {\n background: linear-gradient(90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--category-2 {\n background: linear-gradient(90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);\n}\n.hashtag-color--category-4 {\n background: linear-gradient(90deg, var(--category-4-color) 50%, var(--category-1-color) 50%);\n}"
);
});
});

View File

@ -52,6 +52,7 @@ import {
cleanUpComposerUploadMarkdownResolver,
cleanUpComposerUploadPreProcessor,
} from "discourse/components/composer-editor";
import { cleanUpHashtagTypeClasses } from "discourse/lib/hashtag-autocomplete";
import { resetLastEditNotificationClick } from "discourse/models/post-stream";
import { clearAuthMethods } from "discourse/models/login-method";
import { clearTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown";
@ -190,6 +191,7 @@ export function testCleanup(container, app) {
clearTopicFooterDropdowns();
clearTopicFooterButtons();
clearDesktopNotificationHandlers();
cleanUpHashtagTypeClasses();
resetLastEditNotificationClick();
clearAuthMethods();
setTestPresence(true);
@ -211,6 +213,12 @@ export function testCleanup(container, app) {
resetModelTransformers();
resetMentions();
cleanupTemporaryModuleRegistrations();
cleanupCssGeneratorTags();
}
function cleanupCssGeneratorTags() {
document.querySelector("style#category-color-css-generator")?.remove();
document.querySelector("style#hashtag-css-generator")?.remove();
}
export function discourseModule(name, options) {

View File

@ -4,6 +4,7 @@ import { bind } from "discourse-common/utils/decorators";
import { getOwner } from "discourse-common/lib/get-owner";
import { MENTION_KEYWORDS } from "discourse/plugins/chat/discourse/components/chat-message";
import { clearChatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
import ChannelHashtagType from "discourse/plugins/chat/discourse/lib/hashtag-types/channel";
import { replaceIcon } from "discourse-common/lib/icon-library";
let _lastForcedRefreshAt;
@ -13,6 +14,7 @@ replaceIcon("d-chat", "comment");
export default {
name: "chat-setup",
before: "hashtag-css-generator",
initialize(container) {
this.chatService = container.lookup("service:chat");
@ -25,6 +27,8 @@ export default {
}
withPluginApi("0.12.1", (api) => {
api.registerHashtagType("channel", ChannelHashtagType);
api.registerChatComposerButton({
id: "chat-upload-btn",
icon: "far-image",

View File

@ -0,0 +1,25 @@
import HashtagTypeBase from "discourse/lib/hashtag-types/base";
import { inject as service } from "@ember/service";
export default class ChannelHashtagType extends HashtagTypeBase {
@service chatChannelsManager;
@service currentUser;
get type() {
return "channel";
}
get preloadedData() {
if (this.currentUser) {
return this.chatChannelsManager.publicMessageChannels;
} else {
return [];
}
}
generateColorCssClasses(model) {
return [
`.hashtag-color--${this.type}-${model.id} { color: var(--category-${model.chatable.id}-color); }`,
];
}
}

View File

@ -0,0 +1,57 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance("Chat | Hashtag CSS Generator", function (needs) {
const category1 = { id: 1, color: "ff0000" };
const category2 = { id: 2, color: "333" };
const category3 = { id: 4, color: "2B81AF", parentCategory: { id: 1 } };
needs.settings({ chat_enabled: true });
needs.user({
has_chat_enabled: true,
chat_channels: {
public_channels: [
{
id: 44,
chatable_id: 1,
chatable_type: "Category",
meta: { message_bus_last_ids: {} },
current_user_membership: { following: true },
chatable: category1,
},
{
id: 74,
chatable_id: 2,
chatable_type: "Category",
meta: { message_bus_last_ids: {} },
current_user_membership: { following: true },
chatable: category2,
},
{
id: 88,
chatable_id: 4,
chatable_type: "Category",
meta: { message_bus_last_ids: {} },
current_user_membership: { following: true },
chatable: category3,
},
],
direct_message_channels: [],
meta: { message_bus_last_ids: {} },
},
});
needs.site({
categories: [category1, category2, category3],
});
test("hashtag CSS classes are generated", async function (assert) {
await visit("/");
const cssTag = document.querySelector("style#hashtag-css-generator");
assert.equal(
cssTag.innerHTML,
".hashtag-color--category-1 {\n background: linear-gradient(90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--category-2 {\n background: linear-gradient(90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);\n}\n.hashtag-color--category-4 {\n background: linear-gradient(90deg, var(--category-4-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--channel-44 { color: var(--category-1-color); }\n.hashtag-color--channel-74 { color: var(--category-2-color); }\n.hashtag-color--channel-88 { color: var(--category-4-color); }"
);
});
});