Typing Everywhere

在普通网页文本上进行非侵入式连续打字练习

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==UserScript==
// @name         Typing Everywhere
// @namespace    https://github.com/local/typing-everywhere
// @version      0.1.0
// @description  在普通网页文本上进行非侵入式连续打字练习
// @match        *://*/*
// @run-at       document-idle
// @grant        none
// @license      WTFPLv2
// ==/UserScript==

(() => {
  // src/core/config.js
  var PRESET_THEMES = {
    Classic: {
      colors: {
        outline: "#1f6feb",
        pending: "#9ca3af",
        correct: "#111827",
        error: "#111827",
        errorBackground: "#ff7b6b",
        skipped: "#2563eb",
        statsBackground: "#000000",
        statsText: "#00ff51"
      }
    },
    Soft: {
      colors: {
        outline: "#7c8aa5",
        pending: "#b6bcc8",
        correct: "#1f2937",
        error: "#ffffff",
        errorBackground: "#d97706",
        skipped: "#6d28d9",
        statsBackground: "#000000",
        statsText: "#00ff51"
      }
    },
    HighContrast: {
      colors: {
        outline: "#00b7ff",
        pending: "#8b95a7",
        correct: "#000000",
        error: "#ffffff",
        errorBackground: "#dc2626",
        skipped: "#0f766e",
        statsBackground: "#000000",
        statsText: "#00ff51"
      }
    }
  };
  var DEFAULT_CONFIG = {
    theme: "Classic",
    icon: {
      type: "emoji",
      value: "\u{1F913}"
    },
    colors: { ...PRESET_THEMES.Classic.colors },
    behavior: {
      followCurrentParagraph: true,
      followCorrectTextColor: true
    }
  };
  function mergeConfig(partial = {}) {
    return {
      ...DEFAULT_CONFIG,
      ...partial,
      icon: {
        ...DEFAULT_CONFIG.icon,
        ...partial.icon
      },
      colors: {
        ...DEFAULT_CONFIG.colors,
        ...partial.colors
      },
      behavior: {
        ...DEFAULT_CONFIG.behavior,
        ...partial.behavior
      }
    };
  }

  // src/core/metrics.js
  function calculateMetrics({ typedCount, errorCount, elapsedMs }) {
    if (typedCount === 0 || elapsedMs <= 0) {
      return { wpm: 0, cpm: 0, errorRate: 0 };
    }
    const minutes = elapsedMs / 6e4;
    return {
      wpm: typedCount / 5 / minutes,
      cpm: typedCount / minutes,
      errorRate: errorCount / typedCount
    };
  }

  // src/core/characters.js
  function normalizeText(value) {
    return value.replace(/\s+/gu, " ").trim();
  }
  function splitCharacters(value) {
    return Array.from(value);
  }

  // src/core/paragraphs.js
  var PARAGRAPH_SELECTOR = "p,li,blockquote,pre,figcaption,h1,h2,h3,h4,h5,h6";
  var EXCLUDED_SELECTOR = [
    "input",
    "textarea",
    "select",
    "button",
    '[contenteditable]:not([contenteditable="false"])',
    '[aria-hidden="true"]',
    "script",
    "style",
    "noscript",
    "[data-typing-everywhere-ui]"
  ].join(",");
  function defaultIsVisible(element) {
    const view = element.ownerDocument.defaultView;
    const style = view.getComputedStyle(element);
    return !element.hidden && style.display !== "none" && style.visibility !== "hidden" && normalizeText(element.textContent ?? "") !== "";
  }
  function isValidParagraph(element, { isVisible = defaultIsVisible } = {}) {
    if (!element || !element.matches(PARAGRAPH_SELECTOR)) {
      return false;
    }
    if (element.matches(EXCLUDED_SELECTOR) || element.closest(EXCLUDED_SELECTOR)) {
      return false;
    }
    if (normalizeText(element.textContent ?? "") === "") {
      return false;
    }
    return isVisible(element);
  }
  function findCandidateFromTarget(target, options = {}) {
    const view = target?.ownerDocument?.defaultView;
    const element = view && target instanceof view.Element ? target : target?.parentElement ?? null;
    const candidate = element?.closest(PARAGRAPH_SELECTOR) ?? null;
    return isValidParagraph(candidate, options) ? candidate : null;
  }
  function listParagraphsFrom(start, options = {}) {
    if (!isValidParagraph(start, options)) {
      return [];
    }
    const paragraphs = [...start.ownerDocument.querySelectorAll(PARAGRAPH_SELECTOR)];
    const startIndex = paragraphs.indexOf(start);
    if (startIndex === -1) {
      return [];
    }
    return paragraphs.slice(startIndex).filter((element) => isValidParagraph(element, options));
  }

  // src/core/position.js
  function clampPosition(position, viewport, size, inset = 0) {
    return {
      x: Math.min(
        Math.max(position.x, inset),
        Math.max(viewport.width - size - inset, inset)
      ),
      y: Math.min(
        Math.max(position.y, inset),
        Math.max(viewport.height - size - inset, inset)
      )
    };
  }
  function getClosestEdge(position, viewport, size, inset = 0) {
    const clamped = clampPosition(position, viewport, size, inset);
    const distances = [
      ["left", clamped.x - inset],
      ["right", viewport.width - size - inset - clamped.x],
      ["top", clamped.y - inset],
      ["bottom", viewport.height - size - inset - clamped.y]
    ];
    return distances.reduce((best, item) => item[1] < best[1] ? item : best)[0];
  }
  function snapToNearestEdge(position, viewport, size, inset = 0) {
    const clamped = clampPosition(position, viewport, size, inset);
    const edge = getClosestEdge(position, viewport, size, inset);
    if (edge === "left") {
      return { ...clamped, x: inset };
    }
    if (edge === "right") {
      return { ...clamped, x: viewport.width - size - inset };
    }
    if (edge === "top") {
      return { ...clamped, y: inset };
    }
    return { ...clamped, y: viewport.height - size - inset };
  }

  // src/core/session.js
  function toParagraphCharacters(value) {
    return splitCharacters(normalizeText(value));
  }
  var TypingSession = class {
    constructor(paragraphs) {
      this.paragraphs = paragraphs.map(toParagraphCharacters);
      this.entries = this.paragraphs.map((paragraph) => paragraph.map(() => null));
      this.paragraphIndex = 0;
      this.characterIndex = 0;
      this.typedCount = 0;
      this.errorCount = 0;
      this.done = this.paragraphs.length === 0;
    }
    typeText(value) {
      for (const character of splitCharacters(value)) {
        if (this.done) {
          break;
        }
        const currentParagraph = this.paragraphs[this.paragraphIndex];
        const expected = currentParagraph[this.characterIndex];
        const correct = character === expected;
        this.typedCount += 1;
        if (!correct) {
          this.errorCount += 1;
        }
        this.entries[this.paragraphIndex][this.characterIndex] = {
          value: character,
          correct
        };
        this.characterIndex += 1;
        if (this.characterIndex >= currentParagraph.length) {
          this.#advanceParagraph();
        }
      }
    }
    backspace() {
      if (this.done || this.characterIndex === 0) {
        return;
      }
      this.characterIndex -= 1;
      this.entries[this.paragraphIndex][this.characterIndex] = null;
    }
    skipCharacter() {
      if (this.done) {
        return null;
      }
      const paragraphIndex = this.paragraphIndex;
      const currentParagraph = this.paragraphs[paragraphIndex];
      this.entries[paragraphIndex][this.characterIndex] = {
        value: currentParagraph[this.characterIndex],
        correct: false,
        skipped: true
      };
      this.characterIndex += 1;
      if (this.characterIndex >= currentParagraph.length) {
        this.#advanceParagraph();
      }
      return this.getRenderState(paragraphIndex);
    }
    skipParagraph() {
      if (this.done) {
        return [];
      }
      const paragraphIndex = this.paragraphIndex;
      const currentParagraph = this.paragraphs[paragraphIndex];
      while (!this.done && this.paragraphIndex === paragraphIndex) {
        if (this.entries[paragraphIndex][this.characterIndex] === null) {
          this.entries[paragraphIndex][this.characterIndex] = {
            value: currentParagraph[this.characterIndex],
            correct: false,
            skipped: true
          };
        }
        this.characterIndex += 1;
        if (this.characterIndex >= currentParagraph.length) {
          this.#advanceParagraph();
        }
      }
      return this.getRenderState(paragraphIndex);
    }
    appendParagraphs(paragraphs) {
      const nextParagraphs = paragraphs.map(toParagraphCharacters);
      this.paragraphs.push(...nextParagraphs);
      this.entries.push(...nextParagraphs.map((paragraph) => paragraph.map(() => null)));
      if (this.done && this.paragraphIndex < this.paragraphs.length) {
        this.done = false;
      }
    }
    getRenderState(index = this.paragraphIndex) {
      const paragraph = this.paragraphs[index] ?? [];
      const entries = this.entries[index] ?? [];
      return paragraph.map((expected, position) => {
        const entry = entries[position];
        if (!entry) {
          return { text: expected, state: "pending" };
        }
        if (entry.skipped) {
          return { text: entry.value, state: "skipped" };
        }
        return {
          text: entry.value,
          state: entry.correct ? "correct" : "error"
        };
      });
    }
    snapshot() {
      return {
        paragraphIndex: this.paragraphIndex,
        characterIndex: this.characterIndex,
        typedCount: this.typedCount,
        errorCount: this.errorCount,
        done: this.done
      };
    }
    #advanceParagraph() {
      this.paragraphIndex += 1;
      this.characterIndex = 0;
      this.done = this.paragraphIndex >= this.paragraphs.length;
    }
  };

  // src/ui/widget.js
  var BUTTON_OFFSET = 24;
  var THEME_VARIABLES = {
    outline: "--te-outline-color",
    pending: "--te-pending-color",
    correct: "--te-correct-color",
    error: "--te-error-color",
    skipped: "--te-skipped-color",
    errorBackground: "--te-error-bg-color",
    statsBackground: "--te-stats-background-color",
    statsText: "--te-stats-text-color"
  };
  function createWidget(document2) {
    const host = document2.createElement("div");
    host.dataset.typingEverywhereUi = "true";
    const root = host.attachShadow({ mode: "open" });
    root.innerHTML = `
    <style>
      :host {
        all: initial;
      }

      .te-dock,
      .te-outline,
      .te-typing-layer,
      .te-stats,
      .te-prompt,
      .te-settings,
      .te-capture {
        box-sizing: border-box;
        position: fixed;
        z-index: 2147483647;
      }

      .te-dock {
        top: ${BUTTON_OFFSET}px;
        right: ${BUTTON_OFFSET}px;
        display: flex;
        align-items: center;
        gap: 8px;
        transition: transform 180ms ease, opacity 180ms ease;
      }

      .te-dock[data-edge="left"],
      .te-dock[data-edge="right"] {
        flex-direction: column;
      }

      .te-dock[data-edge="top"],
      .te-dock[data-edge="bottom"] {
        flex-direction: row;
      }

      .te-dock[data-collapsed="true"][data-edge="right"] {
        transform: translateX(calc(100% - 14px));
      }

      .te-dock[data-collapsed="true"][data-edge="left"] {
        transform: translateX(calc(-100% + 58px));
      }

      .te-dock[data-collapsed="true"][data-edge="top"] {
        transform: translateY(calc(-100% + 14px));
      }

      .te-dock[data-collapsed="true"][data-edge="bottom"] {
        transform: translateY(calc(100% - 14px));
      }

      .te-button {
        width: 44px;
        height: 44px;
        border: 0;
        border-radius: 999px;
        background: var(--te-outline-color, #1f6feb);
        color: #ffffff;
        box-shadow: 0 10px 25px rgba(15, 23, 42, 0.22);
        font: 600 18px/1 system-ui, sans-serif;
      }

      .te-start-button img {
        width: 28px;
        height: 28px;
        object-fit: contain;
        pointer-events: none;
      }

      .te-start-button {
        cursor: grab;
      }

      .te-start-button:active {
        cursor: grabbing;
      }

      .te-settings-button {
        display: none;
        cursor: pointer;
        font-size: 16px;
      }

      .te-dock[data-expanded="true"] .te-settings-button {
        display: inline-flex;
        align-items: center;
        justify-content: center;
      }

      .te-outline {
        display: none;
        border: 2px solid var(--te-outline-color, #1f6feb);
        border-radius: 6px;
        pointer-events: none;
      }

      .te-typing-layer {
        display: none;
        pointer-events: none;
        position: fixed;
        white-space: pre-wrap;
        overflow: hidden;
      }

      .te-char[data-state="pending"] {
        color: var(--te-pending-color, #9ca3af);
      }

      .te-char[data-state="correct"] {
        color: var(--te-correct-color, currentColor);
      }

      .te-char[data-state="error"] {
        color: var(--te-error-color, #111827);
        background: var(--te-error-bg-color, #ff7b6b);
      }

      .te-char[data-state="skipped"] {
        color: var(--te-skipped-color, #2563eb);
        text-decoration: underline;
        text-decoration-style: dashed;
      }

      .te-cursor {
        position: absolute;
        width: 0;
        border-left: 2px solid currentColor;
        animation: te-blink 1s steps(1, end) infinite;
      }

      @keyframes te-blink {
        0%,
        49% {
          opacity: 1;
        }

        50%,
        100% {
          opacity: 0;
        }
      }

      .te-stats {
        right: 16px;
        bottom: 16px;
        display: none;
        padding: 12px 14px;
        border-radius: 10px;
        background: var(--te-stats-background-color, #000000);
        color: var(--te-stats-text-color, #00ff51);
        font: 13px/1.4 system-ui, sans-serif;
        white-space: pre;
      }

      .te-prompt {
        top: 50%;
        left: 50%;
        display: none;
        min-width: 260px;
        max-width: min(520px, calc(100vw - 32px));
        padding: 16px 20px;
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-radius: 14px;
        background: rgba(23, 25, 31, 0.92);
        color: #ffffff;
        font: 15px/1.5 system-ui, sans-serif;
        text-align: center;
        transform: translate(-50%, -50%);
      }

      .te-settings {
        top: 72px;
        right: 16px;
        display: none;
        width: min(340px, calc(100vw - 32px));
        max-height: calc(100vh - 96px);
        overflow: auto;
        padding: 16px;
        border: 1px solid rgba(15, 23, 42, 0.12);
        border-radius: 14px;
        background: rgba(255, 255, 255, 0.98);
        color: #111827;
        font: 13px/1.5 system-ui, sans-serif;
        box-shadow: 0 20px 45px rgba(15, 23, 42, 0.16);
      }

      .te-settings h2 {
        margin: 0 0 12px;
        font: 600 14px/1.4 system-ui, sans-serif;
      }

      .te-settings fieldset {
        margin: 0 0 14px;
        padding: 0;
        border: 0;
      }

      .te-settings legend {
        margin-bottom: 8px;
        font-weight: 600;
      }

      .te-settings label {
        display: flex;
        align-items: center;
        gap: 8px;
        margin: 0 0 8px;
      }

      .te-settings label:last-child {
        margin-bottom: 0;
      }

      .te-settings label.color {
        justify-content: space-between;
      }

      .te-settings input[type="color"] {
        width: 44px;
        height: 28px;
        padding: 0;
        border: 0;
        background: transparent;
      }

      .te-capture {
        top: 0;
        left: -10000px;
        width: 1px;
        height: 1px;
        opacity: 0;
      }
    </style>
    <div class="te-dock" data-expanded="false" data-collapsed="false" data-edge="right">
      <button class="te-button te-start-button" type="button" aria-label="\u5F00\u59CB\u6253\u5B57\u7EC3\u4E60">\u{1F913}</button>
      <button class="te-button te-settings-button" type="button" aria-label="\u6253\u5F00\u8BBE\u7F6E">\u2699</button>
    </div>
    <div class="te-outline"></div>
    <div class="te-typing-layer" aria-hidden="true"></div>
    <div class="te-stats"></div>
    <div class="te-prompt" role="status" aria-live="polite"></div>
    <section class="te-settings" aria-label="\u6253\u5B57\u7EC3\u4E60\u8BBE\u7F6E"></section>
    <textarea class="te-capture" aria-hidden="true"></textarea>
  `;
    document2.documentElement.append(host);
    const dock = root.querySelector(".te-dock");
    const button = root.querySelector(".te-start-button");
    const settingsButton = root.querySelector(".te-settings-button");
    const outline = root.querySelector(".te-outline");
    const typingLayer = root.querySelector(".te-typing-layer");
    const stats = root.querySelector(".te-stats");
    const prompt = root.querySelector(".te-prompt");
    const settings = root.querySelector(".te-settings");
    const capture = root.querySelector(".te-capture");
    const themeTargets = [
      dock,
      button,
      settingsButton,
      outline,
      typingLayer,
      stats,
      prompt,
      settings
    ];
    let maskedTarget = null;
    let maskedVisibility = "";
    applyThemeColors(PRESET_THEMES.Classic.colors, themeTargets);
    return {
      host,
      dock,
      button,
      startButton: button,
      settingsButton,
      settings,
      capture,
      setButtonPosition({ x, y }) {
        dock.style.left = `${x}px`;
        dock.style.top = `${y}px`;
        dock.style.right = "auto";
      },
      setDockEdge(edge) {
        dock.dataset.edge = edge;
      },
      setIcon(icon = {}) {
        if (icon.type === "image" && icon.value) {
          button.innerHTML = `<img alt="" src="${escapeHtml(icon.value)}">`;
          return;
        }
        button.textContent = icon.value || "\u{1F913}";
      },
      setExpanded(expanded) {
        dock.dataset.expanded = String(expanded);
      },
      setIdleCollapsed(collapsed, edge = dock.dataset.edge ?? "right") {
        dock.dataset.edge = edge;
        dock.dataset.collapsed = String(collapsed);
      },
      showOutline(rect) {
        outline.style.display = "block";
        outline.style.top = `${rect.top}px`;
        outline.style.left = `${rect.left}px`;
        outline.style.width = `${rect.width}px`;
        outline.style.height = `${rect.height}px`;
      },
      hideOutline() {
        outline.style.display = "none";
      },
      showPrompt(message) {
        prompt.textContent = message;
        prompt.style.display = "block";
      },
      hidePrompt() {
        prompt.style.display = "none";
        prompt.textContent = "";
      },
      showTypingOverlay(target, characters, cursorIndex = null) {
        if (maskedTarget && maskedTarget !== target) {
          this.hideTypingOverlay();
        }
        if (!maskedTarget) {
          maskedTarget = target;
          maskedVisibility = target.style.visibility;
        }
        const rect = target.getBoundingClientRect();
        const style = target.ownerDocument.defaultView.getComputedStyle(target);
        target.style.visibility = "hidden";
        typingLayer.style.display = "block";
        typingLayer.style.top = `${rect.top}px`;
        typingLayer.style.left = `${rect.left}px`;
        typingLayer.style.width = `${rect.width}px`;
        typingLayer.style.height = `${rect.height}px`;
        typingLayer.style.font = style.font;
        typingLayer.style.fontFamily = style.fontFamily;
        typingLayer.style.fontSize = style.fontSize;
        typingLayer.style.fontWeight = style.fontWeight;
        typingLayer.style.fontStyle = style.fontStyle;
        typingLayer.style.lineHeight = style.lineHeight;
        typingLayer.style.letterSpacing = style.letterSpacing;
        typingLayer.style.wordSpacing = style.wordSpacing;
        typingLayer.style.textAlign = style.textAlign;
        typingLayer.style.textTransform = style.textTransform;
        typingLayer.style.color = style.color;
        typingLayer.style.padding = style.padding;
        typingLayer.innerHTML = renderCharacters(characters, cursorIndex);
        positionCursor(typingLayer, cursorIndex, style.lineHeight);
      },
      hideTypingOverlay() {
        if (maskedTarget) {
          maskedTarget.style.visibility = maskedVisibility;
          if (maskedTarget.getAttribute("style") === "") {
            maskedTarget.removeAttribute("style");
          }
        }
        maskedTarget = null;
        maskedVisibility = "";
        typingLayer.style.display = "none";
        typingLayer.innerHTML = "";
      },
      showStats({ wpm, cpm, errorRate }) {
        stats.style.display = "block";
        stats.textContent = `${Math.round(wpm)} WPM  ${Math.round(cpm)} CPM  ${(errorRate * 100).toFixed(1)}%  Esc \u9000\u51FA`;
      },
      hideStats() {
        stats.style.display = "none";
      },
      showSettings(config = {}) {
        const theme = config.theme ?? "Classic";
        const colors = config.colors ?? PRESET_THEMES[theme]?.colors ?? PRESET_THEMES.Classic.colors;
        const followCurrentParagraph = config.behavior?.followCurrentParagraph ?? true;
        const followCorrectTextColor = config.behavior?.followCorrectTextColor ?? true;
        const iconType = config.icon?.type ?? "emoji";
        const iconValue = config.icon?.value ?? "\u{1F913}";
        settings.innerHTML = `
        <h2>\u8BBE\u7F6E</h2>
        <fieldset>
          <legend>\u4E3B\u9898\u9884\u8BBE</legend>
          ${Object.keys(PRESET_THEMES).map(
          (presetName) => `
                <label>
                  <input
                    type="radio"
                    name="te-theme-preset"
                    value="${presetName}"
                    ${presetName === theme ? "checked" : ""}
                  />
                  <span>${presetName}</span>
                </label>
              `
        ).join("")}
        </fieldset>
        <fieldset>
          <legend>\u5165\u53E3\u56FE\u6807</legend>
          <label>
            <span>\u5F53\u524D\u56FE\u6807</span>
            <span>${iconType === "image" ? "\u672C\u5730\u56FE\u6807" : escapeHtml(iconValue)}</span>
          </label>
          <label>
            <span>\u81EA\u5B9A\u4E49\u672C\u5730\u56FE\u6807</span>
            <input type="file" name="icon-file" accept="image/*" />
          </label>
        </fieldset>
        <fieldset>
          <legend>\u989C\u8272</legend>
          ${renderColorInput("outline", "\u6846\u7EBF", colors.outline)}
          ${renderColorInput("pending", "\u672A\u8F93\u5165", colors.pending)}
          ${followCorrectTextColor ? "<label><span>\u5DF2\u8F93\u5165\u989C\u8272</span><span>\u8DDF\u968F\u539F\u6587\u989C\u8272</span></label>" : renderColorInput("correct", "\u5DF2\u8F93\u5165", colors.correct)}
          ${renderColorInput("error", "\u9519\u8BEF\u5B57", colors.error)}
          ${renderColorInput("skipped", "\u8DF3\u8FC7\u5B57", colors.skipped)}
          ${renderColorInput("errorBackground", "\u9519\u8BEF\u80CC\u666F", colors.errorBackground)}
          ${renderColorInput("statsBackground", "\u7EDF\u8BA1\u80CC\u666F", colors.statsBackground)}
          ${renderColorInput("statsText", "\u7EDF\u8BA1\u6587\u5B57", colors.statsText)}
        </fieldset>
        <fieldset>
          <legend>\u884C\u4E3A</legend>
          <label>
            <input
              type="checkbox"
              name="followCurrentParagraph"
              ${followCurrentParagraph ? "checked" : ""}
            />
            <span>\u5F53\u524D\u6BB5\u843D\u8DDF\u968F\u6EDA\u52A8</span>
          </label>
          <label>
            <input
              type="checkbox"
              name="followCorrectTextColor"
              ${followCorrectTextColor ? "checked" : ""}
            />
            <span>\u5DF2\u8F93\u5165\u989C\u8272\u8DDF\u968F\u539F\u6587\u989C\u8272</span>
          </label>
        </fieldset>
      `;
        settings.style.display = "block";
      },
      hideSettings() {
        settings.style.display = "none";
      },
      applyTheme(colors) {
        applyThemeColors(colors, themeTargets);
      },
      destroy() {
        this.hideTypingOverlay();
        host.remove();
      }
    };
  }
  function renderColorInput(name, label, value) {
    return `
    <label class="color">
      <span>${label}</span>
      <input type="color" name="color-${name}" value="${value}" />
    </label>
  `;
  }
  function applyThemeColors(colors, targets) {
    for (const [key, variableName] of Object.entries(THEME_VARIABLES)) {
      if (!Object.hasOwn(colors, key)) {
        continue;
      }
      for (const target of targets) {
        target.style.setProperty(variableName, colors[key]);
      }
    }
  }
  function renderCharacters(characters, cursorIndex) {
    const fragments = [];
    characters.forEach(({ text, state }, index) => {
      fragments.push(
        `<span class="te-char" data-index="${index}" data-state="${state}">${escapeHtml(text)}</span>`
      );
    });
    if (cursorIndex !== null) {
      fragments.push(renderCursor(cursorIndex));
    }
    return fragments.join("");
  }
  function renderCursor(position) {
    return `<span class="te-cursor" data-position="${position}" aria-hidden="true"></span>`;
  }
  function positionCursor(layer, cursorIndex, lineHeightValue) {
    const cursor = layer.querySelector(".te-cursor");
    if (!cursor || cursorIndex === null) {
      return;
    }
    const layerRect = layer.getBoundingClientRect();
    const chars = [...layer.querySelectorAll(".te-char")];
    const nextChar = chars[cursorIndex] ?? null;
    const previousChar = chars[cursorIndex - 1] ?? null;
    const anchorRect = nextChar?.getBoundingClientRect() ?? previousChar?.getBoundingClientRect();
    const fallbackHeight = parseFloat(lineHeightValue) || parseFloat(layer.style.fontSize) || 16;
    if (!anchorRect) {
      cursor.style.left = "0px";
      cursor.style.top = "0px";
      cursor.style.height = `${fallbackHeight}px`;
      return;
    }
    const left = nextChar !== null ? anchorRect.left - layerRect.left : anchorRect.right - layerRect.left;
    const top = anchorRect.top - layerRect.top;
    const height = anchorRect.height || fallbackHeight;
    cursor.style.left = `${left}px`;
    cursor.style.top = `${top}px`;
    cursor.style.height = `${height}px`;
  }
  function escapeHtml(text) {
    return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
  }

  // src/app.js
  var ICON_SIZE = 44;
  var BUTTON_INSET = 24;
  var POSITION_KEY = "typing-everywhere-position";
  var CONFIG_KEY = "typing-everywhere-config";
  var STATS_INTERVAL_MS = 250;
  var IDLE_COLLAPSE_MS = 5 * 60 * 1e3;
  var PARAGRAPH_SKIP_PREVIEW_MS = 600;
  function createTypingApp({
    document: document2,
    now = () => Date.now(),
    isVisible = defaultIsVisible,
    scrollIntoView = (element) => {
      element.scrollIntoView?.({ behavior: "smooth", block: "center" });
    },
    setIntervalFn = (callback, delay) => document2.defaultView.setInterval(callback, delay),
    clearIntervalFn = (timerId) => document2.defaultView.clearInterval(timerId),
    setTimeoutFn = (callback, delay) => document2.defaultView.setTimeout(callback, delay),
    clearTimeoutFn = (timerId) => document2.defaultView.clearTimeout(timerId)
  } = {}) {
    const view = document2.defaultView;
    const widget = createWidget(document2);
    const cleanups = [];
    const observer = new view.MutationObserver(() => {
      refreshParagraphs();
    });
    observer.observe(document2.body, { childList: true, subtree: true });
    let mode = "idle";
    let candidate = null;
    let session = null;
    let paragraphElements = [];
    let startedAt = null;
    let composing = false;
    let drag = null;
    let currentDockEdge = "right";
    let statsTimerId = null;
    let paragraphSkipTimerId = null;
    let previewParagraphIndex = null;
    let settingsOpen = false;
    let config = loadConfig();
    let lastActivityAt = now();
    widget.hideOutline();
    widget.hideStats();
    widget.hidePrompt();
    widget.hideSettings();
    widget.setExpanded(false);
    widget.setIdleCollapsed(false, currentDockEdge);
    widget.setIcon(config.icon);
    widget.applyTheme(getEffectiveColors());
    clearCapture();
    restoreButtonPosition();
    ensureStatsTimer();
    listen(widget.button, "pointerdown", beginDrag);
    listen(widget.button, "contextmenu", suppressContextMenu);
    listen(widget.settingsButton, "click", toggleSettingsPanel);
    listen(widget.settingsButton, "contextmenu", suppressContextMenu);
    listen(widget.dock, "pointerenter", handleDockPointerEnter);
    listen(widget.dock, "pointerleave", handleDockPointerLeave);
    listen(document2, "pointermove", moveDrag, true);
    listen(document2, "pointerup", endDrag, true);
    listen(document2, "pointermove", handleSelectionHover, true);
    listen(document2, "click", handleSelectionClick, true);
    listen(document2, "keydown", handleKeydown, true);
    listen(view, "scroll", syncTypingLayer, true);
    listen(view, "resize", syncTypingLayer);
    listen(widget.capture, "beforeinput", handleBeforeInput);
    listen(widget.capture, "compositionstart", () => {
      composing = true;
      touch();
    });
    listen(widget.capture, "compositionend", (event) => {
      composing = false;
      touch();
      acceptText(event.data ?? "");
      clearCapture();
    });
    listen(widget.settings, "input", handleSettingsInput);
    listen(widget.settings, "change", handleSettingsInput);
    return {
      capture: widget.capture,
      enterSelectionMode,
      selectParagraph,
      getMode: () => mode,
      getSnapshot: () => session?.snapshot() ?? null,
      destroy
    };
    function listen(target, type, handler, options) {
      target.addEventListener(type, handler, options);
      cleanups.push(() => target.removeEventListener(type, handler, options));
    }
    function touch() {
      lastActivityAt = now();
      widget.setIdleCollapsed(false, currentDockEdge);
    }
    function getViewport() {
      return { width: view.innerWidth, height: view.innerHeight };
    }
    function loadConfig() {
      const raw = view.localStorage.getItem(CONFIG_KEY);
      if (!raw) {
        return mergeConfig(DEFAULT_CONFIG);
      }
      try {
        return mergeConfig(JSON.parse(raw));
      } catch {
        view.localStorage.removeItem(CONFIG_KEY);
        return mergeConfig(DEFAULT_CONFIG);
      }
    }
    function saveConfig() {
      view.localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
    }
    function getEffectiveColors() {
      if (config.behavior.followCorrectTextColor) {
        return {
          ...config.colors,
          correct: "currentColor"
        };
      }
      return config.colors;
    }
    function restoreButtonPosition() {
      const raw = view.localStorage.getItem(POSITION_KEY);
      if (!raw) {
        widget.setDockEdge(currentDockEdge);
        return;
      }
      try {
        const saved = JSON.parse(raw);
        const position = clampPosition(
          {
            x: saved.xRatio * view.innerWidth,
            y: saved.yRatio * view.innerHeight
          },
          getViewport(),
          ICON_SIZE,
          BUTTON_INSET
        );
        currentDockEdge = saved.edge ?? getClosestEdge(position, getViewport(), ICON_SIZE, BUTTON_INSET);
        widget.setDockEdge(currentDockEdge);
        widget.setButtonPosition(position);
      } catch {
        view.localStorage.removeItem(POSITION_KEY);
      }
    }
    function saveButtonPosition(position) {
      view.localStorage.setItem(
        POSITION_KEY,
        JSON.stringify({
          edge: currentDockEdge,
          xRatio: position.x / Math.max(view.innerWidth, 1),
          yRatio: position.y / Math.max(view.innerHeight, 1)
        })
      );
    }
    function enterSelectionMode() {
      exitMode();
      touch();
      mode = "selecting";
      settingsOpen = false;
      widget.hideSettings();
      widget.setExpanded(true);
      widget.showPrompt("\u8BF7\u9009\u62E9\u4E00\u6BB5\u6587\u672C\uFF0CEsc \u9000\u51FA");
    }
    function selectParagraph(element) {
      paragraphElements = listParagraphsFrom(element, { isVisible });
      if (paragraphElements.length === 0) {
        exitMode();
        return;
      }
      clearParagraphSkipPreview();
      session = new TypingSession(
        paragraphElements.map((paragraph) => paragraph.textContent ?? "")
      );
      mode = "typing";
      candidate = null;
      startedAt = null;
      composing = false;
      widget.hidePrompt();
      widget.hideOutline();
      widget.hideTypingOverlay();
      widget.showStats({ wpm: 0, cpm: 0, errorRate: 0 });
      syncTypingLayer();
      focusParagraph(session.snapshot().paragraphIndex);
      widget.capture.focus();
    }
    function exitMode() {
      mode = "idle";
      candidate = null;
      session = null;
      paragraphElements = [];
      startedAt = null;
      composing = false;
      previewParagraphIndex = null;
      clearParagraphSkipPreview();
      widget.hidePrompt();
      widget.hideOutline();
      widget.hideTypingOverlay();
      widget.hideStats();
      widget.capture.blur();
      clearCapture();
    }
    function clearCapture() {
      widget.capture.value = "";
    }
    function ensureStatsTimer() {
      if (statsTimerId !== null) {
        return;
      }
      statsTimerId = setIntervalFn(() => {
        if (mode === "typing" && session && startedAt !== null && previewParagraphIndex === null) {
          renderMetrics();
        }
        updateIdleCollapse();
      }, STATS_INTERVAL_MS);
    }
    function updateIdleCollapse() {
      const shouldCollapse = mode === "idle" && !settingsOpen && now() - lastActivityAt >= IDLE_COLLAPSE_MS;
      widget.setIdleCollapsed(shouldCollapse, currentDockEdge);
    }
    function handleDockPointerEnter() {
      touch();
      widget.setExpanded(true);
    }
    function handleDockPointerLeave() {
      if (!settingsOpen && mode === "idle") {
        widget.setExpanded(false);
      }
    }
    function toggleSettingsPanel(event) {
      event.preventDefault();
      event.stopPropagation();
      touch();
      settingsOpen = !settingsOpen;
      widget.setExpanded(true);
      if (settingsOpen) {
        widget.showSettings(config);
      } else {
        widget.hideSettings();
        if (mode === "idle") {
          widget.setExpanded(false);
        }
      }
    }
    function suppressContextMenu(event) {
      event.preventDefault();
      event.stopPropagation();
      touch();
    }
    function handleKeydown(event) {
      if (event.key === "Escape" && mode !== "idle") {
        event.preventDefault();
        event.stopPropagation();
        exitMode();
        return;
      }
      if (mode !== "typing") {
        return;
      }
      if (event.key === "Tab") {
        event.preventDefault();
        event.stopPropagation();
        touch();
        if (event.shiftKey) {
          skipCurrentParagraph();
        } else {
          session.skipCharacter();
          syncTypingLayer();
        }
        widget.capture.focus();
        return;
      }
      if (composing || event.isComposing) {
        return;
      }
      const fromCapture = getEventSource(event) === widget.capture;
      const hasShortcutModifier = event.ctrlKey || event.metaKey || event.altKey;
      if (!fromCapture || hasShortcutModifier || !isPermittedTypingKey(event.key)) {
        event.preventDefault();
        event.stopPropagation();
        widget.capture.focus();
      }
    }
    function handleSelectionHover(event) {
      if (mode !== "selecting") {
        return;
      }
      candidate = findCandidateFromTarget(event.target, { isVisible });
      if (!candidate) {
        widget.hideOutline();
        return;
      }
      widget.showOutline(candidate.getBoundingClientRect());
    }
    function handleSelectionClick(event) {
      if (mode !== "selecting") {
        return;
      }
      const selected = findCandidateFromTarget(event.target, { isVisible });
      if (!selected) {
        return;
      }
      event.preventDefault();
      event.stopImmediatePropagation();
      touch();
      selectParagraph(selected);
    }
    function handleBeforeInput(event) {
      if (mode !== "typing" || previewParagraphIndex !== null) {
        return;
      }
      event.preventDefault();
      touch();
      if (composing || event.isComposing) {
        return;
      }
      if (event.inputType === "deleteContentBackward") {
        session.backspace();
        syncTypingLayer();
        renderMetrics();
        clearCapture();
        return;
      }
      if (event.inputType?.startsWith("insert")) {
        acceptText(event.data ?? "");
        clearCapture();
      }
    }
    function acceptText(text) {
      if (mode !== "typing" || !text || previewParagraphIndex !== null) {
        return;
      }
      if (startedAt === null) {
        startedAt = now();
      }
      const previousIndex = session.snapshot().paragraphIndex;
      session.typeText(text);
      const nextState = session.snapshot();
      if (!nextState.done && nextState.paragraphIndex !== previousIndex) {
        focusParagraph(nextState.paragraphIndex);
      }
      refreshParagraphs();
      syncTypingLayer();
      renderMetrics();
    }
    function skipCurrentParagraph() {
      if (!session || previewParagraphIndex !== null) {
        return;
      }
      const previousIndex = session.snapshot().paragraphIndex;
      session.skipParagraph();
      previewParagraphIndex = previousIndex;
      syncTypingLayer();
      clearParagraphSkipPreview();
      paragraphSkipTimerId = setTimeoutFn(() => {
        previewParagraphIndex = null;
        paragraphSkipTimerId = null;
        const state = session?.snapshot();
        if (state && !state.done) {
          focusParagraph(state.paragraphIndex);
        }
        syncTypingLayer();
      }, PARAGRAPH_SKIP_PREVIEW_MS);
    }
    function clearParagraphSkipPreview() {
      if (paragraphSkipTimerId !== null) {
        clearTimeoutFn(paragraphSkipTimerId);
        paragraphSkipTimerId = null;
      }
    }
    function focusParagraph(index) {
      if (!config.behavior.followCurrentParagraph) {
        return;
      }
      const target = paragraphElements[index];
      if (target) {
        scrollIntoView(target);
      }
    }
    function refreshParagraphs() {
      if (!session || !session.snapshot().done || paragraphElements.length === 0) {
        return;
      }
      const tail = paragraphElements.at(-1);
      if (!tail?.isConnected) {
        return;
      }
      const discovered = listParagraphsFrom(tail, { isVisible }).slice(1);
      const additions = discovered.filter((element) => !paragraphElements.includes(element));
      if (additions.length === 0) {
        return;
      }
      paragraphElements.push(...additions);
      session.appendParagraphs(additions.map((element) => element.textContent ?? ""));
      focusParagraph(session.snapshot().paragraphIndex);
      syncTypingLayer();
    }
    function renderMetrics() {
      if (!session) {
        return;
      }
      const state = session.snapshot();
      const elapsedMs = startedAt === null ? 0 : Math.max(now() - startedAt, 1);
      widget.showStats(
        calculateMetrics({
          typedCount: state.typedCount,
          errorCount: state.errorCount,
          elapsedMs
        })
      );
    }
    function syncTypingLayer() {
      if (mode !== "typing" || !session) {
        widget.hideTypingOverlay();
        return;
      }
      const state = session.snapshot();
      const renderIndex = previewParagraphIndex ?? state.paragraphIndex;
      const target = paragraphElements[renderIndex];
      if (!target) {
        widget.hideTypingOverlay();
        return;
      }
      if (state.done && previewParagraphIndex === null) {
        widget.hideTypingOverlay();
        return;
      }
      const cursorIndex = previewParagraphIndex === null && renderIndex === state.paragraphIndex ? state.characterIndex : null;
      widget.showTypingOverlay(
        target,
        session.getRenderState(renderIndex),
        cursorIndex
      );
    }
    function beginDrag(event) {
      if (event.button === 2) {
        return;
      }
      touch();
      drag = {
        button: event.button,
        pointerId: event.pointerId,
        startX: event.clientX,
        startY: event.clientY,
        x: event.clientX,
        y: event.clientY,
        moved: false
      };
      widget.button.setPointerCapture?.(event.pointerId);
    }
    function moveDrag(event) {
      if (!drag || event.pointerId !== drag.pointerId) {
        return;
      }
      drag.x = event.clientX;
      drag.y = event.clientY;
      drag.moved ||= Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 5;
      if (!drag.moved) {
        return;
      }
      const position = clampPosition(
        {
          x: drag.x - ICON_SIZE / 2,
          y: drag.y - ICON_SIZE / 2
        },
        getViewport(),
        ICON_SIZE,
        BUTTON_INSET
      );
      currentDockEdge = getClosestEdge(position, getViewport(), ICON_SIZE, BUTTON_INSET);
      widget.setDockEdge(currentDockEdge);
      widget.setButtonPosition(position);
    }
    function endDrag(event) {
      if (!drag || event.pointerId !== drag.pointerId) {
        return;
      }
      const finished = drag;
      drag = null;
      if (!finished.moved) {
        if (finished.button !== 2) {
          touch();
          enterSelectionMode();
        }
        return;
      }
      const position = snapToNearestEdge(
        {
          x: finished.x - ICON_SIZE / 2,
          y: finished.y - ICON_SIZE / 2
        },
        getViewport(),
        ICON_SIZE,
        BUTTON_INSET
      );
      currentDockEdge = getClosestEdge(position, getViewport(), ICON_SIZE, BUTTON_INSET);
      widget.setDockEdge(currentDockEdge);
      widget.setButtonPosition(position);
      saveButtonPosition(position);
    }
    function handleSettingsInput(event) {
      const target = event.target;
      if (!(target instanceof view.HTMLInputElement)) {
        return;
      }
      touch();
      if (target.name === "te-theme-preset") {
        config = mergeConfig({
          ...config,
          theme: target.value,
          colors: { ...PRESET_THEMES[target.value].colors }
        });
        widget.applyTheme(getEffectiveColors());
        widget.showSettings(config);
        saveConfig();
        return;
      }
      if (target.name === "followCurrentParagraph") {
        config = mergeConfig({
          ...config,
          behavior: {
            ...config.behavior,
            followCurrentParagraph: target.checked
          }
        });
        saveConfig();
        return;
      }
      if (target.name === "followCorrectTextColor") {
        config = mergeConfig({
          ...config,
          behavior: {
            ...config.behavior,
            followCorrectTextColor: target.checked
          }
        });
        widget.applyTheme(getEffectiveColors());
        widget.showSettings(config);
        syncTypingLayer();
        saveConfig();
        return;
      }
      if (target.name === "icon-file") {
        const [file] = target.files ?? [];
        if (file) {
          void updateCustomIcon(file);
        }
        return;
      }
      if (target.name.startsWith("color-")) {
        const colorKey = target.name.replace("color-", "");
        config = mergeConfig({
          ...config,
          colors: {
            ...config.colors,
            [colorKey]: target.value
          }
        });
        widget.applyTheme(getEffectiveColors());
        saveConfig();
      }
    }
    async function updateCustomIcon(file) {
      const dataUrl = await readFileAsDataUrl(file);
      config = mergeConfig({
        ...config,
        icon: {
          type: "image",
          value: dataUrl
        }
      });
      widget.setIcon(config.icon);
      widget.showSettings(config);
      saveConfig();
    }
    function readFileAsDataUrl(file) {
      return new Promise((resolve, reject) => {
        const reader = new view.FileReader();
        reader.onload = () => resolve(reader.result);
        reader.onerror = () => reject(reader.error ?? new Error("\u8BFB\u53D6\u56FE\u6807\u5931\u8D25"));
        reader.readAsDataURL(file);
      });
    }
    function destroy() {
      exitMode();
      if (statsTimerId !== null) {
        clearIntervalFn(statsTimerId);
        statsTimerId = null;
      }
      observer.disconnect();
      while (cleanups.length > 0) {
        cleanups.pop()();
      }
      widget.destroy();
    }
  }
  function getEventSource(event) {
    return event.composedPath?.()[0] ?? event.target;
  }
  function isPermittedTypingKey(key) {
    return key.length === 1 || key === "Backspace" || key === "Shift" || key === "CapsLock" || key === "Process" || key === "Dead" || key === "Compose";
  }

  // src/typing-everywhere.user.js
  createTypingApp({ document });
})();