Skip to content

Commit

Permalink
feat(selection): add double click detection
Browse files Browse the repository at this point in the history
  • Loading branch information
crimx committed Apr 16, 2018
1 parent 6d69462 commit 1299bec
Show file tree
Hide file tree
Showing 2 changed files with 119 additions and 37 deletions.
80 changes: 47 additions & 33 deletions src/selection/index.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,95 @@
import { appConfigFactory, AppConfig } from 'src/app-config'
import { message, storage } from '@/_helpers/browser-api'
import { AppConfig, appConfigFactory } from '@/app-config'
import { message } from '@/_helpers/browser-api'
import { isContainChinese, isContainEnglish } from '@/_helpers/lang-check'
import { createAppConfigStream } from '@/_helpers/config-manager'
import * as selection from '@/_helpers/selection'
import { MsgType, PostMsgType, PostMsgSelection, MsgSelection } from '@/typings/message'

import { Observable } from 'rxjs/Observable'
import { of } from 'rxjs/observable/of'
import { map, filter, withLatestFrom, buffer, debounceTime, observeOn, share , startWith } from 'rxjs/operators'
import { map, mapTo, scan, filter, take, switchMap, buffer, debounceTime, observeOn, share , startWith } from 'rxjs/operators'
import { fromEvent } from 'rxjs/observable/fromEvent'
import { timer } from 'rxjs/observable/timer'
import { of } from 'rxjs/observable/of'
import { merge } from 'rxjs/observable/merge'
import { async } from 'rxjs/scheduler/async'

message.addListener(MsgType.__PreloadSelection__, (data, sender, sendResponse) => {
sendResponse(selection.getSelectionInfo())
})

