famibee/SKYNovel

View on GitHub
src/sn/EventMng.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* ***** BEGIN LICENSE BLOCK *****
    Copyright (c) 2018-2024 Famibee (famibee.blog38.fc2.com)

    This software is released under the MIT License.
    http://opensource.org/licenses/mit-license.php
** ***** END LICENSE BLOCK ***** */

import {CmnLib, IEvtMng, argChk_Boolean, addStyle, mesErrJSON} from './CmnLib';
import {IHTag, HArg} from './Grammar';
import {IVariable, IMain, IHEvt2Fnc} from './CmnInterface';
import {LayerMng} from './LayerMng';
import {ScriptIterator} from './ScriptIterator';
import {TxtLayer} from './TxtLayer';
import {EventListenerCtn} from './EventListenerCtn';
import {Button} from './Button';
import {FocusMng} from './FocusMng';
import {Main} from './Main';
import {SoundMng} from './SoundMng';
import {Config} from './Config';
import {SysBase} from './SysBase';
import {SEARCH_PATH_ARG_EXT} from './ConfigBase';
import {ReadState} from './ReadState';

import {Container, Application, utils} from 'pixi.js';
const {GamepadListener} = require('gamepad.js');
import {createPopper, Instance as InsPop} from '@popperjs/core';

export class EventMng implements IEvtMng {
    readonly    #elc        = new EventListenerCtn;

    readonly    #gamepad    = new GamepadListener({
        analog    : false,
        deadZone: 0.3,
    });
    readonly    #fcs        = new FocusMng;

    #rs    : ReadState;

    constructor(private readonly cfg: Config, private readonly hTag: IHTag, readonly appPixi: Application, private readonly main: IMain, readonly layMng: LayerMng, readonly val: IVariable, sndMng: SoundMng, private readonly scrItr: ScriptIterator, readonly sys: SysBase) {
        //    イベント
        hTag.clear_event    = o=> ReadState.clear_event(o);    // イベントを全消去
        // enable_event        // LayerMng.ts内で定義        //イベント有無の切替
        hTag.event            = o=> this.#event(o);    // イベントを予約
        //hTag.gesture_event    (形式変更)            // ジェスチャイベントを予約
        hTag.l                = o=> this.#rs.l(o);        // 行末クリック待ち
        hTag.p                = o=> this.#rs.p(o);        // 改ページクリック待ち
        hTag.s                = o=> this.#rs.s(o);        // 停止する
        hTag.set_cancel_skip= ()=> false;            // (2023/05/27 廃止)スキップ中断予約
        hTag.set_focus        = o=> this.#set_focus(o);    // フォーカス移動
        hTag.wait            = o=> this.#rs.wait(o);        // ウェイトを入れる
        hTag.waitclick        = o=> this.#rs.waitclick(o);    // クリックを待つ

        // ラベル・ジャンプ
        hTag.page            = o=> this.#rs.page(o);        // ページ移動

        sndMng.setEvtMng(this);
        scrItr.setOtherObj(this, layMng);
        TxtLayer.setEvtMng(this, sys);
        layMng.setEvtMng(this);
        sys.setFire((KEY, e)=> this.fire(KEY, e));

        if (CmnLib.isDbg) {
            const hHook    : {[type: string]: ()=> void}    = {
                pause    : ()=> {
//                    this.#isDbgBreak = true;
                    if (! this.#rs.isWait) return;

                    const hArg: HArg = {};
                    scrItr.recodeDesign(hArg);
                    sys.callHook('_enterDesign', hArg);
                    sys.send2Dbg('_enterDesign', hArg);
                },
//                stopOnBreakpoint        : ()=> this.#isDbgBreak = true,
//                stopOnDataBreakpoint    : ()=> this.#isDbgBreak = true,
//                continue                : ()=> this.#isDbgBreak = false,
//                disconnect                : ()=> this.#isDbgBreak = false,
            };
            hHook.attach =
            hHook.stopOnEntry =
            hHook.stopOnStep =
            hHook.stopOnStepIn =
            hHook.stopOnStepOut =
            hHook.stopOnBackstep = hHook.pause;

            sys.addHook(type=> hHook[type]?.());
        }

        addStyle(`
.sn_hint {
    background-color: #3c3225;
    color: white;
    padding: 4px 8px;
    border-radius: 4px;
    font-size: 1.2em;
    z-index: 10000;
    pointer-events: none;
    user-select: none;
}

.sn_hint_ar,
.sn_hint_ar::before {
    position: absolute;
    width: 8px;
    height: 8px;
    background: inherit;
}
.sn_hint_ar {
    visibility: hidden;
}
.sn_hint_ar::before {
    visibility: visible;
    content: '';
    transform: rotate(45deg);
}

.sn_hint[data-popper-placement^='top']        > .sn_hint_ar {bottom: -4px;}
.sn_hint[data-popper-placement^='bottom']    > .sn_hint_ar {top: -4px;}
.sn_hint[data-popper-placement^='left']        > .sn_hint_ar {right: -4px;}
.sn_hint[data-popper-placement^='right']    > .sn_hint_ar {left: -4px;}
`);

        for (const v of Array.from(document.getElementsByClassName('sn_hint'))) v.parentElement?.removeChild(v);
            // ギャラリーリロード用初期化
        Main.cvs.parentElement?.insertAdjacentHTML('beforeend', `
<div class="sn_hint" role="tooltip">
    <span>Dummy</span>
    <div class="sn_hint_ar" data-popper-arrow></div>
</div>`);
        this.#elmHint = document.querySelector('.sn_hint') as HTMLElement;
        this.#spanHint = this.#elmHint.querySelector('span')!;
        this.#popper = createPopper(this.#elmV, this.#elmHint);
        this.#elmHint.hidden = true;


        appPixi.stage.interactive = true;
        if (CmnLib.isMobile) appPixi.stage.on('pointerdown', e=> this.fire('click', e));
        else this.#elc.add(appPixi.stage, 'pointerdown', e=> {
            switch (e.data.button) {
                case 0:    this.fire('click', e);    break;
                case 1:    this.fire('middleclick', e);    break;
            }
        });
        this.#elc.add(window, 'keydown', e=> this.#ev_keydown(e));
        this.#elc.add(Main.cvs, 'contextmenu', e=> this.#ev_contextmenu(e));

        // 言語切り替え通知
        const fncUpdNavLang = ()=> val.setVal_Nochk('tmp', 'const.sn.navigator.language', navigator.language);
        // TODO: アプリ版で[event key=sn:chgNavLang]が発生しない件
