diff options
| -rw-r--r-- | .gitattributes | 1 | ||||
| -rw-r--r-- | ext/bg/background.html | 9 | ||||
| -rw-r--r-- | ext/bg/context.html | 6 | ||||
| -rw-r--r-- | ext/bg/guide.html | 6 | ||||
| -rw-r--r-- | ext/bg/js/api.js | 58 | ||||
| -rw-r--r-- | ext/bg/js/backend.js | 8 | ||||
| -rw-r--r-- | ext/bg/js/context.js | 4 | ||||
| -rw-r--r-- | ext/bg/js/handlebars.js | 4 | ||||
| -rw-r--r-- | ext/bg/js/options.js | 4 | ||||
| -rw-r--r-- | ext/bg/js/search.js | 173 | ||||
| -rw-r--r-- | ext/bg/js/settings-popup-preview.js | 7 | ||||
| -rw-r--r-- | ext/bg/js/translator.js | 2 | ||||
| -rw-r--r-- | ext/bg/legal.html | 6 | ||||
| -rw-r--r-- | ext/bg/search.html | 23 | ||||
| -rw-r--r-- | ext/bg/settings-popup-preview.html | 6 | ||||
| -rw-r--r-- | ext/bg/settings.html | 6 | ||||
| -rw-r--r-- | ext/fg/float.html | 6 | ||||
| -rw-r--r-- | ext/fg/js/api.js | 8 | ||||
| -rw-r--r-- | ext/fg/js/frontend.js | 411 | ||||
| -rw-r--r-- | ext/fg/js/popup.js | 2 | ||||
| -rw-r--r-- | ext/manifest.json | 5 | ||||
| -rw-r--r-- | ext/mixed/js/display.js | 23 | ||||
| -rw-r--r-- | ext/mixed/js/extension.js | 38 | 
23 files changed, 570 insertions, 246 deletions
| diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2000050e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tmpl/*.html text eol=lf diff --git a/ext/bg/background.html b/ext/bg/background.html index 194d4a45..3ab68639 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -3,8 +3,17 @@      <head>          <meta charset="UTF-8">          <meta name="viewport" content="width=device-width,initial-scale=1" /> +        <title>Background</title> +        <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> +        <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> +        <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> +        <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> +        <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> +        <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">      </head>      <body> +        <div id="clipboard-paste-target" contenteditable="true"></div> +          <script src="/mixed/lib/dexie.min.js"></script>          <script src="/mixed/lib/handlebars.min.js"></script>          <script src="/mixed/lib/jszip.min.js"></script> diff --git a/ext/bg/context.html b/ext/bg/context.html index 48fa463f..7e08dddd 100644 --- a/ext/bg/context.html +++ b/ext/bg/context.html @@ -3,6 +3,12 @@      <head>          <meta charset="UTF-8">          <meta name="viewport" content="width=device-width,initial-scale=1" /> +        <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> +        <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> +        <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> +        <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> +        <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> +        <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap-toggle/bootstrap-toggle.min.css"> diff --git a/ext/bg/guide.html b/ext/bg/guide.html index 2a602f1f..ff9c71ee 100644 --- a/ext/bg/guide.html +++ b/ext/bg/guide.html @@ -4,6 +4,12 @@          <meta charset="UTF-8">          <meta name="viewport" content="width=device-width,initial-scale=1" />          <title>Welcome to Yomichan!</title> +        <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> +        <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> +        <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> +        <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> +        <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> +        <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">      </head> diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index 93d9c155..3209cc31 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -21,6 +21,52 @@ function apiOptionsGet(optionsContext) {      return utilBackend().getOptions(optionsContext);  } +async function apiOptionsSet(changedOptions, optionsContext, source) { +    const options = await apiOptionsGet(optionsContext); + +    function getValuePaths(obj) { +        let valuePaths = []; +        let nodes = [{ +            obj, +            path: [] +        }]; +        while (nodes.length > 0) { +            let node = nodes.pop(); +            Object.keys(node.obj).forEach((key) => { +                let path = node.path.concat(key); +                let value = node.obj[key]; +                if (typeof value === 'object') { +                    nodes.unshift({ +                        obj: value, +                        path: path +                    }); +                } else { +                    valuePaths.push([value, path]); +                } +            }); +        } +        return valuePaths; +    } + +    function modifyOption(path, value, options) { +        let pivot = options; +        for (let pathKey of path.slice(0, -1)) { +            if (!(pathKey in pivot)) { +                return false; +            } +            pivot = pivot[pathKey]; +        } +        pivot[path[path.length - 1]] = value; +        return true; +    } + +    for (let [value, path] of getValuePaths(changedOptions)) { +        modifyOption(path, value, options); +    } + +    await apiOptionsSave(source); +} +  function apiOptionsGetFull() {      return utilBackend().getFullOptions();  } @@ -153,7 +199,7 @@ async function apiCommandExec(command, params) {  }  apiCommandExec.handlers = {      search: async (params) => { -        const url = chrome.extension.getURL('/bg/search.html'); +        const url = chrome.runtime.getURL('/bg/search.html');          if (!(params && params.newTab)) {              try {                  const tab = await apiFindTab(1000, (url2) => ( @@ -181,7 +227,7 @@ apiCommandExec.handlers = {              chrome.runtime.openOptionsPage();          } else {              const manifest = chrome.runtime.getManifest(); -            const url = chrome.extension.getURL(manifest.options_ui.page); +            const url = chrome.runtime.getURL(manifest.options_ui.page);              chrome.tabs.create({url});          }      }, @@ -401,3 +447,11 @@ async function apiFocusTab(tab) {          // Edge throws exception for no reason here.      }  } + +async function apiClipboardGet() { +    const clipboardPasteTarget = utilBackend().clipboardPasteTarget; +    clipboardPasteTarget.innerText = ''; +    clipboardPasteTarget.focus(); +    document.execCommand('paste'); +    return clipboardPasteTarget.innerText; +} diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index f29230a2..71393467 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -30,6 +30,8 @@ class Backend {          this.isPreparedResolve = null;          this.isPreparedPromise = new Promise((resolve) => (this.isPreparedResolve = resolve)); +        this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target'); +          this.apiForwarder = new BackendApiForwarder();      } @@ -45,7 +47,7 @@ class Backend {          const options = this.getOptionsSync(this.optionsContext);          if (options.general.showGuide) { -            chrome.tabs.create({url: chrome.extension.getURL('/bg/guide.html')}); +            chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')});          }          this.isPreparedResolve(); @@ -175,6 +177,7 @@ class Backend {  Backend.messageHandlers = {      optionsGet: ({optionsContext}) => apiOptionsGet(optionsContext), +    optionsSet: ({changedOptions, optionsContext, source}) => apiOptionsSet(changedOptions, optionsContext, source),      kanjiFind: ({text, optionsContext}) => apiKanjiFind(text, optionsContext),      termsFind: ({text, optionsContext}) => apiTermsFind(text, optionsContext),      definitionAdd: ({definition, mode, context, optionsContext}) => apiDefinitionAdd(definition, mode, context, optionsContext), @@ -187,7 +190,8 @@ Backend.messageHandlers = {      forward: ({action, params}, sender) => apiForward(action, params, sender),      frameInformationGet: (params, sender) => apiFrameInformationGet(sender),      injectStylesheet: ({css}, sender) => apiInjectStylesheet(css, sender), -    getEnvironmentInfo: () => apiGetEnvironmentInfo() +    getEnvironmentInfo: () => apiGetEnvironmentInfo(), +    clipboardGet: () => apiClipboardGet()  };  window.yomichan_backend = new Backend(); diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js index 8e1dbce6..3fb27f0d 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -55,8 +55,8 @@ $(document).ready(utilAsync(() => {      const manifest = chrome.runtime.getManifest(); -    setupButtonEvents('.action-open-search', 'search', chrome.extension.getURL('/bg/search.html')); -    setupButtonEvents('.action-open-options', 'options', chrome.extension.getURL(manifest.options_ui.page)); +    setupButtonEvents('.action-open-search', 'search', chrome.runtime.getURL('/bg/search.html')); +    setupButtonEvents('.action-open-options', 'options', chrome.runtime.getURL(manifest.options_ui.page));      setupButtonEvents('.action-open-help', 'help');      const optionsContext = { diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js index 92764a20..fba437da 100644 --- a/ext/bg/js/handlebars.js +++ b/ext/bg/js/handlebars.js @@ -49,13 +49,13 @@ function handlebarsFuriganaPlain(options) {      let result = '';      for (const seg of segs) {          if (seg.furigana) { -            result += `${seg.text}[${seg.furigana}]`; +            result += ` ${seg.text}[${seg.furigana}]`;          } else {              result += seg.text;          }      } -    return result; +    return result.trimLeft();  }  function handlebarsKanjiLinks(options) { diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index 4854cd65..be1ccfbb 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -279,7 +279,9 @@ function profileOptionsCreateDefaults() {              popupTheme: 'default',              popupOuterTheme: 'default',              customPopupCss: '', -            customPopupOuterCss: '' +            customPopupOuterCss: '', +            enableWanakana: true, +            enableClipboardMonitor: false          },          audio: { diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index 431478c9..dbfcb15d 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -17,6 +17,12 @@   */ +let IS_FIREFOX = null; +(async () => { +    const {browser} = await apiGetEnvironmentInfo(); +    IS_FIREFOX = ['firefox', 'firefox-mobile'].includes(browser); +})(); +  class DisplaySearch extends Display {      constructor() {          super(document.querySelector('#spinner'), document.querySelector('#content')); @@ -29,8 +35,14 @@ class DisplaySearch extends Display {          this.search = document.querySelector('#search');          this.query = document.querySelector('#query');          this.intro = document.querySelector('#intro'); +        this.clipboardMonitorEnable = document.querySelector('#clipboard-monitor-enable'); +        this.wanakanaEnable = document.querySelector('#wanakana-enable'); +          this.introVisible = true;          this.introAnimationTimer = null; + +        this.clipboardMonitorIntervalId = null; +        this.clipboardPrevText = null;      }      static create() { @@ -49,16 +61,69 @@ class DisplaySearch extends Display {              if (this.query !== null) {                  this.query.addEventListener('input', () => this.onSearchInput(), false); +                if (this.wanakanaEnable !== null) { +                    if (this.options.general.enableWanakana === true) { +                        this.wanakanaEnable.checked = true; +                        window.wanakana.bind(this.query); +                    } else { +                        this.wanakanaEnable.checked = false; +                    } +                    this.wanakanaEnable.addEventListener('change', (e) => { +                        const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || ''; +                        if (e.target.checked) { +                            window.wanakana.bind(this.query); +                            this.query.value = window.wanakana.toKana(query); +                            apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext()); +                        } else { +                            window.wanakana.unbind(this.query); +                            this.query.value = query; +                            apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext()); +                        } +                        this.onSearchQueryUpdated(this.query.value, false); +                    }); +                } +                  const query = DisplaySearch.getSearchQueryFromLocation(window.location.href);                  if (query !== null) { -                    this.query.value = window.wanakana.toKana(query); -                    this.onSearchQueryUpdated(query, false); +                    if (this.isWanakanaEnabled()) { +                        this.query.value = window.wanakana.toKana(query); +                    } else { +                        this.query.value = query; +                    } +                    this.onSearchQueryUpdated(this.query.value, false);                  } - -                window.wanakana.bind(this.query); +            } +            if (this.clipboardMonitorEnable !== null) { +                if (this.options.general.enableClipboardMonitor === true) { +                    this.clipboardMonitorEnable.checked = true; +                    this.startClipboardMonitor(); +                } else { +                    this.clipboardMonitorEnable.checked = false; +                } +                this.clipboardMonitorEnable.addEventListener('change', (e) => { +                    if (e.target.checked) { +                        chrome.permissions.request( +                            {permissions: ['clipboardRead']}, +                            (granted) => { +                                if (granted) { +                                    this.startClipboardMonitor(); +                                    apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext()); +                                } else { +                                    e.target.checked = false; +                                } +                            } +                        ); +                    } else { +                        this.stopClipboardMonitor(); +                        apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext()); +                    } +                });              } +            window.addEventListener('popstate', (e) => this.onPopState(e)); +              this.updateSearchButton(); +            this.initClipboardMonitor();          } catch (e) {              this.onError(e);          } @@ -79,6 +144,11 @@ class DisplaySearch extends Display {      onSearchInput() {          this.updateSearchButton(); + +        const queryElementRect = this.query.getBoundingClientRect(); +        if (queryElementRect.top < 0 || queryElementRect.bottom > window.innerHeight) { +            this.query.scrollIntoView(); +        }      }      onSearch(e) { @@ -90,10 +160,60 @@ class DisplaySearch extends Display {          const query = this.query.value;          const queryString = query.length > 0 ? `?query=${encodeURIComponent(query)}` : ''; -        window.history.replaceState(null, '', `${window.location.pathname}${queryString}`); +        window.history.pushState(null, '', `${window.location.pathname}${queryString}`);          this.onSearchQueryUpdated(query, true);      } +    onPopState(e) { +        const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || ''; +        if (this.query !== null) { +            if (this.isWanakanaEnabled()) { +                this.query.value = window.wanakana.toKana(query); +            } else { +                this.query.value = query; +            } +        } + +        this.onSearchQueryUpdated(this.query.value, false); +    } + +    onKeyDown(e) { +        const key = Display.getKeyFromEvent(e); + +        let activeModifierMap = { +            'Control': e.ctrlKey, +            'Meta': e.metaKey, +            'ANY_MOD': true +        }; + +        const ignoreKeys = { +            'ANY_MOD': ['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageDown', 'PageUp', 'Home', 'End'] +                .concat( +                    Array.from(Array(24).keys()) +                    .map(i => `F${i + 1}`) +                ), +            'Control': ['C', 'A', 'Z', 'Y', 'X', 'F', 'G'], +            'Meta': ['C', 'A', 'Z', 'Y', 'X', 'F', 'G'], +            'OS': [], +            'Alt': [], +            'AltGraph': [], +            'Shift': [] +        } + +        let preventFocus = false; +        for (const [modifier, keys] of Object.entries(ignoreKeys)) { +            const modifierActive = activeModifierMap[modifier]; +            if (key === modifier || (modifierActive && keys.includes(key))) { +                preventFocus = true; +                break; +            } +        } + +        if (!super.onKeyDown(e) && !preventFocus && document.activeElement !== this.query) { +            this.query.focus({preventScroll: true}); +        } +    } +      async onSearchQueryUpdated(query, animate) {          try {              const valid = (query.length > 0); @@ -125,6 +245,49 @@ class DisplaySearch extends Display {          }      } +    initClipboardMonitor() { +        // ignore copy from search page +        window.addEventListener('copy', (e) => { +            this.clipboardPrevText = document.getSelection().toString().trim(); +        }); +    } + +    startClipboardMonitor() { +        this.clipboardMonitorIntervalId = setInterval(async () => { +            let curText = null; +            // TODO get rid of this and figure out why apiClipboardGet doesn't work on Firefox +            if (IS_FIREFOX) { +                curText = (await navigator.clipboard.readText()).trim(); +            } else if (IS_FIREFOX === false) { +                curText = (await apiClipboardGet()).trim(); +            } +            if (curText && (curText !== this.clipboardPrevText)) { +                if (this.isWanakanaEnabled()) { +                    this.query.value = window.wanakana.toKana(curText); +                } else { +                    this.query.value = curText; +                } + +                const queryString = curText.length > 0 ? `?query=${encodeURIComponent(curText)}` : ''; +                window.history.pushState(null, '', `${window.location.pathname}${queryString}`); +                this.onSearchQueryUpdated(this.query.value, true); + +                this.clipboardPrevText = curText; +            } +        }, 100); +    } + +    stopClipboardMonitor() { +        if (this.clipboardMonitorIntervalId) { +            clearInterval(this.clipboardMonitorIntervalId); +            this.clipboardMonitorIntervalId = null; +        } +    } + +    isWanakanaEnabled() { +        return this.wanakanaEnable !== null && this.wanakanaEnable.checked; +    } +      getOptionsContext() {          return this.optionsContext;      } diff --git a/ext/bg/js/settings-popup-preview.js b/ext/bg/js/settings-popup-preview.js index b12fb726..7d641c46 100644 --- a/ext/bg/js/settings-popup-preview.js +++ b/ext/bg/js/settings-popup-preview.js @@ -159,8 +159,11 @@ class SettingsPopupPreview {          range.selectNode(textNode);          const source = new TextSourceRange(range, range.toString(), null); -        this.frontend.textSourceLast = null; -        await this.frontend.searchSource(source, 'script'); +        try { +            await this.frontend.searchSource(source, 'script'); +        } finally { +            source.cleanup(); +        }          await this.frontend.lastShowPromise;          if (this.frontend.popup.isVisible()) { diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index ee012d96..9d90136b 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -31,7 +31,7 @@ class Translator {          }          if (!this.deinflector) { -            const url = chrome.extension.getURL('/bg/lang/deinflect.json'); +            const url = chrome.runtime.getURL('/bg/lang/deinflect.json');              const reasons = await requestJson(url, 'GET');              this.deinflector = new Deinflector(reasons);          } diff --git a/ext/bg/legal.html b/ext/bg/legal.html index 26ac033d..30927da6 100644 --- a/ext/bg/legal.html +++ b/ext/bg/legal.html @@ -4,6 +4,12 @@          <meta charset="UTF-8">          <meta name="viewport" content="width=device-width,initial-scale=1" />          <title>Yomichan Legal</title> +        <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> +        <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> +        <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> +        <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> +        <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> +        <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">      </head> diff --git a/ext/bg/search.html b/ext/bg/search.html index 9d28b358..91140b95 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -4,6 +4,12 @@          <meta charset="UTF-8">          <meta name="viewport" content="width=device-width,initial-scale=1" />          <title>Yomichan Search</title> +        <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> +        <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> +        <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> +        <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> +        <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> +        <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">          <link rel="stylesheet" type="text/css" href="/mixed/css/display.css"> @@ -19,7 +25,22 @@                  <p style="margin-bottom: 0;">Search your installed dictionaries by entering a Japanese expression into the field below.</p>              </div> -            <form class="input-group" style="padding-top: 10px;"> +            <div class="input-group" style="padding-top: 10px; font-size: 20px; user-select: none;"> +                <span title="Enable kana input method" class="input-group-text"> +                    <label> +                        あ +                        <input type="checkbox" id="wanakana-enable" /> +                    </label> +                </span> +                <span title="Enable clipboard monitor" class="input-group-text"> +                    <label> +                        <span class="glyphicon glyphicon-paste"></span> +                        <input type="checkbox" id="clipboard-monitor-enable" /> +                    </label> +                </span> +            </div> + +            <form class="input-group">                  <input type="text" class="form-control" placeholder="Search for..." id="query" autofocus>                  <span class="input-group-btn">                      <input type="submit" class="btn btn-default form-control" id="search" value="Search"> diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html index 07caa271..d27a9a33 100644 --- a/ext/bg/settings-popup-preview.html +++ b/ext/bg/settings-popup-preview.html @@ -4,6 +4,12 @@          <meta charset="UTF-8">          <meta name="viewport" content="width=device-width,initial-scale=1" />          <title>Yomichan Popup Preview</title> +        <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> +        <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> +        <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> +        <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> +        <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> +        <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">          <link rel="stylesheet" type="text/css" href="/fg/css/client.css" id="client-css">          <style>              html { diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 9b1c4366..a3b75576 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -4,6 +4,12 @@          <meta charset="UTF-8">          <meta name="viewport" content="width=device-width,initial-scale=1" />          <title>Yomichan Options</title> +        <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> +        <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> +        <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> +        <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> +        <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> +        <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">          <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">          <link rel="stylesheet" type="text/css" href="/bg/css/settings.css"> diff --git a/ext/fg/float.html b/ext/fg/float.html index 580a7963..aec3db20 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -4,6 +4,12 @@          <meta charset="UTF-8">          <meta name="viewport" content="width=device-width,initial-scale=1" />          <title></title> +        <link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16"> +        <link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19"> +        <link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38"> +        <link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48"> +        <link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64"> +        <link rel="icon" type="image/png" href="/mixed/img/icon128.png" sizes="128x128">          <link rel="stylesheet" href="/mixed/css/display.css">          <link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default">          <link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark"> diff --git a/ext/fg/js/api.js b/ext/fg/js/api.js index b0746b85..54818702 100644 --- a/ext/fg/js/api.js +++ b/ext/fg/js/api.js @@ -21,6 +21,10 @@ function apiOptionsGet(optionsContext) {      return utilInvoke('optionsGet', {optionsContext});  } +function apiOptionsSet(changedOptions, optionsContext, source) { +    return utilInvoke('optionsSet', {changedOptions, optionsContext, source}); +} +  function apiTermsFind(text, optionsContext) {      return utilInvoke('termsFind', {text, optionsContext});  } @@ -72,3 +76,7 @@ function apiInjectStylesheet(css) {  function apiGetEnvironmentInfo() {      return utilInvoke('getEnvironmentInfo');  } + +function apiClipboardGet() { +    return utilInvoke('clipboardGet'); +} diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index e854f74e..e67008df 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -20,8 +20,8 @@  class Frontend {      constructor(popup, ignoreNodes) {          this.popup = popup; -        this.popupTimer = null; -        this.textSourceLast = null; +        this.popupTimerPromise = null; +        this.textSourceCurrent = null;          this.pendingLookup = false;          this.options = null;          this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null); @@ -32,12 +32,10 @@ class Frontend {          };          this.primaryTouchIdentifier = null; -        this.contextMenuChecking = false; -        this.contextMenuPrevent = false; -        this.contextMenuPreviousRange = null; -        this.mouseDownPrevent = false; -        this.clickPrevent = false; -        this.scrollPrevent = false; +        this.preventNextContextMenu = false; +        this.preventNextMouseDown = false; +        this.preventNextClick = false; +        this.preventScroll = false;          this.enabled = false;          this.eventListeners = []; @@ -74,7 +72,7 @@ class Frontend {      }      onMouseOver(e) { -        if (e.target === this.popup.container && this.popupTimer !== null) { +        if (e.target === this.popup.container) {              this.popupTimerClear();          }      } @@ -82,10 +80,7 @@ class Frontend {      onMouseMove(e) {          this.popupTimerClear(); -        if ( -            this.pendingLookup || -            (e.buttons & 0x1) !== 0x0 // Left mouse button -        ) { +        if (this.pendingLookup || Frontend.isMouseButton('primary', e)) {              return;          } @@ -93,65 +88,60 @@ class Frontend {          const scanningModifier = scanningOptions.modifier;          if (!(              Frontend.isScanningModifierPressed(scanningModifier, e) || -            (scanningOptions.middleMouse && (e.buttons & 0x4) !== 0x0) // Middle mouse button +            (scanningOptions.middleMouse && Frontend.isMouseButton('auxiliary', e))          )) {              return;          }          const search = async () => { -            try { -                await this.searchAt(e.clientX, e.clientY, 'mouse'); -            } catch (e) { -                this.onError(e); +            if (scanningModifier === 'none') { +                if (!await this.popupTimerWait()) { +                    // Aborted +                    return; +                }              } + +            await this.searchAt(e.clientX, e.clientY, 'mouse');          }; -        if (scanningModifier === 'none') { -            this.popupTimerSet(search); -        } else { -            search(); -        } +        search();      }      onMouseDown(e) { -        if (this.mouseDownPrevent) { -            this.setMouseDownPrevent(false, false); -            this.setClickPrevent(true); +        if (this.preventNextMouseDown) { +            this.preventNextMouseDown = false; +            this.preventNextClick = true;              e.preventDefault();              e.stopPropagation();              return false;          } -        this.popupTimerClear(); -        this.searchClear(true); +        if (e.button === 0) { +            this.popupTimerClear(); +            this.searchClear(true); +        }      }      onMouseOut(e) {          this.popupTimerClear();      } -    onWindowMessage(e) { -        const action = e.data; -        const handlers = Frontend.windowMessageHandlers; -        if (handlers.hasOwnProperty(action)) { -            const handler = handlers[action]; -            handler(this); +    onClick(e) { +        if (this.preventNextClick) { +            this.preventNextClick = false; +            e.preventDefault(); +            e.stopPropagation(); +            return false;          }      } -    async onResize() { -        if (this.textSourceLast !== null && await this.popup.isVisibleAsync()) { -            const textSource = this.textSourceLast; -            this.lastShowPromise = this.popup.showContent( -                textSource.getRect(), -                textSource.getWritingMode() -            ); -        } +    onAuxClick(e) { +        this.preventNextContextMenu = false;      } -    onClick(e) { -        if (this.clickPrevent) { -            this.setClickPrevent(false); +    onContextMenu(e) { +        if (this.preventNextContextMenu) { +            this.preventNextContextMenu = false;              e.preventDefault();              e.stopPropagation();              return false; @@ -159,28 +149,58 @@ class Frontend {      }      onTouchStart(e) { -        if (this.primaryTouchIdentifier !== null && this.getIndexOfTouch(e.touches, this.primaryTouchIdentifier) >= 0) { +        if (this.primaryTouchIdentifier !== null || e.changedTouches.length === 0) {              return;          } -        let touch = this.getPrimaryTouch(e.changedTouches); -        if (this.selectionContainsPoint(window.getSelection(), touch.clientX, touch.clientY)) { -            touch = null; +        this.preventScroll = false; +        this.preventNextContextMenu = false; +        this.preventNextMouseDown = false; +        this.preventNextClick = false; + +        const primaryTouch = e.changedTouches[0]; +        if (Frontend.selectionContainsPoint(window.getSelection(), primaryTouch.clientX, primaryTouch.clientY)) { +            return;          } -        this.setPrimaryTouch(touch); -    } +        this.primaryTouchIdentifier = primaryTouch.identifier; -    onTouchEnd(e) { -        if (this.primaryTouchIdentifier === null) { +        if (this.pendingLookup) {              return;          } -        if (this.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0) { +        const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null; + +        this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart') +        .then(() => { +            if ( +                this.textSourceCurrent === null || +                this.textSourceCurrent.equals(textSourceCurrentPrevious) +            ) { +                return; +            } + +            this.preventScroll = true; +            this.preventNextContextMenu = true; +            this.preventNextMouseDown = true; +        }); +    } + +    onTouchEnd(e) { +        if ( +            this.primaryTouchIdentifier === null || +            this.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0 +        ) {              return;          } -        this.setPrimaryTouch(this.getPrimaryTouch(this.excludeTouches(e.touches, e.changedTouches))); +        this.primaryTouchIdentifier = null; +        this.preventScroll = false; +        this.preventNextClick = false; +        // Don't revert context menu and mouse down prevention, +        // since these events can occur after the touch has ended. +        // this.preventNextContextMenu = false; +        // this.preventNextMouseDown = false;      }      onTouchCancel(e) { @@ -188,7 +208,7 @@ class Frontend {      }      onTouchMove(e) { -        if (!this.scrollPrevent || !e.cancelable || this.primaryTouchIdentifier === null) { +        if (!this.preventScroll || !e.cancelable || this.primaryTouchIdentifier === null) {              return;          } @@ -198,39 +218,29 @@ class Frontend {              return;          } -        const touch = touches[index]; -        this.searchFromTouch(touch.clientX, touch.clientY, 'touchMove'); +        const primaryTouch = touches[index]; +        this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchMove');          e.preventDefault(); // Disable scroll      } -    onContextMenu(e) { -        if (this.contextMenuPrevent) { -            this.setContextMenuPrevent(false, false); -            e.preventDefault(); -            e.stopPropagation(); -            return false; +    async onResize() { +        if (this.textSourceCurrent !== null && await this.popup.isVisibleAsync()) { +            const textSource = this.textSourceCurrent; +            this.lastShowPromise = this.popup.showContent( +                textSource.getRect(), +                textSource.getWritingMode() +            );          }      } -    onAfterSearch(newRange, cause, searched, success) { -        if (cause === 'mouse') { -            return; -        } - -        if ( -            !this.contextMenuChecking || -            (this.contextMenuPreviousRange === null ? newRange === null : this.contextMenuPreviousRange.equals(newRange))) { -            return; -        } - -        if (cause === 'touchStart' && newRange !== null) { -            this.scrollPrevent = true; +    onWindowMessage(e) { +        const action = e.data; +        const handlers = Frontend.windowMessageHandlers; +        if (handlers.hasOwnProperty(action)) { +            const handler = handlers[action]; +            handler(this);          } - -        this.setContextMenuPrevent(true, false); -        this.setMouseDownPrevent(true, false); -        this.contextMenuChecking = false;      }      onRuntimeMessage({action, params}, sender, callback) { @@ -271,6 +281,7 @@ class Frontend {          if (this.options.scanning.touchInputEnabled) {              this.addEventListener(window, 'click', this.onClick.bind(this)); +            this.addEventListener(window, 'auxclick', this.onAuxClick.bind(this));              this.addEventListener(window, 'touchstart', this.onTouchStart.bind(this));              this.addEventListener(window, 'touchend', this.onTouchEnd.bind(this));              this.addEventListener(window, 'touchcancel', this.onTouchCancel.bind(this)); @@ -297,47 +308,69 @@ class Frontend {          await this.popup.setOptions(this.options);      } -    popupTimerSet(callback) { +    async popupTimerWait() {          const delay = this.options.scanning.delay; -        if (delay > 0) { -            this.popupTimer = window.setTimeout(callback, delay); -        } else { -            Promise.resolve().then(callback); +        const promise = promiseTimeout(delay, true); +        this.popupTimerPromise = promise; +        try { +            return await promise; +        } finally { +            if (this.popupTimerPromise === promise) { +                this.popupTimerPromise = null; +            }          }      }      popupTimerClear() { -        if (this.popupTimer !== null) { -            window.clearTimeout(this.popupTimer); -            this.popupTimer = null; +        if (this.popupTimerPromise !== null) { +            this.popupTimerPromise.resolve(false); +            this.popupTimerPromise = null;          }      }      async searchAt(x, y, cause) { -        if (this.pendingLookup || await this.popup.containsPoint(x, y)) { -            return; -        } +        try { +            this.popupTimerClear(); -        const textSource = docRangeFromPoint(x, y, this.options); -        return await this.searchSource(textSource, cause); +            if (this.pendingLookup || await this.popup.containsPoint(x, y)) { +                return; +            } + +            const textSource = docRangeFromPoint(x, y, this.options); +            if (this.textSourceCurrent !== null && this.textSourceCurrent.equals(textSource)) { +                return; +            } + +            try { +                await this.searchSource(textSource, cause); +            } finally { +                if (textSource !== null) { +                    textSource.cleanup(); +                } +            } +        } catch (e) { +            this.onError(e); +        }      }      async searchSource(textSource, cause) { -        let hideResults = textSource === null; -        let searched = false; -        let success = false; +        let results = null;          try { -            if (!hideResults && (!this.textSourceLast || !this.textSourceLast.equals(textSource))) { -                searched = true; -                this.pendingLookup = true; -                const focus = (cause === 'mouse'); -                hideResults = !await this.searchTerms(textSource, focus) && !await this.searchKanji(textSource, focus); -                success = true; +            this.pendingLookup = true; +            if (textSource !== null) { +                results = ( +                    await this.findTerms(textSource) || +                    await this.findKanji(textSource) +                ); +                if (results !== null) { +                    const focus = (cause === 'mouse'); +                    this.showContent(textSource, focus, results.definitions, results.type); +                }              }          } catch (e) {              if (window.yomichan_orphaned) { -                if (textSource && this.options.scanning.modifier !== 'none') { +                if (textSource !== null && this.options.scanning.modifier !== 'none') {                      this.lastShowPromise = this.popup.showContent(                          textSource.getRect(),                          textSource.getWritingMode(), @@ -348,93 +381,69 @@ class Frontend {                  this.onError(e);              }          } finally { -            if (textSource !== null) { -                textSource.cleanup(); -            } -            if (hideResults && this.options.scanning.autoHideResults) { +            if (results === null && this.options.scanning.autoHideResults) {                  this.searchClear(true);              }              this.pendingLookup = false; -            this.onAfterSearch(this.textSourceLast, cause, searched, success); -        } -    } - -    async searchTerms(textSource, focus) { -        this.setTextSourceScanLength(textSource, this.options.scanning.length); - -        const searchText = textSource.text(); -        if (searchText.length === 0) { -            return false;          } -        const {definitions, length} = await apiTermsFind(searchText, this.getOptionsContext()); -        if (definitions.length === 0) { -            return false; -        } - -        textSource.setEndOffset(length); +        return results; +    } +    showContent(textSource, focus, definitions, type) {          const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);          const url = window.location.href;          this.lastShowPromise = this.popup.showContent(              textSource.getRect(),              textSource.getWritingMode(), -            'terms', +            type,              {definitions, context: {sentence, url, focus}}          ); -        this.textSourceLast = textSource; +        this.textSourceCurrent = textSource;          if (this.options.scanning.selectText) {              textSource.select();          } +    } + +    async findTerms(textSource) { +        this.setTextSourceScanLength(textSource, this.options.scanning.length); + +        const searchText = textSource.text(); +        if (searchText.length === 0) { return null; } + +        const {definitions, length} = await apiTermsFind(searchText, this.getOptionsContext()); +        if (definitions.length === 0) { return null; } -        return true; +        textSource.setEndOffset(length); + +        return {definitions, type: 'terms'};      } -    async searchKanji(textSource, focus) { +    async findKanji(textSource) {          this.setTextSourceScanLength(textSource, 1);          const searchText = textSource.text(); -        if (searchText.length === 0) { -            return false; -        } +        if (searchText.length === 0) { return null; }          const definitions = await apiKanjiFind(searchText, this.getOptionsContext()); -        if (definitions.length === 0) { -            return false; -        } - -        const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); -        const url = window.location.href; -        this.lastShowPromise = this.popup.showContent( -            textSource.getRect(), -            textSource.getWritingMode(), -            'kanji', -            {definitions, context: {sentence, url, focus}} -        ); - -        this.textSourceLast = textSource; -        if (this.options.scanning.selectText) { -            textSource.select(); -        } +        if (definitions.length === 0) { return null; } -        return true; +        return {definitions, type: 'kanji'};      }      searchClear(changeFocus) {          this.popup.hide(changeFocus);          this.popup.clearAutoPlayTimer(); -        if (this.options.scanning.selectText && this.textSourceLast) { -            this.textSourceLast.deselect(); -        } - -        this.textSourceLast = null; -    } +        if (this.textSourceCurrent !== null) { +            if (this.options.scanning.selectText) { +                this.textSourceCurrent.deselect(); +            } -    getPrimaryTouch(touchList) { -        return touchList.length > 0 ? touchList[0] : null; +            this.textSourceCurrent = null; +        }      }      getIndexOfTouch(touchList, identifier) { @@ -447,74 +456,7 @@ class Frontend {          return -1;      } -    excludeTouches(touchList, excludeTouchList) { -        const result = []; -        for (let r of touchList) { -            if (this.getIndexOfTouch(excludeTouchList, r.identifier) < 0) { -                result.push(r); -            } -        } -        return result; -    } - -    setPrimaryTouch(touch) { -        if (touch === null) { -            this.primaryTouchIdentifier = null; -            this.contextMenuPreviousRange = null; -            this.contextMenuChecking = false; -            this.scrollPrevent = false; -            this.setContextMenuPrevent(false, true); -            this.setMouseDownPrevent(false, true); -            this.setClickPrevent(false); -        } -        else { -            this.primaryTouchIdentifier = touch.identifier; -            this.contextMenuPreviousRange = this.textSourceLast ? this.textSourceLast.clone() : null; -            this.contextMenuChecking = true; -            this.scrollPrevent = false; -            this.setContextMenuPrevent(false, false); -            this.setMouseDownPrevent(false, false); -            this.setClickPrevent(false); - -            this.searchFromTouch(touch.clientX, touch.clientY, 'touchStart'); -        } -    } - -    setContextMenuPrevent(value, delay) { -        if (!delay) { -            this.contextMenuPrevent = value; -        } -    } - -    setMouseDownPrevent(value, delay) { -        if (!delay) { -            this.mouseDownPrevent = value; -        } -    } - -    setClickPrevent(value) { -        this.clickPrevent = value; -    } - -    searchFromTouch(x, y, cause) { -        this.popupTimerClear(); - -        if (this.pendingLookup) { -            return; -        } - -        const search = async () => { -            try { -                await this.searchAt(x, y, cause); -            } catch (e) { -                this.onError(e); -            } -        }; - -        search(); -    } - -    selectionContainsPoint(selection, x, y) { +    static selectionContainsPoint(selection, x, y) {          for (let i = 0; i < selection.rangeCount; ++i) {              const range = selection.getRangeAt(i);              for (const rect of range.getClientRects()) { @@ -557,6 +499,25 @@ class Frontend {              default: return false;          }      } + +    static isMouseButton(button, mouseEvent) { +        switch (mouseEvent.type) { +            case 'mouseup': +            case 'mousedown': +            case 'click': switch (button) { +                case 'primary': return mouseEvent.button === 0; +                case 'secondary': return mouseEvent.button === 2; +                case 'auxiliary': return mouseEvent.button === 1; +                default: return false; +            } +            default: switch (button) { +                case 'primary': return (mouseEvent.buttons & 0x1) !== 0x0; +                case 'secondary': return (mouseEvent.buttons & 0x2) !== 0x0; +                case 'auxiliary': return (mouseEvent.buttons & 0x4) !== 0x0; +                default: return false; +            } +        } +    }  }  Frontend.windowMessageHandlers = { diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index b5eb9fe2..1f9317e0 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -30,7 +30,7 @@ class Popup {          this.container.className = 'yomichan-float';          this.container.addEventListener('mousedown', e => e.stopPropagation());          this.container.addEventListener('scroll', e => e.stopPropagation()); -        this.container.setAttribute('src', chrome.extension.getURL('/fg/float.html')); +        this.container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));          this.container.style.width = '0px';          this.container.style.height = '0px';          this.injectPromise = null; diff --git a/ext/manifest.json b/ext/manifest.json index d4a72c8a..b9d91a81 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,7 +1,7 @@  {      "manifest_version": 2,      "name": "Yomichan (testing)", -    "version": "1.8.9", +    "version": "1.9.5",      "description": "Japanese dictionary with Anki integration (testing)",      "icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"}, @@ -44,6 +44,9 @@          "clipboardWrite",          "unlimitedStorage"      ], +    "optional_permissions": [ +        "clipboardRead" +    ],      "commands": {          "toggle": {              "suggested_key": { diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index b40228b0..6d992897 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -35,6 +35,7 @@ class Display {          this.persistentEventListeners = [];          this.interactive = false;          this.eventListenersActive = false; +        this.clickScanPrevent = false;          this.windowScroll = new WindowScroll(); @@ -81,6 +82,22 @@ class Display {          }      } +    onGlossaryMouseDown(e) { +        if (Frontend.isMouseButton('primary', e)) { +            this.clickScanPrevent = false; +        } +    } + +    onGlossaryMouseMove(e) { +        this.clickScanPrevent = true; +    } + +    onGlossaryMouseUp(e) { +        if (!this.clickScanPrevent && Frontend.isMouseButton('primary', e)) { +            this.onTermLookup(e); +        } +    } +      async onTermLookup(e) {          try {              e.preventDefault(); @@ -157,8 +174,10 @@ class Display {              const handler = handlers[key];              if (handler(this, e)) {                  e.preventDefault(); +                return true;              }          } +        return false;      }      onWheel(e) { @@ -250,7 +269,9 @@ class Display {              this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this));              this.addEventListeners('.source-term', 'click', this.onSourceTermView.bind(this));              if (this.options.scanning.enablePopupSearch) { -                this.addEventListeners('.glossary-item', 'click', this.onTermLookup.bind(this)); +                this.addEventListeners('.glossary-item', 'mouseup', this.onGlossaryMouseUp.bind(this)); +                this.addEventListeners('.glossary-item', 'mousedown', this.onGlossaryMouseDown.bind(this)); +                this.addEventListeners('.glossary-item', 'mousemove', this.onGlossaryMouseMove.bind(this));              }          } else {              Display.clearEventListeners(this.eventListeners); diff --git a/ext/mixed/js/extension.js b/ext/mixed/js/extension.js index 861e52a5..54862e19 100644 --- a/ext/mixed/js/extension.js +++ b/ext/mixed/js/extension.js @@ -95,3 +95,41 @@ if (EXTENSION_IS_BROWSER_EDGE) {      // Edge does not have chrome defined.      chrome = browser;  } + +function promiseTimeout(delay, resolveValue) { +    if (delay <= 0) { +        return Promise.resolve(resolveValue); +    } + +    let timer = null; +    let promiseResolve = null; +    let promiseReject = null; + +    const complete = (callback, value) => { +        if (callback === null) { return; } +        if (timer !== null) { +            window.clearTimeout(timer); +            timer = null; +        } +        promiseResolve = null; +        promiseReject = null; +        callback(value); +    }; + +    const resolve = (value) => complete(promiseResolve, value); +    const reject = (value) => complete(promiseReject, value); + +    const promise = new Promise((resolve, reject) => { +        promiseResolve = resolve; +        promiseReject = reject; +    }); +    timer = window.setTimeout(() => { +        timer = null; +        resolve(resolveValue); +    }, delay); + +    promise.resolve = resolve; +    promise.reject = reject; + +    return promise; +} |