window.addEventListener('message', ({ data, source }: { data: PostMsgSelection, source: Window }) => {
/** Pass through message from iframes */
window.addEventListener('message', ({ data, source }: { data: PostMsgSelection, source: Window | null }) => {
if (data.type !== PostMsgType.Selection) { return }

// get the souce iframe
const iframe = Array.from(document.querySelectorAll('iframe'))
.find(({ contentWindow }) => contentWindow === source)
if (!iframe) { return }

const { selectionInfo, mouseX, mouseY, ctrlKey } = data
const { selectionInfo, mouseX, mouseY, ctrlKey, dbClick } = data
const { left, top } = iframe.getBoundingClientRect()

sendMessage(
mouseX + left,
mouseY + top,
dbClick,
ctrlKey,
selectionInfo
)
})

const appConfig$$: Observable<AppConfig> = createAppConfigStream().pipe(
share(),
)
let config = appConfigFactory()
let isCtrlPressed = false
let clickPeriodCount = 0

const isCtrlPressed$$: Observable<boolean> = merge(
const isCtrlPressed$: Observable<boolean> = merge(
fromEvent(window, 'keydown', true, e => isCtrlKey(e)),
fromEvent(window, 'keyup', true, e => false),
fromEvent(window, 'blur', true, e => false),
).pipe(
share(),
startWith(false),
of(false)
)

const ctrlPressed$ = isCtrlPressed$$.pipe(
withLatestFrom(appConfig$$, (isCtrlPressed, config) => config.active && isCtrlPressed),
filter(isCtrlPressed => isCtrlPressed),
const validCtrlPressed$$ = isCtrlPressed$.pipe(
filter(isCtrlPressed => config.active && isCtrlPressed),
share(),
)

const tripleCtrlPressed$ = ctrlPressed$.pipe(
buffer(ctrlPressed$.pipe(debounceTime(500))),
map(group => group.length),
filter(x => x >= 3),
const tripleCtrlPressed$ = validCtrlPressed$$.pipe(
buffer(debounceTime(500)(validCtrlPressed$$)),
filter(group => group.length >= 3),
)

const mouseup$ = fromEvent<MouseEvent>(window, 'mouseup', true).pipe(
withLatestFrom(appConfig$$, isCtrlPressed$$),
filter(([ e, config ]) => {
if (!config.active || window.name === 'saladict-frame') { return false }
if ((e.target as Element).className && ((e.target as Element).className.startsWith('saladict-'))) {
return false
}
return true
}),
const validMouseup$$ = fromEvent<MouseEvent>(window, 'mouseup', true).pipe(
filter(({ target }) => (
config.active &&
window.name !== 'saladict-frame' &&
(!target || !target['className'] || !target['className'].startsWith('saladict-'))
)),
// if user click on a selected text,
// getSelection would reture the text before the highlight disappears
// delay to wait for selection get cleared
observeOn(async),
share(),
)

const clickPeriodCount$ = merge(
mapTo(true)(validMouseup$$),
switchMap(() => timer(config.doubleClickDelay).pipe(take(1), mapTo(false)))(validMouseup$$)
).pipe(
scan((sum: number, flag: boolean) => flag ? sum + 1 : 0, 0)
)

createAppConfigStream().subscribe(newConfig => config = newConfig)

isCtrlPressed$.subscribe(flag => isCtrlPressed = flag)

clickPeriodCount$.subscribe(count => clickPeriodCount = count)

tripleCtrlPressed$.subscribe(() => {
message.self.send({ type: MsgType.TripleCtrl })
})

mouseup$.subscribe(([ evt, config, ctrlKey ]) => {
validMouseup$$.subscribe(({ clientX, clientY }) => {
const text = selection.getSelectionText()
if (
text && (
Expand All @@ -88,9 +98,10 @@ mouseup$.subscribe(([ evt, config, ctrlKey ]) => {
)
) {
sendMessage(
evt.clientX,
evt.clientY,
ctrlKey,
clientX,
clientY,
clickPeriodCount >= 2,
isCtrlPressed,
{
text: selection.getSelectionText(),
context: selection.getSelectionSentence(),
Expand All @@ -109,6 +120,7 @@ mouseup$.subscribe(([ evt, config, ctrlKey ]) => {
function sendMessage (
clientX: number,
clientY: number,
dbClick: boolean,
isCtrlPressed: boolean,
selectionInfo: selection.SelectionInfo
) {
Expand All @@ -119,6 +131,7 @@ function sendMessage (
selectionInfo,
mouseX: clientX,
mouseY: clientY,
dbClick,
ctrlKey: isCtrlPressed,
} as MsgSelection)
} else {
Expand All @@ -128,6 +141,7 @@ function sendMessage (
selectionInfo,
mouseX: clientX,
mouseY: clientY,
dbClick,
ctrlKey: isCtrlPressed,
} as PostMsgSelection, '*')
}
Expand Down
76 changes: 72 additions & 4 deletions test/specs/selection/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,21 @@ const { dispatchAppConfigEvent }: {
dispatchAppConfigEvent: typeof ConfigManagerMock.dispatchAppConfigEvent
} = require('@/_helpers/config-manager')

// speed up
const mockAppConfigFactory = () => {
const config = appConfigFactory() as AppConfigMutable
config.doubleClickDelay = 0
return config
}

describe('Message Selection', () => {
beforeEach(() => {
browser.flush()
window.name = ''
message.self.send.mockClear()
selection.getSelectionText.mockReturnValue('test')
selection.getSelectionSentence.mockReturnValue('This is a test.')
dispatchAppConfigEvent(appConfigFactory())
dispatchAppConfigEvent(mockAppConfigFactory())
})

it('should send empty message when mouseup and no selection', done => {
Expand All @@ -51,7 +58,7 @@ describe('Message Selection', () => {
})

it('should send empty message if the selection language does not match (Chinese)', done => {
const config = appConfigFactory() as AppConfigMutable
const config = mockAppConfigFactory()
config.language.chinese = true
config.language.english = false
dispatchAppConfigEvent(config)
Expand All @@ -75,7 +82,7 @@ describe('Message Selection', () => {
it('should send empty message if the selection language does not match (English)', done => {
selection.getSelectionText.mockReturnValue('你好')
selection.getSelectionSentence.mockReturnValue('你好')
const config = appConfigFactory() as AppConfigMutable
const config = mockAppConfigFactory()
config.language.chinese = false
config.language.english = true
dispatchAppConfigEvent(config)
Expand Down Expand Up @@ -109,6 +116,7 @@ describe('Message Selection', () => {
type: MsgType.Selection,
mouseX: 10,
mouseY: 10,
dbClick: false,
ctrlKey: false,
selectionInfo: expect.objectContaining({
text: 'test',
Expand Down Expand Up @@ -139,7 +147,7 @@ describe('Message Selection', () => {
})

it('should do nothing if conifg.active is off', done => {
const config = appConfigFactory() as AppConfigMutable
const config = mockAppConfigFactory()
config.active = false
dispatchAppConfigEvent(config)

Expand Down Expand Up @@ -240,4 +248,64 @@ describe('Message Selection', () => {
done()
}, 510)
})

it('should not trigger double click if the interval is too long', done => {
const config = mockAppConfigFactory()
config.doubleClickDelay = 100
dispatchAppConfigEvent(config)

window.dispatchEvent(new MouseEvent('mouseup', {
button: 0,
clientX: 10,
clientY: 10,
}))

setTimeout(() => {
window.dispatchEvent(new MouseEvent('mouseup', {
button: 0,
clientX: 20,
clientY: 20,
}))

setTimeout(() => {
expect(message.self.send).toHaveBeenCalledTimes(2)
expect(message.self.send).toBeCalledWith(
expect.objectContaining({
dbClick: false,
}),
)
done()
}, 0)
}, 200)
})

it('should trigger double click if the interval is within delay', done => {
const config = mockAppConfigFactory()
config.doubleClickDelay = 100
dispatchAppConfigEvent(config)

window.dispatchEvent(new MouseEvent('mouseup', {
button: 0,
clientX: 10,
clientY: 10,
}))

setTimeout(() => {
window.dispatchEvent(new MouseEvent('mouseup', {
button: 0,
clientX: 20,
clientY: 20,
}))

setTimeout(() => {
expect(message.self.send).toHaveBeenCalledTimes(2)
expect(message.self.send).toBeCalledWith(
expect.objectContaining({
dbClick: true,
}),
)
done()
}, 0)
}, 50)
})
})

0 comments on commit 1299bec

Please sign in to comment.