//        this.#elc.add(globalThis, 'languagechange', e=> {
        this.#elc.add(window, 'languagechange', e=> {
//console.log(`fn:EventMng.ts languagechange `);
            fncUpdNavLang();
            this.fire('sn:chgNavLang', e);
            utils.clearTextureCache();
        });
        fncUpdNavLang();

        // ダークモード切り替え検知
        const fncMql = (mq: MediaQueryList | MediaQueryListEvent)=> {
            CmnLib.isDarkMode = mq.matches;
            val.setVal_Nochk('tmp', 'const.sn.isDarkMode', CmnLib.isDarkMode);
        };
        const mql = globalThis.matchMedia('(prefers-color-scheme: dark)');
        fncMql(mql);
        this.#elc.add(mql, 'change', e=> {
            fncMql(e);
            this.fire('sn:chgDarkMode', e);
        });

        let procWheel4wle = (_elc: EventListenerCtn, _onIntr: ()=> void)=> {};
        if ('WheelEvent' in window) {
            this.#elc.add(Main.cvs, 'wheel', e=> this.#ev_wheel(e), {passive: true});
            this.#resvFlameEvent4Wheel = win=> this.#elc.add(win, 'wheel', e=> this.#ev_wheel(e), {passive: true});

            procWheel4wle = (elc: EventListenerCtn, fnc: ()=> void)=> elc.add(Main.cvs, 'wheel', e=> {
                //if (! e.isTrusted) return;
                if (e['isComposing']) return; // サポートしてない環境でもいける書き方
                if (e.deltaY <= 0) return;

                e.stopPropagation();
                fnc();
            });
        }
        ReadState.init((rs: ReadState)=> this.#rs = rs, main, val, layMng, scrItr, sndMng, hTag, this.#fcs, procWheel4wle, this.#elmHint, cfg);

        // Gamepad
        if (CmnLib.debugLog) {
            this.#gamepad.on('gamepad:connected', (e: any)=> console.log(`👺<'gamepad:connected' index:${e.detail.index} id:${e.detail.gamepad.id}`));
            this.#gamepad.on('gamepad:disconnected', (e: any)=> console.log(`👺<'gamepad:disconnected' index:${e.detail.index} id:${e.detail.gamepad.id}`));
        }
        const aStick: string[] = [
            '',            'ArrowUp',    '',                // '7', '8', '9',
            'ArrowLeft', '',        'ArrowRight',    // '4', '5', '6',
            '',            'ArrowDown', '',            // '1', '2', '3',
        ];
        const stick_xy = [0, 0];
        this.#gamepad.on('gamepad:axis', (e: any)=> {
            if (! document.hasFocus() || e.detail.stick !== 0) return;
            stick_xy[e.detail.axis] = e.detail.value;
            const s = (stick_xy[1] +1)*3 + (stick_xy[0] +1);
//console.log(`fn:EventMng.ts line:137 👺 'gamepad:axis' detail:%o`, e.detail);
            const s2 = aStick[s];
            if (! s2) return;
            const cmp = this.#fcs.getFocus();
            ((! cmp || cmp instanceof Container) ?globalThis :cmp)
            .dispatchEvent(new KeyboardEvent('keydown', {key: s2, bubbles: true}));

            if (! cmp || cmp instanceof Container) return;
            if (cmp.getAttribute('type') === 'range') cmp.dispatchEvent(new InputEvent('input', {bubbles: true}));    // スライダー変更時、表示数字が変わらない対応
        });
        this.#gamepad.on('gamepad:button', (e: any)=> {
            if (! document.hasFocus()) return;
