From f719a42aa80a1c8bfb5da40d1f3a606f4bfa3804 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 4 Dec 2024 14:53:07 +0000 Subject: [PATCH] WIP: Reimplement for new topic list --- .../components/topic-list-thumbnail.gjs | 155 ++++++++++++ .../topic-list-resize-observer.gjs | 45 ++++ .../topic-thumbnail.hbr | 1 + .../topic-thumbnail.hbr | 1 + .../initializers/topic-thumbnails-init.gjs | 113 +++++++++ .../initializers/topic-thumbnails-init.js | 232 ------------------ .../discourse/lib/masonry-calculator.js | 73 ++++++ .../raw-views/topic-list-thumbnail.js | 119 --------- .../discourse/services/topic-thumbnails.js | 3 + 9 files changed, 391 insertions(+), 351 deletions(-) create mode 100644 javascripts/discourse/components/topic-list-thumbnail.gjs create mode 100644 javascripts/discourse/connectors/before-topic-list-body/topic-list-resize-observer.gjs create mode 100644 javascripts/discourse/initializers/topic-thumbnails-init.gjs delete mode 100644 javascripts/discourse/initializers/topic-thumbnails-init.js create mode 100644 javascripts/discourse/lib/masonry-calculator.js delete mode 100644 javascripts/discourse/raw-views/topic-list-thumbnail.js diff --git a/javascripts/discourse/components/topic-list-thumbnail.gjs b/javascripts/discourse/components/topic-list-thumbnail.gjs new file mode 100644 index 0000000..61dbff8 --- /dev/null +++ b/javascripts/discourse/components/topic-list-thumbnail.gjs @@ -0,0 +1,155 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import concatClass from "discourse/helpers/concat-class"; +import dIcon from "discourse-common/helpers/d-icon"; + +export default class TopicListThumbnail extends Component { + @service topicThumbnails; + + responsiveRatios = [1, 1.5, 2]; + + // Make sure to update about.json thumbnail sizes if you change these variables + get displayWidth() { + return this.topicThumbnails.displayList + ? settings.list_thumbnail_size + : 400; + } + + get topic() { + return this.args.topic; + } + + get hasThumbnail() { + return !!this.topic.thumbnails; + } + + get srcSet() { + const srcSetArray = []; + + this.responsiveRatios.forEach((ratio) => { + const target = ratio * this.displayWidth; + const match = this.topic.thumbnails.find( + (t) => t.url && t.max_width === target + ); + if (match) { + srcSetArray.push(`${match.url} ${ratio}x`); + } + }); + + if (srcSetArray.length === 0) { + srcSetArray.push(`${this.original.url} 1x`); + } + + return srcSetArray.join(","); + } + + get original() { + return this.topic.thumbnails[0]; + } + + get width() { + return this.original.width; + } + + get isLandscape() { + return this.original.width >= this.original.height; + } + + get height() { + return this.original.height; + } + + get fallbackSrc() { + const largeEnough = this.topic.thumbnails.filter((t) => { + if (!t.url) { + return false; + } + return t.max_width > this.displayWidth * this.responsiveRatios.lastObject; + }); + + if (largeEnough.lastObject) { + return largeEnough.lastObject.url; + } + + return this.original.url; + } + + get url() { + return this.topic.linked_post_number + ? this.topic.urlForPostNumber(this.topic.linked_post_number) + : this.topic.get("lastUnreadUrl"); + } + + +} diff --git a/javascripts/discourse/connectors/before-topic-list-body/topic-list-resize-observer.gjs b/javascripts/discourse/connectors/before-topic-list-body/topic-list-resize-observer.gjs new file mode 100644 index 0000000..b5468ab --- /dev/null +++ b/javascripts/discourse/connectors/before-topic-list-body/topic-list-resize-observer.gjs @@ -0,0 +1,45 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import { modifier } from "ember-modifier"; + +export default class extends Component { + @service topicThumbnails; + + attachResizeObserver = modifier((element) => { + const topicList = element.closest(".topic-list"); + + if (!topicList) { + // eslint-disable-next-line no-console + console.error( + "topic-list-thumbnails resize-observer must be inside a topic-list" + ); + return; + } + + this.topicThumbnails.masonryContainerWidth = + topicList.getBoundingClientRect().width; + + const observer = new ResizeObserver(() => { + this.topicThumbnails.masonryContainerWidth = + topicList.getBoundingClientRect().width; + console.log( + "ResizeObserver triggered", + this.topicThumbnails.masonryContainerWidth + ); + }); + observer.observe(topicList); + + return () => { + observer.disconnect(); + this.topicThumbnails.masonryContainerWidth = null; + }; + }); + + +} diff --git a/javascripts/discourse/connectors/topic-list-before-columns/topic-thumbnail.hbr b/javascripts/discourse/connectors/topic-list-before-columns/topic-thumbnail.hbr index 517d43a..e098181 100644 --- a/javascripts/discourse/connectors/topic-list-before-columns/topic-thumbnail.hbr +++ b/javascripts/discourse/connectors/topic-list-before-columns/topic-thumbnail.hbr @@ -1 +1,2 @@ +{{!-- has-modern-replacement --}} {{~raw "topic-list-thumbnail" topic=context.topic location="before-columns"}} diff --git a/javascripts/discourse/connectors/topic-list-before-link/topic-thumbnail.hbr b/javascripts/discourse/connectors/topic-list-before-link/topic-thumbnail.hbr index f59fe05..6b2f084 100644 --- a/javascripts/discourse/connectors/topic-list-before-link/topic-thumbnail.hbr +++ b/javascripts/discourse/connectors/topic-list-before-link/topic-thumbnail.hbr @@ -1,2 +1,3 @@ +{{!-- has-modern-replacement --}} {{~raw "topic-list-thumbnail" topic=context.topic location="before-link"}} diff --git a/javascripts/discourse/initializers/topic-thumbnails-init.gjs b/javascripts/discourse/initializers/topic-thumbnails-init.gjs new file mode 100644 index 0000000..316d52b --- /dev/null +++ b/javascripts/discourse/initializers/topic-thumbnails-init.gjs @@ -0,0 +1,113 @@ +import { readOnly } from "@ember/object/computed"; +import { service } from "@ember/service"; +import { apiInitializer } from "discourse/lib/api"; +import TopicListThumbnail from "../components/topic-list-thumbnail"; +import MasonryCalculator from "../lib/masonry-calculator"; + +export default apiInitializer("0.8", (api) => { + const ttService = api.container.lookup("service:topic-thumbnails"); + + api.registerValueTransformer("topic-list-class", ({ value }) => { + if (ttService.displayMinimalGrid) { + value.push("topic-thumbnails-minimal"); + } else if (ttService.displayGrid) { + value.push("topic-thumbnails-grid"); + } else if (ttService.displayList) { + value.push("topic-thumbnails-list"); + } else if (ttService.displayMasonry) { + value.push("topic-thumbnails-masonry"); + } else if (ttService.displayBlogStyle) { + value.push("topic-thumbnails-blog-style-grid"); + } + return value; + }); + + api.registerValueTransformer("topic-list-columns", ({ value: columns }) => { + if (ttService.enabledForRoute && !ttService.displayList) { + columns.add( + "thumbnail", + { + item: TopicListThumbnail, + }, + { before: "topic" } + ); + } + return columns; + }); + + api.renderInOutlet("topic-list-before-link", ); + + api.registerValueTransformer("topic-list-item-mobile-layout", ({ value }) => { + if (ttService.enabledForRoute && !ttService.displayList) { + // Force the desktop layout + return false; + } + return value; + }); + + // Todo: what do do with docs? + const siteSettings = api.container.lookup("service:site-settings"); + if (settings.docs_thumbnail_mode !== "none" && siteSettings.docs_enabled) { + api.modifyClass("component:docs-topic-list", { + pluginId: "topic-thumbnails", + topicThumbnailsService: service("topic-thumbnails"), + classNameBindings: [ + "isMinimalGrid:topic-thumbnails-minimal", + "isThumbnailGrid:topic-thumbnails-grid", + "isThumbnailList:topic-thumbnails-list", + "isMasonryList:topic-thumbnails-masonry", + "isBlogStyleGrid:topic-thumbnails-blog-style-grid", + ], + isMinimalGrid: readOnly("topicThumbnailsService.displayMinimalGrid"), + isThumbnailGrid: readOnly("topicThumbnailsService.displayGrid"), + isThumbnailList: readOnly("topicThumbnailsService.displayList"), + isMasonryList: readOnly("topicThumbnailsService.displayMasonry"), + isBlogStyleGrid: readOnly("topicThumbnailsService.displayBlogStyle"), + }); + } + + // Masonry Layout + api.registerValueTransformer( + "topic-list-style", + ({ value, context: { topics } }) => { + if (!ttService.displayMasonry) { + return; + } + + if (!ttService.masonryContainerWidth) { + return; + } + + const calculator = new MasonryCalculator( + api.container, + topics, + ttService.masonryContainerWidth + ); + calculator.calculateMasonryLayout(); + value += calculator.masonryStyle; + + return value; + } + ); + + api.registerValueTransformer( + "topic-list-item-style", + ({ value, context }) => { + if (ttService.displayMasonry) { + const masonryData = context.topic.get("masonryData"); + if (masonryData) { + value += [ + `--masonry-height: ${Math.round(masonryData.height)}px;`, + `--masonry-height-above: ${Math.round(masonryData.heightAbove)}px;`, + `--masonry-column-index: ${masonryData.columnIndex};`, + ].join(""); + } + } + return value; + } + ); +}); diff --git a/javascripts/discourse/initializers/topic-thumbnails-init.js b/javascripts/discourse/initializers/topic-thumbnails-init.js deleted file mode 100644 index 0e291cd..0000000 --- a/javascripts/discourse/initializers/topic-thumbnails-init.js +++ /dev/null @@ -1,232 +0,0 @@ -import { readOnly } from "@ember/object/computed"; -import { once } from "@ember/runloop"; -import { service } from "@ember/service"; -import { htmlSafe } from "@ember/template"; -import { withPluginApi } from "discourse/lib/plugin-api"; -import { - getResolverOption, - setResolverOption, -} from "discourse-common/resolver"; -import discourseComputed, { observes } from "discourse-common/utils/decorators"; - -export default { - name: "topic-thumbnails-init", - initialize() { - withPluginApi("0.8.7", (api) => this.initWithApi(api)); - }, - - initWithApi(api) { - api.modifyClass("component:topic-list", { - pluginId: "topic-thumbnails", - topicThumbnailsService: service("topic-thumbnails"), - classNameBindings: [ - "isMinimalGrid:topic-thumbnails-minimal", - "isThumbnailGrid:topic-thumbnails-grid", - "isThumbnailList:topic-thumbnails-list", - "isMasonryList:topic-thumbnails-masonry", - "isBlogStyleGrid:topic-thumbnails-blog-style-grid", - ], - isMinimalGrid: readOnly("topicThumbnailsService.displayMinimalGrid"), - isThumbnailGrid: readOnly("topicThumbnailsService.displayGrid"), - isThumbnailList: readOnly("topicThumbnailsService.displayList"), - isMasonryList: readOnly("topicThumbnailsService.displayMasonry"), - isBlogStyleGrid: readOnly("topicThumbnailsService.displayBlogStyle"), - }); - - const siteSettings = api.container.lookup("service:site-settings"); - - if (settings.docs_thumbnail_mode !== "none" && siteSettings.docs_enabled) { - api.modifyClass("component:docs-topic-list", { - pluginId: "topic-thumbnails", - topicThumbnailsService: service("topic-thumbnails"), - classNameBindings: [ - "isMinimalGrid:topic-thumbnails-minimal", - "isThumbnailGrid:topic-thumbnails-grid", - "isThumbnailList:topic-thumbnails-list", - "isMasonryList:topic-thumbnails-masonry", - "isBlogStyleGrid:topic-thumbnails-blog-style-grid", - ], - isMinimalGrid: readOnly("topicThumbnailsService.displayMinimalGrid"), - isThumbnailGrid: readOnly("topicThumbnailsService.displayGrid"), - isThumbnailList: readOnly("topicThumbnailsService.displayList"), - isMasonryList: readOnly("topicThumbnailsService.displayMasonry"), - isBlogStyleGrid: readOnly("topicThumbnailsService.displayBlogStyle"), - }); - } - - api.modifyClass("component:topic-list-item", { - pluginId: "topic-thumbnails", - topicThumbnailsService: service("topic-thumbnails"), - - // Hack to disable the mobile topic-list-item template - // Our grid styling is responsive, and uses the desktop HTML structure - @observes("topic.pinned") - renderTopicListItem() { - const wasMobileView = getResolverOption("mobileView"); - if ( - wasMobileView && - (this.topicThumbnailsService.displayGrid || - this.topicThumbnailsService.displayMasonry || - this.topicThumbnailsService.displayMinimalGrid || - this.topicThumbnailsService.displayBlogStyle) - ) { - setResolverOption("mobileView", false); - } - - this._super(); - - if (wasMobileView) { - setResolverOption("mobileView", true); - } - }, - }); - - ////////////////////////// - /////////// Masonry Layout - ////////////////////////// - api.modifyClass("component:topic-list", { - pluginId: "topic-thumbnails-masonry", - topicThumbnailsService: service("topic-thumbnails"), - - masonryTargetColumnWidth: 300, - masonryGridSpacingPixels: 10, - masonryTitleSpacePixels: 76, - masonryDefaultAspect: 1.3, - masonryMinAspect: 0.7, - - @discourseComputed("masonryContainerWidth") - masonryNumColumns(width) { - return Math.floor(width / this.masonryTargetColumnWidth); - }, - - @discourseComputed( - "masonryNumColumns", - "masonryContainerWidth", - "masonryGridSpacingPixels" - ) - masonryColumnWidth(numColumns, containerWidth, gridSpacing) { - return (containerWidth - (numColumns - 1) * gridSpacing) / numColumns; - }, - - didInsertElement() { - this._super(); - this.updateElementHeight(); - - if (window.ResizeObserver) { - const observer = new ResizeObserver(() => this.updateElementHeight()); - observer.observe(this.element); - this.set("resizeObserver", observer); - } - }, - - willDestroyElement() { - this._super(); - if (this.resizeObserver) { - this.resizeObserver.unobserve(this.element); - } - }, - - updateElementHeight() { - this.set( - "masonryContainerWidth", - this.element.getBoundingClientRect().width - ); - }, - - @observes("topics.[]", "masonryContainerWidth") - masonryTopicsChanged() { - if (!this.topicThumbnailsService.displayMasonry) { - return; - } - if (!this.masonryContainerWidth) { - return; - } - once(this, this.calculateMasonryLayout); - }, - - calculateMasonryLayout() { - const numColumns = this.masonryNumColumns; - const gridSpacingPixels = this.masonryGridSpacingPixels; - - const columnHeights = []; - for (let n = 0; n < numColumns; n++) { - columnHeights[n] = 0; - } - - this.filteredTopics.forEach((topic) => { - // Pick the column with the lowest height - const smallestColumn = columnHeights.indexOf( - Math.min(...columnHeights) - ); - - // Get the height of this topic - let aspect = this.masonryDefaultAspect; - if (topic.thumbnails) { - aspect = topic.thumbnails[0].width / topic.thumbnails[0].height; - } - aspect = Math.max(aspect, this.masonryMinAspect); - const thisHeight = - this.masonryColumnWidth / aspect + this.masonryTitleSpacePixels; - - topic.set("masonryData", { - columnIndex: smallestColumn, - height: thisHeight, - heightAbove: columnHeights[smallestColumn], - }); - - columnHeights[smallestColumn] += thisHeight + gridSpacingPixels; - }); - - this.set("masonryTallestColumn", Math.max(...columnHeights)); - }, - - attributeBindings: ["masonryStyle:style"], - - @discourseComputed( - "topicThumbnailsService.displayMasonry", - "masonryNumColumns", - "masonryGridSpacingPixels", - "masonryTallestColumn", - "masonryColumnWidth" - ) - masonryStyle( - useMasonry, - numColumns, - gridSpacingPixels, - tallestColumn, - columnWidth - ) { - if (!useMasonry) { - return; - } - - return htmlSafe( - `--masonry-num-columns: ${Math.round(numColumns)}; ` + - `--masonry-grid-spacing: ${gridSpacingPixels}px; ` + - `--masonry-tallest-column: ${Math.round(tallestColumn)}px; ` + - `--masonry-column-width: ${Math.round(columnWidth)}px; ` - ); - }, - }); - - api.modifyClass("component:topic-list-item", { - pluginId: "topic-thumbnails-masonry", - attributeBindings: ["masonryStyle:style"], - - @discourseComputed("topic.masonryData") - masonryStyle(masonryData) { - if (!masonryData) { - return; - } - - return htmlSafe( - `--masonry-height: ${Math.round(masonryData.height)}px; ` + - `--masonry-height-above: ${Math.round( - masonryData.heightAbove - )}px; ` + - `--masonry-column-index: ${masonryData.columnIndex};` - ); - }, - }); - }, -}; diff --git a/javascripts/discourse/lib/masonry-calculator.js b/javascripts/discourse/lib/masonry-calculator.js new file mode 100644 index 0000000..4fe24f0 --- /dev/null +++ b/javascripts/discourse/lib/masonry-calculator.js @@ -0,0 +1,73 @@ +export default class TopicThumbnailsMasonryCalculator { + masonryTargetColumnWidth = 300; + masonryGridSpacingPixels = 10; + masonryTitleSpacePixels = 76; + masonryDefaultAspect = 1.3; + masonryMinAspect = 0.7; + + topics; + masonryContainerWidth; + + constructor(topicThumbnails, topics, masonryContainerWidth) { + this.topicThumbnails = topicThumbnails; + this.topics = topics; + this.masonryContainerWidth = masonryContainerWidth; + } + + get masonryNumColumns() { + return Math.floor( + this.masonryContainerWidth / this.masonryTargetColumnWidth + ); + } + + get masonryColumnWidth() { + return ( + (this.masonryContainerWidth - + (this.masonryNumColumns - 1) * this.masonryGridSpacingPixels) / + this.masonryNumColumns + ); + } + + calculateMasonryLayout() { + const numColumns = this.masonryNumColumns; + const gridSpacingPixels = this.masonryGridSpacingPixels; + + const columnHeights = []; + for (let n = 0; n < numColumns; n++) { + columnHeights[n] = 0; + } + + this.topics.forEach((topic) => { + // Pick the column with the lowest height + const smallestColumn = columnHeights.indexOf(Math.min(...columnHeights)); + + // Get the height of this topic + let aspect = this.masonryDefaultAspect; + if (topic.thumbnails) { + aspect = topic.thumbnails[0].width / topic.thumbnails[0].height; + } + aspect = Math.max(aspect, this.masonryMinAspect); + const thisHeight = + this.masonryColumnWidth / aspect + this.masonryTitleSpacePixels; + + topic.set("masonryData", { + columnIndex: smallestColumn, + height: thisHeight, + heightAbove: columnHeights[smallestColumn], + }); + + columnHeights[smallestColumn] += thisHeight + gridSpacingPixels; + }); + + this.masonryTallestColumn = Math.max(...columnHeights); + } + + get masonryStyle() { + return ( + `--masonry-num-columns: ${Math.round(this.masonryNumColumns)}; ` + + `--masonry-grid-spacing: ${this.masonryGridSpacingPixels}px; ` + + `--masonry-tallest-column: ${Math.round(this.masonryTallestColumn)}px; ` + + `--masonry-column-width: ${Math.round(this.masonryColumnWidth)}px; ` + ); + } +} diff --git a/javascripts/discourse/raw-views/topic-list-thumbnail.js b/javascripts/discourse/raw-views/topic-list-thumbnail.js deleted file mode 100644 index e978865..0000000 --- a/javascripts/discourse/raw-views/topic-list-thumbnail.js +++ /dev/null @@ -1,119 +0,0 @@ -import EmberObject from "@ember/object"; -import { and } from "@ember/object/computed"; -import { service } from "@ember/service"; -import discourseComputed from "discourse-common/utils/decorators"; - -export default class TopicListThumbnail extends EmberObject { - @service("topic-thumbnails") topicThumbnailsService; - - responsiveRatios = [1, 1.5, 2]; - - @and("topicThumbnailsService.shouldDisplay", "enabledForOutlet") - shouldDisplay; - - // Make sure to update about.json thumbnail sizes if you change these variables - @discourseComputed("topicThumbnailsService.displayList") - displayWidth(displayList) { - return displayList ? settings.list_thumbnail_size : 400; - } - - @discourseComputed( - "location", - "site.mobileView", - "topicThumbnailsService.displayMinimalGrid", - "topicThumbnailsService.displayGrid", - "topicThumbnailsService.displayList", - "topicThumbnailsService.displayMasonry", - "topicThumbnailsService.displayBlogStyle" - ) - enabledForOutlet( - location, - mobile, - displayMinimalGrid, - displayGrid, - displayList, - displayMasonry, - displayBlogStyle - ) { - if ( - (displayGrid || - displayMasonry || - displayMinimalGrid || - displayBlogStyle) && - location === "before-columns" - ) { - return true; - } - if (displayList && location === "before-link") { - return true; - } - return false; - } - - @discourseComputed("topic.thumbnails") - hasThumbnail(thumbnails) { - return !!thumbnails; - } - - @discourseComputed("topic.thumbnails", "displayWidth") - srcSet(thumbnails, displayWidth) { - const srcSetArray = []; - - this.responsiveRatios.forEach((ratio) => { - const target = ratio * displayWidth; - const match = thumbnails.find((t) => t.url && t.max_width === target); - if (match) { - srcSetArray.push(`${match.url} ${ratio}x`); - } - }); - - if (srcSetArray.length === 0) { - srcSetArray.push(`${this.original.url} 1x`); - } - - return srcSetArray.join(","); - } - - @discourseComputed("topic.thumbnails") - original(thumbnails) { - return thumbnails[0]; - } - - @discourseComputed("original") - width(original) { - return original.width; - } - - @discourseComputed("original") - isLandscape(original) { - return original.width >= original.height; - } - - @discourseComputed("original") - height(original) { - return original.height; - } - - @discourseComputed("topic.thumbnails") - fallbackSrc(thumbnails) { - const largeEnough = thumbnails.filter((t) => { - if (!t.url) { - return false; - } - return t.max_width > this.displayWidth * this.responsiveRatios.lastObject; - }); - - if (largeEnough.lastObject) { - return largeEnough.lastObject.url; - } - - return this.original.url; - } - - @discourseComputed("topic") - url(topic) { - return topic.linked_post_number - ? topic.urlForPostNumber(topic.linked_post_number) - : topic.get("lastUnreadUrl"); - } -} diff --git a/javascripts/discourse/services/topic-thumbnails.js b/javascripts/discourse/services/topic-thumbnails.js index 2f3e041..e546b3d 100644 --- a/javascripts/discourse/services/topic-thumbnails.js +++ b/javascripts/discourse/services/topic-thumbnails.js @@ -1,3 +1,4 @@ +import { tracked } from "@glimmer/tracking"; import { dependentKeyCompat } from "@ember/object/compat"; import Service, { service } from "@ember/service"; import Site from "discourse/models/site"; @@ -33,6 +34,8 @@ export default class TopicThumbnailService extends Service { @service router; @service discovery; + @tracked masonryContainerWidth; + @dependentKeyCompat get isTopicListRoute() { return this.discovery.onDiscoveryRoute;