From d5024d96f18553064c55c4ea3c65246399916b54 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 3 Feb 2023 15:11:12 +0100 Subject: [PATCH] FEATURE: resizeable chat drawer (#20160) This commit implements a requested feature: resizing the chat drawer. The user can now adjust the drawer size to their liking, and the new size will be stored in localstorage so that it persists across refreshes. In addition to this feature, a bug was fixed where the --composer-right margin was not being correctly computed. This bug could have resulted in incorrectly positioned drawer when the composer was expanded. Note that it includes support for RTL. --- .../discourse/components/chat-drawer.hbs | 3 + .../discourse/components/chat-drawer.js | 49 +++++-- .../modifiers/chat/resizable-node.js | 124 ++++++++++++++++++ .../discourse/services/chat-drawer-size.js | 32 +++++ .../stylesheets/common/chat-drawer.scss | 42 +++++- .../assets/stylesheets/desktop/desktop.scss | 5 - plugins/chat/spec/system/drawer_spec.rb | 37 ++++++ .../unit/services/chat-drawer-size-test.js | 22 ++++ 8 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js create mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-drawer-size.js create mode 100644 plugins/chat/spec/system/drawer_spec.rb create mode 100644 plugins/chat/test/javascripts/unit/services/chat-drawer-size-test.js diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs index 75626a8ce9a..48edf5dd80d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs @@ -5,8 +5,11 @@ "chat-drawer" (if this.chatStateManager.isDrawerExpanded "is-expanded") }} + {{chat/resizable-node ".chat-drawer-resizer" this.didResize}} + style={{this.drawerStyle}} >
+
instance.cleanup()); + } + + modify(element, [resizerSelector, didResizeContainer]) { + this.resizerSelector = resizerSelector; + this.element = element; + this.didResizeContainer = didResizeContainer; + + this.element + .querySelector(this.resizerSelector) + ?.addEventListener("mousedown", this._startResize); + } + + cleanup() { + this.element + .querySelector(this.resizerSelector) + ?.removeEventListener("mousedown", this._startResize); + } + + @bind + _startResize(event) { + event.preventDefault(); + + this._originalWidth = parseFloat( + getComputedStyle(this.element, null) + .getPropertyValue("width") + .replace("px", "") + ); + this._originalHeight = parseFloat( + getComputedStyle(this.element, null) + .getPropertyValue("height") + .replace("px", "") + ); + this._originalX = this.element.getBoundingClientRect().left; + this._originalY = this.element.getBoundingClientRect().top; + this._originalMouseX = event.pageX; + this._originalMouseY = event.pageY; + + window.addEventListener("mousemove", this._resize); + window.addEventListener("mouseup", this._stopResize); + } + + @bind + _resize(event) { + throttle(this, this._resizeThrottled, event, 24); + } + + /* + The bulk of the logic is to calculate the new width and height of the element + based on the current mouse position: width is calculated by subtracting + the difference between the current event.pageX and the original this._originalMouseX + from the original this._originalWidth, and rounding up to the nearest integer. + height is calculated in a similar way using event.pageY and this._originalMouseY. + + In this example (B) is the current element top/left and (A) is x/y of the mouse after dragging: + + A------ + | | + | B--| + | | | + ------- + */ + @bind + _resizeThrottled(event) { + let width = this._originalWidth; + let diffWidth = event.pageX - this._originalMouseX; + if (document.documentElement.classList.contains("rtl")) { + width = Math.ceil(width + diffWidth); + } else { + width = Math.ceil(width - diffWidth); + } + + const height = Math.ceil( + this._originalHeight - (event.pageY - this._originalMouseY) + ); + + const newStyle = {}; + + if (width > MINIMUM_SIZE) { + newStyle.width = width + "px"; + newStyle.left = + Math.ceil(this._originalX + (event.pageX - this._originalMouseX)) + + "px"; + } + + if (height > MINIMUM_SIZE) { + newStyle.height = height + "px"; + newStyle.top = + Math.ceil(this._originalY + (event.pageY - this._originalMouseY)) + + "px"; + } + + Object.assign(this.element.style, newStyle); + + this.didResizeContainer?.(this.element, { width, height }); + } + + @bind + _stopResize() { + window.removeEventListener("mousemove", this._resize); + window.removeEventListener("mouseup", this._stopResize); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-size.js b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-size.js new file mode 100644 index 00000000000..d19c873f32b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-size.js @@ -0,0 +1,32 @@ +import Service from "@ember/service"; +import KeyValueStore from "discourse/lib/key-value-store"; + +export default class ChatDrawerSize extends Service { + STORE_NAMESPACE = "discourse_chat_drawer_size_"; + MIN_HEIGHT = 300; + MIN_WIDTH = 250; + + store = new KeyValueStore(this.STORE_NAMESPACE); + + get size() { + return { + width: this.store.getObject("width") || 400, + height: this.store.getObject("height") || 530, + }; + } + + set size({ width, height }) { + this.store.setObject({ + key: "width", + value: this.#min(width, this.MIN_WIDTH), + }); + this.store.setObject({ + key: "height", + value: this.#min(height, this.MIN_HEIGHT), + }); + } + + #min(number, min) { + return Math.max(number, min); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-drawer.scss b/plugins/chat/assets/stylesheets/common/chat-drawer.scss index 6181283b831..7ea7fbada75 100644 --- a/plugins/chat/assets/stylesheets/common/chat-drawer.scss +++ b/plugins/chat/assets/stylesheets/common/chat-drawer.scss @@ -2,12 +2,39 @@ body.composer-open .chat-drawer-outlet-container { bottom: 11px; // prevent height of grippie from obscuring ...is typing indicator } +.chat-drawer-resizer { + position: absolute; + top: -5px; + width: 15px; + height: 15px; +} + +html:not(.rtl) { + .chat-drawer-resizer { + cursor: nwse-resize; + left: -5px; + } +} + +html.rtl { + .chat-drawer-resizer { + cursor: nesw-resize; + right: -5px; + } +} + .chat-drawer-outlet-container { // higher than timeline, lower than composer, lower than user card (bump up below) z-index: z("usercard"); position: fixed; right: var(--composer-right, 20px); left: 0; + + .rtl & { + left: var(--composer-right, 20px); + right: 0; + } + margin: 0; padding: 0; display: flex; @@ -37,6 +64,11 @@ body.composer-open .chat-drawer-outlet-container { .chat-drawer { align-self: flex-end; + width: 400px; + min-width: 250px !important; // important to override inline styles + max-width: calc(100% - var(--composer-right)); + max-height: 85vh; + min-height: 300px !important; // important to override inline styles .chat-drawer-container { background: var(--secondary); @@ -48,15 +80,21 @@ body.composer-open .chat-drawer-outlet-container { box-sizing: border-box; display: flex; flex-direction: column; + position: relative; + overflow: hidden; } &.is-expanded { .chat-drawer-container { - max-height: $float-height; - height: calc(85vh - var(--composer-height, 0px)); + height: 100%; } } + &:not(.is-expanded) { + min-height: 0 !important; + height: auto !important; + } + .chat-live-pane { height: 100%; } diff --git a/plugins/chat/assets/stylesheets/desktop/desktop.scss b/plugins/chat/assets/stylesheets/desktop/desktop.scss index 087736187b1..95c06084d07 100644 --- a/plugins/chat/assets/stylesheets/desktop/desktop.scss +++ b/plugins/chat/assets/stylesheets/desktop/desktop.scss @@ -1,8 +1,3 @@ -.chat-drawer { - width: 400px; - max-width: 100vw; -} - .user-card, .group-card { z-index: z("usercard") + 1; // bump up user card diff --git a/plugins/chat/spec/system/drawer_spec.rb b/plugins/chat/spec/system/drawer_spec.rb new file mode 100644 index 00000000000..da3f2325e7c --- /dev/null +++ b/plugins/chat/spec/system/drawer_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe "Drawer", type: :system, js: true do + fab!(:current_user) { Fabricate(:admin) } + let(:chat_page) { PageObjects::Pages::Chat.new } + let(:drawer) { PageObjects::Pages::ChatDrawer.new } + + before do + chat_system_bootstrap + sign_in(current_user) + end + + context "when opening" do + it "uses stored size" do + visit("/") # we need to visit the page first to set the local storage + + page.execute_script "window.localStorage.setItem('discourse_chat_drawer_size_width','500');" + page.execute_script "window.localStorage.setItem('discourse_chat_drawer_size_height','500');" + + visit("/") + + chat_page.open_from_header + + expect(page.find(".chat-drawer").native.style("width")).to eq("500px") + expect(page.find(".chat-drawer").native.style("height")).to eq("500px") + end + + it "has a default size" do + visit("/") + + chat_page.open_from_header + + expect(page.find(".chat-drawer").native.style("width")).to eq("400px") + expect(page.find(".chat-drawer").native.style("height")).to eq("530px") + end + end +end diff --git a/plugins/chat/test/javascripts/unit/services/chat-drawer-size-test.js b/plugins/chat/test/javascripts/unit/services/chat-drawer-size-test.js new file mode 100644 index 00000000000..3bc9ca81adb --- /dev/null +++ b/plugins/chat/test/javascripts/unit/services/chat-drawer-size-test.js @@ -0,0 +1,22 @@ +import { module, test } from "qunit"; +import { getOwner } from "discourse-common/lib/get-owner"; + +module("Discourse Chat | Unit | Service | chat-drawer-size", function (hooks) { + hooks.beforeEach(function () { + this.subject = getOwner(this).lookup("service:chat-drawer-size"); + }); + + test("get size (with default)", async function (assert) { + assert.deepEqual(this.subject.size, { width: 400, height: 530 }); + }); + + test("set size", async function (assert) { + this.subject.size = { width: 400, height: 500 }; + assert.deepEqual(this.subject.size, { width: 400, height: 500 }); + }); + + test("min size", async function (assert) { + this.subject.size = { width: 100, height: 100 }; + assert.deepEqual(this.subject.size, { width: 250, height: 300 }); + }); +});