//console.log(`fn:EventMng.ts line:155 👺 'gamepad:button' detail:%o`, e.detail);
            if (e.detail.button % 2 === 0) {
                const cmp = this.#fcs.getFocus();
                ((! cmp || cmp instanceof Container) ?globalThis :cmp)
                .dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
            }
            else Main.cvs.dispatchEvent(new Event('contextmenu'));
        });
        this.#gamepad.start();

        this.#elc.add(window, 'keyup', (e: any)=> {
            if (e['isComposing']) return;    // サポートしてない環境でもいける書き方

            if (e.key in this.#hDownKeys) this.#hDownKeys[e.key] = 0;
        });
        val.defTmp('const.sn.key.alternate', ()=> this.#hDownKeys['Alt'] > 0);
        val.defTmp('const.sn.key.command', ()=> this.#hDownKeys['Meta'] > 0);
        val.defTmp('const.sn.key.control', ()=> this.#hDownKeys['Control'] > 0);
        val.defTmp('const.sn.key.end', ()=> this.#hDownKeys['End'] > 0);
        val.defTmp('const.sn.key.escape', ()=> this.#hDownKeys['Escape'] > 0);
        val.defTmp('const.sn.key.back', ()=> this.#hDownKeys['GoBack'] > 0);
    }

    resvFlameEvent(win: Window) {
        this.#elc.add(win, 'keydown', e=> this.#ev_keydown(e));
        this.#elc.add(win, 'contextmenu', e=> this.#ev_contextmenu(e));
        this.#resvFlameEvent4Wheel(win);
    }
    #resvFlameEvent4Wheel = (_win: Window)=> {};
    #ev_keydown(e: any) {
        //if (! e.isTrusted) return;
        if (e['isComposing']) return;    // サポートしてない環境でもいける書き方

        if (e.key in this.#hDownKeys) this.#hDownKeys[e.key] = e.repeat ?2 :1;

        const key = (e.altKey ?(e.key === 'Alt' ?'' :'alt+') :'')
        +    (e.ctrlKey ?(e.key === 'Control' ?'' :'ctrl+') :'')
        +    (e.shiftKey ?(e.key === 'Shift' ?'' :'shift+') :'')
        +    e.key;
        this.fire(key, e);
    }
    #ev_contextmenu(e: any) {
        //if (! e.isTrusted) return;

        const key = (e.altKey ?(e.key === 'Alt' ?'' :'alt+') :'')
        +    (e.ctrlKey ?(e.key === 'Control' ?'' :'ctrl+') :'')
        +    (e.shiftKey ?(e.key === 'Shift' ?'' :'shift+') :'')
        +    'rightclick';
        this.fire(key, e);
        e.preventDefault();        // イベント未登録時、メニューが出てしまうので
    }

    #ev_wheel(e: any) {
        //if (! e.isTrusted) return;
        if (e['isComposing']) return;    // サポートしてない環境でもいける書き方

        if (this.#wheeling) {this.#extend_wheel = true; return}
        this.#wheeling = true;
        this.#ev_wheel_waitstop();

        // 今のところ縦回転ホイールのみ想定
        const key = (e.altKey ?'alt+' :'')
        +    (e.ctrlKey ?'ctrl+' :'')
        +    (e.shiftKey ?'shift+' :'')
        +    (e.deltaY > 0 ?'downwheel' :'upwheel');
        this.fire(key, e);
    }
    #wheeling = false;
    #extend_wheel = false;
    #ev_wheel_waitstop() {
        setTimeout(()=> {    // clearTimeout()不要と判断
            if (this.#extend_wheel) {
                this.#extend_wheel = false;
                this.#ev_wheel_waitstop();
                return;
            }
            this.#wheeling = false;
        }, 250);
    }

    destroy() {
        this.#fcs.destroy();
        this.#elc.clear();
    }

    fire(KEY: string, e: Event) {this.#rs.fire(KEY, e)}

    popLocalEvts(): IHEvt2Fnc {return ReadState.popLocalEvts()}
    pushLocalEvts(h: IHEvt2Fnc) {ReadState.pushLocalEvts(h)}

    unButton(ctnBtn: Container) {this.#fcs.remove(ctnBtn)}
    button(hArg: HArg, ctnBtn: Container, normal: ()=> void, hover: ()=> boolean, clicked: ()=> void) {
        if (! hArg.fn && ! hArg.label && ! hArg.url) this.main.errScript('fnまたはlabelまたはurlは必須です');
        hArg.fn ??= this.scrItr.scriptFn;

        // クリックイベント
        ctnBtn.interactive = true;
        ctnBtn.cursor = 'pointer';
        const key = hArg.key?.toLowerCase() ?? ' ';
        const glb = argChk_Boolean(hArg, 'global', false);
        ReadState.setEvt2Fnc(glb, key, ()=> this.main.resumeByJumpOrCall(hArg));
        ctnBtn.on('pointerdown', (e: any)=> this.#rs.fire(key, e));

        // マウスカーソルを載せるとヒントをツールチップス表示する
        const onHint = hArg.hint ?()=> this.#dispHint(hArg, ctnBtn) :()=> {};
        // マウスオーバーでの見た目変化
        const nr = ()=> {normal(); this.#elmHint.hidden = true};
        const hv = ()=> {onHint(); return hover()};
        ctnBtn.on('pointerover', hv);
        ctnBtn.on('pointerout', ()=> {if (this.#fcs.isFocus(ctnBtn)) hv(); else nr()});
        ctnBtn.on('pointerdown', ()=> {
            this.#elmHint.hidden = true;
            const f = this.#fcs.getFocus();
            clicked();
            if (f instanceof Button) f.normal();// 旧フォーカスボタンを通常状態に
        });
        ctnBtn.on('pointerup', CmnLib.isMobile
            ? nr
            : ()=> {if (this.#fcs.isFocus(ctnBtn)) hv(); else nr()}
        );
        // フォーカス処理対象として登録
        this.#fcs.add(ctnBtn, hv, nr);

        // 音関係
        if (hArg.clickse) {    //    clickse    クリック時に効果音
            hArg.clicksebuf ??= 'SYS';
            this.cfg.searchPath(hArg.clickse, SEARCH_PATH_ARG_EXT.SOUND);// 存在チェック
            ctnBtn.on('pointerdown', ()=> {
                this.hTag.playse({fn: hArg.clickse, buf: hArg.clicksebuf, join: false});
            });
        }
        if (hArg.enterse) {    //    enterse    ボタン上にマウスカーソルが載った時に効果音
            hArg.entersebuf ??= 'SYS';
            this.cfg.searchPath(hArg.enterse, SEARCH_PATH_ARG_EXT.SOUND);// 存在チェック
            ctnBtn.on('pointerover', ()=> {
                this.hTag.playse({fn: hArg.enterse, buf: hArg.entersebuf, join: false});
            });
        }
        if (hArg.leavese) {    //    leavese    ボタン上からマウスカーソルが外れた時に効果音
            hArg.leavesebuf ??= 'SYS';
            this.cfg.searchPath(hArg.leavese, SEARCH_PATH_ARG_EXT.SOUND);// 存在チェック
            ctnBtn.on('pointerout', ()=> {
                this.hTag.playse({fn: hArg.leavese, buf: hArg.leavesebuf, join: false});
            });
        }

        if (hArg.onenter) {
            // マウス重なり(フォーカス取得)時、ラベルコール。必ず[return]で戻ること
            const k = key + hArg.onenter.toLowerCase();
            const o: HArg = {fn: hArg.fn, label: hArg.onenter, call: true, key: k};
            ReadState.setEvt2Fnc(glb, k, ()=> this.main.resumeByJumpOrCall(o));
            ctnBtn.on('pointerover', (e: any)=> this.fire(k, e));
        }
        if (hArg.onleave) {
            // マウス外れ(フォーカス外れ)時、ラベルコール。必ず[return]で戻ること
            const k = key + hArg.onleave.toLowerCase();
            const o: HArg = {fn: hArg.fn, label: hArg.onleave, call: true, key: k};
            ReadState.setEvt2Fnc(glb, k, ()=> this.main.resumeByJumpOrCall(o));
            ctnBtn.on('pointerout', (e: any)=> this.fire(k, e));
        }
    }
    readonly    #elmV = {
        getBoundingClientRect: (x = 0, y = 0)=> DOMRect.fromRect({x, y, width: 0, height: 0,}),
    };
    readonly    #elmHint    : HTMLElement;
    readonly    #spanHint    : HTMLElement;
    readonly    #popper        : InsPop;
    readonly    #oHintOpt    = {
        placement: 'bottom',
        modifiers: [
            {    // Flip | Popper https://popper.js.org/docs/v2/modifiers/flip/
                name: 'flip',
                options: {
                    fallbackPlacements: ['top', 'bottom'],
                },
            },
        ],
    };
    #dispHint(hArg: HArg, ctnBtn: Container) {
        const rctBtn = ctnBtn instanceof Button
            ? ctnBtn.getBtnBounds()
            : ctnBtn.getBounds();
        const isLink = (hArg[':タグ名'] === 'link');
        if (! isLink) {
            const cpp = ctnBtn.parent.parent;
            rctBtn.x += cpp.x;    // レイヤ位置を加算
            rctBtn.y += cpp.y;
        }
        if (! hArg.hint) {this.#elmHint.hidden = true; return}

        this.#elmHint.style.cssText = `position:${this.#elmHint.style.position}; transform:${this.#elmHint.style.transform};`+ (hArg.hint_style ?? '');
        this.#spanHint.style.cssText = '';
        this.#spanHint.textContent = hArg.hint ?? '';

        try {
            const o = hArg.hint_opt ?{...this.#oHintOpt, ...JSON.parse(hArg.hint_opt)}: this.#oHintOpt;
            this.#popper.setOptions(o);
        } catch (e) {console.error(mesErrJSON(hArg, 'hint_opt', e.message))}

        this.#elmV.getBoundingClientRect = ()=> DOMRect.fromRect({
            x: this.sys.ofsLeft4elm +rctBtn.x *this.sys.cvsScale,
            y: this.sys.ofsTop4elm  +rctBtn.y *this.sys.cvsScale,
            width: rctBtn.width, height: rctBtn.height,
        });
        this.#popper.update();

        this.#elmHint.hidden = false;
    }
    hideHint() {this.#elmHint.hidden = true}
    cvsResize() {this.hideHint()}


    #event(hArg: HArg): boolean {
        const KeY = hArg.key;
        if (! KeY) throw 'keyは必須です';
        const key = KeY.toLowerCase();

        const call = argChk_Boolean(hArg, 'call', false);
        const glb = argChk_Boolean(hArg, 'global', false);
        if (argChk_Boolean(hArg, 'del', false)) {
            if (hArg.fn || hArg.label || call || hArg.url) throw 'fn/label/callとdelは同時指定できません';

            ReadState.clear_eventer(KeY, glb, key);

            // その他・キーボードイベント
            return false;
        }

        if (! hArg.fn && ! hArg.label && ! hArg.url) throw 'fnまたはlabelまたはurlは必須です';
        hArg.fn ??= this.scrItr.scriptFn;

        // domイベント
        if (KeY.slice(0, 4) === 'dom=') {
            const g = ReadState.getHtmlElmList(KeY);
            if (g.el.length === 0) {
                if (argChk_Boolean(hArg, 'need_err', true)) throw `HTML内にセレクタ(${g.sel})に対応する要素が見つかりません。存在しない場合を許容するなら、need_err=false と指定してください`;
                return false;
            }

            let aEv = ['click', 'keydown'];    // ラジオボタンも
            const inp = g.el[0] as HTMLInputElement;
            switch (inp.type ?? '') {
        //    switch (g.el[0].getAttribute('type') ?? '') { textareaで''になる
                case 'checkbox':    aEv = ['input'];    break;
                case 'range':        aEv = ['input'];    break;
                case 'text':
                case 'textarea':    aEv = ['input', 'change'];    break;
            }

            aEv.forEach((v, i)=> g.el.forEach(elm=> {
                this.#elc.add(elm, v, e=> {
                    if (! this.#rs.isWait || this.layMng.getFrmDisabled(g.id)) return;
                    if (v === 'keydown' && e.key !== 'Enter') return;

                    const d = elm.dataset;
                    for (const [k, v] of Object.entries(d)) this.val.setVal_Nochk('tmp', `sn.event.domdata.${k}`, v);
                    this.fire(KeY, e);
                });

                // フォーカス処理対象として登録
                if (i === 0) this.#fcs.add(
                    elm,
                    ()=> {
                        if (! this.#canFocus(elm)) return false;
                        elm.focus();
                        return true;
                    },
                    ()=> {},
                );
            }));

            // return;    // hGlobalEvt2Fnc(hLocalEvt2Fnc)登録もする
        }

        // その他・キーボードイベント
        ReadState.setEvt2Fnc(glb, key, ()=> this.main.resumeByJumpOrCall(hArg));

        return false;
    }
    #canFocus(elm: HTMLElement): boolean {
        if (elm.offsetParent === null) return false;

        let el: HTMLElement | null = elm;
        do {
            const style = getComputedStyle(el);
            if (style.display === 'none'
            || el.dataset.focus === 'false'
        //    || style.visibility !== 'visible'
            || (el as any)?.disabled
        //    || parseFloat(style.opacity ?? '') <= 0.0
        //    || parseInt(style.height ?? '', 10) <= 0
        //    || parseInt(style.width ?? '', 10) <= 0
            ) return false;
            el = el.parentElement;
        }
        while (el !== null);

        return true;
    }

    // フォーカス移動
    #set_focus(hArg: HArg) {
        const {add, del, to} = hArg;
        if (add?.slice(0, 4) === 'dom=') {
            const g = ReadState.getHtmlElmList(add);
            if (g.el.length === 0 && argChk_Boolean(hArg, 'need_err', true)) throw `HTML内にセレクタ(${g.sel})に対応する要素が見つかりません。存在しない場合を許容するなら、need_err=false と指定してください`;

            g.el.forEach(elm=> this.#fcs.add(
                elm,
                ()=> {
                    if (! this.#canFocus(elm)) return false;
                    elm.focus();
                    return true;
                },
                ()=> {},
            ));
            return false;
        }

        if (del?.slice(0, 4) === 'dom=') {
            const g = ReadState.getHtmlElmList(del);
            if (g.el.length === 0 && argChk_Boolean(hArg, 'need_err', true)) throw `HTML内にセレクタ(${g.sel})に対応する要素が見つかりません。存在しない場合を許容するなら、need_err=false と指定してください`;

            g.el.forEach(elm=> this.#fcs.remove(elm));
            return false;
        }

        if (! to) throw '[set_focus] add か to は必須です';
        switch (to) {
            case 'null':    this.#fcs.blur();    break;
            case 'next':    this.#fcs.next();    break;
            case 'prev':    this.#fcs.prev();    break;
        }
        return false;
    }


    // テキスト表示待ちと処理終了待ち(予約イベント受付しない)
    //    waitEvent を使用する場合、通常 break 時は breakLimitedEvent() すること
    readonly    waitEvent = (hArg: HArg, onIntr: ()=> void)=> this.#rs.waitEvent(hArg, onIntr);
    breakEvent() {this.#rs.breakEvent()}


    noticeCompTxt() {ReadState.noticeCompTxt()}

    // キー押下によるスキップ中か
    get    isSkipping(): boolean {
        if (this.#rs.isSkipping) return true;
        return Object.keys(this.#hDownKeys).some(k=> this.#hDownKeys[k] === 2);
    }
    // 0:no push  1:one push  2:push repeating
    readonly #hDownKeys    : {[key: string]: number}    = {
        'Alt'        : 0,
        'Meta'        : 0,    // COMMANDキー
        'Control'    : 0,
        'ArrowDown'    : 0,
        'End'        : 0,
        'Enter'        : 0,
        'Escape'    : 0,
        ' '            : 0,
        'GoBack'    : 0,    // AndroidのBackキーだと思う
    }

}