From 9e253d88ee5bf0716f6b0279994f590b70354fa6 Mon Sep 17 00:00:00 2001 From: IronKinoko Date: Mon, 8 Jan 2024 18:01:39 +0800 Subject: [PATCH] feat: support syosetu --- packages/novel-speech-synthesis/meta.template | 1 + .../src/adapter/index.ts | 21 +++ packages/novel-speech-synthesis/src/index.ts | 12 ++ .../src/speech/index.scss | 34 +++- .../src/speech/index.ts | 152 +++++++++++++++++- .../src/speech/ui.template.html | 19 +++ 6 files changed, 227 insertions(+), 12 deletions(-) diff --git a/packages/novel-speech-synthesis/meta.template b/packages/novel-speech-synthesis/meta.template index ea45591..fe6f2b4 100644 --- a/packages/novel-speech-synthesis/meta.template +++ b/packages/novel-speech-synthesis/meta.template @@ -9,6 +9,7 @@ // @match https://www.bilixs.com/* // @match https://www.bilinovel.com/* // @match https://www.linovelib.com/* +// @match https://novel18.syosetu.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain= // @grant none // @noframes diff --git a/packages/novel-speech-synthesis/src/adapter/index.ts b/packages/novel-speech-synthesis/src/adapter/index.ts index 1ce8ac7..b9e39d7 100644 --- a/packages/novel-speech-synthesis/src/adapter/index.ts +++ b/packages/novel-speech-synthesis/src/adapter/index.ts @@ -49,4 +49,25 @@ export const adapter: Record = { ?.click() }, }, + 'novel18.syosetu': { + container: 'body', + lang: 'ja-JP', + getParagraph: () => { + const content = document.querySelector('#novel_honbun') + if (!content) throw new Error('content not found') + + return Array.from(content.querySelectorAll('p')) + }, + nextChapter: () => { + let dom = document.querySelector( + '.novel_bn a[rel="next"]' + ) + if (!dom) { + dom = document.querySelector( + '.novel_bn a:last-child' + ) + } + dom?.click() + }, + }, } diff --git a/packages/novel-speech-synthesis/src/index.ts b/packages/novel-speech-synthesis/src/index.ts index c568885..71ef799 100644 --- a/packages/novel-speech-synthesis/src/index.ts +++ b/packages/novel-speech-synthesis/src/index.ts @@ -37,3 +37,15 @@ router({ }, ], }) + +router({ + domain: ['novel18.syosetu.com'], + routes: [ + { + pathname: /^\/([^/]+?)\/([^/]+?)\/?$/, + run: () => { + new Speech(adapter['novel18.syosetu']) + }, + }, + ], +}) diff --git a/packages/novel-speech-synthesis/src/speech/index.scss b/packages/novel-speech-synthesis/src/speech/index.scss index 9f465b9..eae3007 100644 --- a/packages/novel-speech-synthesis/src/speech/index.scss +++ b/packages/novel-speech-synthesis/src/speech/index.scss @@ -1,11 +1,27 @@ #speech { position: fixed; - bottom: calc(env(safe-area-inset-bottom) + 16px); - right: 16px; z-index: 999999999; - border-radius: 8px; border: 1px solid #ddd; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; + transform: translateX(0); + opacity: 1; + box-sizing: border-box; + overflow: hidden; + box-shadow: rgba(0, 0, 0, 0.2) -1px 1px 10px 0px; + + &.left { + border-radius: 0 8px 8px 0; + --transform-x: -100%; + } + &.right { + border-radius: 8px 0 0 8px; + --transform-x: 100%; + } + &.hide { + opacity: 0; + pointer-events: none; + transform: translateX(var(--transform-x)); + } * { box-sizing: border-box; @@ -22,11 +38,13 @@ .speech-controls-button { padding: 0 4px; width: 35px; + height: 28px; text-align: center; cursor: pointer; display: flex; justify-content: center; align-items: center; + margin: 0; } .speech-controls-button + .speech-controls-button { border-left: 1px solid #ddd; @@ -65,6 +83,14 @@ color: #1890ff; } } + .speech-controls-menu { + input:checked + svg { + color: #1890ff; + } + } + .speech-controls-hide { + display: none !important; + } } .speech-reading { text-decoration: underline !important; diff --git a/packages/novel-speech-synthesis/src/speech/index.ts b/packages/novel-speech-synthesis/src/speech/index.ts index 93a4e03..8ec2364 100644 --- a/packages/novel-speech-synthesis/src/speech/index.ts +++ b/packages/novel-speech-synthesis/src/speech/index.ts @@ -1,4 +1,4 @@ -import { keybind } from 'shared' +import { keybind, local, throttle } from 'shared' import './index.scss' import T from './ui.template.html' @@ -12,16 +12,19 @@ export type SpeechOptions = { export default class Speech { private elements: { + root: HTMLDivElement play: HTMLInputElement voice: HTMLSelectElement rate: HTMLSelectElement continuous: HTMLInputElement + menu: HTMLInputElement } | null = null private utterance = { rate: 1.5, voiceURI: null as string | null, continuous: true, + menu: true, } private voices: SpeechSynthesisVoice[] = [] private paragraphList = [] as HTMLElement[] @@ -62,21 +65,23 @@ export default class Speech { } private createUI() { - const dom = new DOMParser().parseFromString(T.speech, 'text/html').body - .children[0] + const root = new DOMParser().parseFromString(T.speech, 'text/html').body + .children[0] as HTMLDivElement const container = document.querySelector(this.opts.container) if (!container) throw new Error('container not found') - container.appendChild(dom) + container.appendChild(root) window.addEventListener('beforeunload', () => { this.cancel() }) this.elements = { - play: dom.querySelector('.speech-controls-play input')!, - voice: dom.querySelector('.speech-controls-voice')!, - rate: dom.querySelector('.speech-controls-rate')!, - continuous: dom.querySelector('.speech-controls-continuous input')!, + root, + play: root.querySelector('.speech-controls-play input')!, + voice: root.querySelector('.speech-controls-voice')!, + rate: root.querySelector('.speech-controls-rate')!, + continuous: root.querySelector('.speech-controls-continuous input')!, + menu: root.querySelector('.speech-controls-menu input')!, } this.elements.play.addEventListener('change', (e) => { const target = e.target as HTMLInputElement @@ -131,12 +136,143 @@ export default class Speech { this.saveUtterance() }) + this.elements.menu.checked = this.utterance.menu + this.elements.menu.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement + this.utterance.menu = target.checked + this.updateMenuUI() + this.saveUtterance() + }) + + this.updateMenuUI() + + this.addDragEvent(root) + keybind(['space'], (e) => { e.preventDefault() this.elements!.play.click() }) } + private addDragEvent(fxiedDom: HTMLDivElement) { + let prevY = 0 + let storeY = 0 + const key = 'speech-fixed-position' + let position = local.getItem(key, { + top: document.documentElement.clientHeight / 4, + left: document.documentElement.clientWidth, + }) + + // safe position area + const safeArea = { + top: (y: number) => + Math.min( + Math.max(y, 0), + document.documentElement.clientHeight - + fxiedDom!.getBoundingClientRect().height + ), + left: (x: number) => + Math.min( + Math.max(x, 0), + document.documentElement.clientWidth - + fxiedDom!.getBoundingClientRect().width + ), + } + + // set fixedNextBtn position + const setPosition = ( + position: { left: number; top: number }, + isMoving: boolean + ) => { + fxiedDom!.classList.remove('left', 'right') + fxiedDom!.style.transition = isMoving ? 'none' : '' + fxiedDom!.style.top = `${position.top}px` + fxiedDom!.style.left = `${position.left}px` + + if (!isMoving) { + const halfScreenWidth = document.documentElement.clientWidth / 2 + fxiedDom!.classList.add( + position.left > halfScreenWidth ? 'right' : 'left' + ) + fxiedDom!.style.left = + position.left > halfScreenWidth + ? `${ + document.documentElement.clientWidth - + fxiedDom!.getBoundingClientRect().width + }px` + : '0px' + } + } + + setPosition(position, false) + + // remember fixedNextBtn move position + fxiedDom.addEventListener('touchstart', (e) => { + const touch = e.touches[0] + const { clientX, clientY } = touch + const { top, left } = fxiedDom!.getBoundingClientRect() + const diffX = clientX - left + const diffY = clientY - top + const move = (e: TouchEvent) => { + e.preventDefault() + e.stopPropagation() + const touch = e.touches[0] + const { clientX, clientY } = touch + const x = safeArea.left(clientX - diffX) + const y = safeArea.top(clientY - diffY) + position = { top: y, left: x } + setPosition(position, true) + } + const end = () => { + local.setItem(key, position) + setPosition(position, false) + fxiedDom!.style.removeProperty('transition') + window.removeEventListener('touchmove', move) + window.removeEventListener('touchend', end) + } + window.addEventListener('touchmove', move, { passive: false }) + window.addEventListener('touchend', end) + }) + + window.addEventListener( + 'scroll', + throttle(() => { + const dom = document.scrollingElement! + + const currentY = dom.scrollTop + let diffY = currentY - storeY + if ( + currentY < 50 || + currentY + dom.clientHeight > dom.scrollHeight - 800 || + diffY < -30 + ) { + fxiedDom?.classList.remove('hide') + } else { + fxiedDom?.classList.add('hide') + } + + if (currentY > prevY) { + storeY = currentY + } + prevY = currentY + }, 100) + ) + } + + private updateMenuUI() { + this.elements!.root.querySelectorAll('.speech-controls-button').forEach( + (dom) => { + if (dom.classList.contains('speech-controls-menu')) return + + if (this.utterance.menu) { + dom.classList.remove('speech-controls-hide') + } else { + dom.classList.add('speech-controls-hide') + } + } + ) + } + private refreshSpeech() { const idx = this.currentSpeakingParagraphIdx if (idx === null) return diff --git a/packages/novel-speech-synthesis/src/speech/ui.template.html b/packages/novel-speech-synthesis/src/speech/ui.template.html index a7c297b..a07ce35 100644 --- a/packages/novel-speech-synthesis/src/speech/ui.template.html +++ b/packages/novel-speech-synthesis/src/speech/ui.template.html @@ -28,6 +28,11 @@ C +