Skip to content
Draft
57 changes: 56 additions & 1 deletion extensions/mentions/js/src/forum/addPostQuoteButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,38 @@ import PostQuoteButton from './fragments/PostQuoteButton';
import selectedText from './utils/selectedText';

export default function addPostQuoteButton() {
let pointerType;
let pointerDownTarget;
let selectionOnPointerDown;
const selectionSnapshot = () => {
const selection = window.getSelection();
return [selection?.anchorNode, selection?.anchorOffset, selection?.focusNode, selection?.focusOffset];
};
const cleanups = new WeakMap();

document.addEventListener(
'pointerdown',
(event) => {
pointerType = event.pointerType;
pointerDownTarget = event.target;
selectionOnPointerDown = selectionSnapshot();
},
true
);
document.addEventListener(
'keydown',
(event) => {
if (!['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) pointerType = undefined;
},
true
);

extend(CommentPost.prototype, 'oncreate', function () {
const post = this.attrs.post;

if (post.isHidden() || (app.session.user && !post.discussion().canReply())) return;

const postElement = this.$()[0];
const $postBody = this.$('.Post-body');

// Wrap the quote button in a wrapper element so that we can render
Expand All @@ -20,9 +47,21 @@ export default function addPostQuoteButton() {
const button = new PostQuoteButton(post);

const handler = function (e) {
if (e.type === 'mouseup' && pointerType === 'touch') return;

if (
e.type === 'mouseup' &&
pointerType === 'mouse' &&
!postElement.contains(pointerDownTarget) &&
selectionSnapshot().every((value, index) => value === selectionOnPointerDown[index])
)
return;

setTimeout(() => {
const content = selectedText($postBody);
if (content) {
if (e.type === 'selectionchange' && pointerType === 'mouse') return;

button.content = content;
m.render($container[0], button.render());

Expand Down Expand Up @@ -59,10 +98,26 @@ export default function addPostQuoteButton() {
}, 1);
};

this.$().after($container).on('mouseup', handler);
// A flashing post has a transform, which would become the quote button's
// positioning context and make it jump when the animation ends.
$(document.body).append($container);
$(document).on('mouseup', handler);

if ('ontouchstart' in window) {
document.addEventListener('selectionchange', handler, false);
}

cleanups.set(this, () => {
document.removeEventListener('selectionchange', handler, false);
$(document).off('mouseup', handler);
button.hide();
m.render($container[0], null);
$container.remove();
});
});

extend(CommentPost.prototype, 'onremove', function () {
cleanups.get(this)?.();
cleanups.delete(this);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ export default class PostQuoteButton extends Fragment {
<button
className="Button PostQuoteButton"
type="button"
onpointerdown={(event) => {
// Preserve the selection and prevent mouseup from hiding the button
// before the touch-generated click can activate it.
if (event.button === 0) event.preventDefault();
}}
onclick={() => {
this.hide();
// If the user is not logged in, the promise rejects, and a login modal shows up.
// Since that's already handled, we dont need to show an error message in the console.
reply(this.post, this.content).catch(() => {});
Expand Down
148 changes: 144 additions & 4 deletions extensions/mentions/js/tests/unit/forum/addPostQuoteButton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import $ from 'jquery';
import addPostQuoteButton from '../../../src/forum/addPostQuoteButton';

const originalOncreate = CommentPost.prototype.oncreate;
const originalOnremove = CommentPost.prototype.onremove;
let renderedPost: any;

// A phone-sized viewport, and the size of the rendered button. jsdom does no
// layout, so both are set here.
Expand All @@ -22,6 +24,7 @@ beforeAll(() => {
// Only the quote button's own hook runs: the stand-in post below is not a
// real CommentPost.
CommentPost.prototype.oncreate = function () {};
CommentPost.prototype.onremove = function () {};
addPostQuoteButton();

// jsdom has no innerText. The quote helper reads it from a detached clone,
Expand Down Expand Up @@ -60,18 +63,24 @@ beforeAll(() => {

afterAll(() => {
CommentPost.prototype.oncreate = originalOncreate;
CommentPost.prototype.onremove = originalOnremove;
});

beforeEach(() => jest.useFakeTimers());
beforeEach(() => {
jest.useFakeTimers();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
});

afterEach(() => {
if (renderedPost) CommentPost.prototype.onremove.call(renderedPost);
renderedPost = undefined;
jest.useRealTimers();
// @ts-ignore jsdom has no getClientRects on a range; each test sets its own.
delete Range.prototype.getClientRects;
});

/** A rendered post that the quote button has been added to. */
function renderPost(): HTMLElement {
function renderPost(transformed = false): HTMLElement {
app.store.pushPayload({
data: {
type: 'posts',
Expand All @@ -85,9 +94,18 @@ function renderPost(): HTMLElement {
const post = app.store.getById<Post>('posts', '1')!;

document.body.innerHTML = '<article class="Post"><div class="Post-body"><p>Some text to quote</p></div></article><p class="Elsewhere">Other text</p>';
if (transformed) {
const item = document.createElement('div');
item.className = 'PostStream-item';
item.style.transform = 'scale(1.02)';
document.querySelector('.Post')!.before(item);
item.appendChild(document.querySelector('.Post')!);
}

const element = document.querySelector<HTMLElement>('.Post')!;

CommentPost.prototype.oncreate.call({ attrs: { post }, $: (selector?: string) => (selector ? $(element).find(selector) : $(element)) } as any);
renderedPost = { attrs: { post }, $: (selector?: string) => (selector ? $(element).find(selector) : $(element)) };
CommentPost.prototype.oncreate.call(renderedPost);

return element;
}
Expand Down Expand Up @@ -124,6 +142,128 @@ function quoteButton(): HTMLElement {
}

describe('the quote button', () => {
it('waits for mouse release on a touch-capable device', () => {
const element = renderPost();
const pointerdown = new Event('pointerdown', { bubbles: true });
Object.defineProperty(pointerdown, 'pointerType', { value: 'mouse' });
document.dispatchEvent(pointerdown);

selectByTouch(element, [{ left: 40, right: 200, top: 300, bottom: 320 }]);

expect(document.querySelector('.PostQuoteButton')).toBeNull();

$(element).trigger($.Event('mouseup', { clientX: 200, clientY: 330 }));
jest.advanceTimersByTime(1);

expect(quoteButton().style.top).toBe('325px');

document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true }));
selectByTouch(element, [{ left: 40, right: 200, top: 400, bottom: 420 }]);
expect(quoteButton().style.top).toBe('490px');

document.dispatchEvent(pointerdown);
const touch = new Event('pointerdown', { bubbles: true });
Object.defineProperty(touch, 'pointerType', { value: 'touch' });
document.dispatchEvent(touch);
selectByTouch(element, [{ left: 40, right: 200, top: 500, bottom: 520 }]);
expect(quoteButton().style.top).toBe('590px');
});

it('protects a primary quote press and hides the button after activation', () => {
const element = renderPost();
selectByTouch(element, [{ left: 40, right: 200, top: 300, bottom: 320 }]);
const button = quoteButton();

for (const buttonNumber of [1, 2]) {
const secondaryPress = new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: buttonNumber });
button.dispatchEvent(secondaryPress);
expect(secondaryPress.defaultPrevented).toBe(false);
}

const press = new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 });
button.dispatchEvent(press);
expect(press.defaultPrevented).toBe(true);

button.click();
expect(button.style.display).toBe('none');
expect(($ as any)._data(document, 'events')?.mouseup).toHaveLength(1);
});

it('shows a mouse selection when it is released outside the post', () => {
const element = renderPost();
const pointerdown = new Event('pointerdown', { bubbles: true });
Object.defineProperty(pointerdown, 'pointerType', { value: 'mouse' });
element.dispatchEvent(pointerdown);
selectByTouch(element, [{ left: 40, right: 200, top: 300, bottom: 320 }]);

document.dispatchEvent(new MouseEvent('mouseup', { clientX: 250, clientY: 330 }));
jest.advanceTimersByTime(1);

expect(quoteButton()?.style.top).toBe('325px');
});

it('keeps the quote overlay outside transformed posts and removes it with the post', () => {
const element = renderPost(true);
selectByTouch(element, [{ left: 40, right: 200, top: 300, bottom: 320 }]);

expect(quoteButton().closest('.PostStream-item')).toBeNull();
const container = quoteButton().parentElement!;

CommentPost.prototype.onremove.call(renderedPost);
expect(($ as any)._data(document, 'events')?.mouseup).toBeUndefined();
expect(document.querySelector('.PostQuoteButton')).toBeNull();

selectByTouch(element, [{ left: 40, right: 200, top: 300, bottom: 320 }]);
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 200, clientY: 330 }));
jest.advanceTimersByTime(1);
expect(document.querySelector('.PostQuoteButton')).toBeNull();
expect(container.querySelector('.PostQuoteButton')).toBeNull();
});

it('keeps using mouse positioning when a modifier is pressed during a drag', () => {
const element = renderPost();
const pointerdown = new Event('pointerdown', { bubbles: true });
Object.defineProperty(pointerdown, 'pointerType', { value: 'mouse' });
element.dispatchEvent(pointerdown);

document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', shiftKey: true }));
selectByTouch(element, [{ left: 40, right: 200, top: 300, bottom: 320 }]);
expect(document.querySelector('.PostQuoteButton')).toBeNull();

document.dispatchEvent(new MouseEvent('mouseup', { clientX: 200, clientY: 330 }));
jest.advanceTimersByTime(1);
expect(quoteButton().style.top).toBe('325px');
});

it('dismisses Quote after an unrelated control release', () => {
const element = renderPost();
selectByTouch(element, [{ left: 40, right: 200, top: 300, bottom: 320 }]);
const button = quoteButton();
const pointerdown = new Event('pointerdown', { bubbles: true });
Object.defineProperty(pointerdown, 'pointerType', { value: 'mouse' });
document.querySelector('.Elsewhere')!.dispatchEvent(pointerdown);

document.dispatchEvent(new MouseEvent('mouseup', { clientX: 200, clientY: 330 }));
jest.advanceTimersByTime(1);

expect(button.style.display).toBe('none');
});

it('does not re-place a touch selection on compatibility mouseup', () => {
const element = renderPost();
const pointerdown = new Event('pointerdown', { bubbles: true });
Object.defineProperty(pointerdown, 'pointerType', { value: 'touch' });
element.dispatchEvent(pointerdown);
selectByTouch(element, [{ left: 40, right: 200, top: 300, bottom: 320 }]);
const button = quoteButton();

document.dispatchEvent(new MouseEvent('mouseup', { clientX: 200, clientY: 330 }));
jest.advanceTimersByTime(1);

expect(button.style.top).toBe('390px');
expect(button.style.display).toBe('none');
});

it('shows above a selection', () => {
const element = renderPost();

Expand Down Expand Up @@ -275,7 +415,7 @@ describe('the quote button', () => {
$(document).trigger('mouseup');

expect(quoteButton().style.display).toBe('none');
expect(($ as any)._data(document, 'events')?.mouseup).toBeUndefined();
expect(($ as any)._data(document, 'events')?.mouseup).toHaveLength(1);
});

// Browsers can report no rectangles for a selection, e.g. one that ends
Expand Down
Loading