简易富文本

46 阅读3分钟
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Simple Text Editor — Smart Selection Toolbar</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    :root {
      --bg: #0b1020;
      --panel: #121a33;
      --accent: #6ea8fe;
      --text: #e8eefc;
      --muted: #9fb0d0;
      --ok: #6ede8a;
      --warn: #ffd166;
      --shadow: 0 10px 30px rgba(0,0,0,.35);
      --radius: 14px;
    }
    * { box-sizing: border-box }
    html, body { height: 100% }
    body {
      margin: 0;
      background: radial-gradient(1200px 800px at 20% -10%, #1d2a54 0%, #0b1020 55%);
      color: var(--text);
      font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif;
      display: grid;
      place-items: start center;
      padding: 40px 16px;
    }
    .wrap {
      width: min(980px, 100%);
      display: grid;
      gap: 24px;
    }
    .title {
      display: flex;
      gap: 12px;
      align-items: baseline;
      color: var(--muted);
    }
    .title h1 {
      margin: 0;
      font-size: clamp(22px, 3.5vw, 32px);
      color: var(--text);
    }
    .editor {
      position: relative;
      background: var(--panel);
      border-radius: var(--radius);
      box-shadow: var(--shadow);
      padding: clamp(16px, 3vw, 28px);
      outline: 1px solid rgba(255,255,255,.06);
    }
    .surface {
      min-height: 340px;
      outline: none;
      caret-color: var(--accent);
      font-size: 18px;
    }
    .surface :where(h2, h3, p) { margin: .6em 0 }
    .surface h2 { font-size: 26px; line-height: 1.25 }
    .surface mark { background: rgba(255, 245, 157, .4); padding: 0 .2em; border-radius: 6px }
    /* floating toolbar */
    .toolbar {
      position: absolute;
      display: none;
      gap: 6px;
      padding: 6px;
      background: #0f1730;
      border: 1px solid rgba(255,255,255,.06);
      border-radius: 10px;
      box-shadow: var(--shadow);
      transform: translateY(-100%);
      z-index: 1000;
    }
    .tool {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      min-width: 36px;
      height: 36px;
      padding: 0 10px;
      color: var(--text);
      background: #18244a;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      transition: transform .08s ease, background .12s ease, opacity .12s ease;
      user-select: none;
      font-size: 14px;
    }
    .tool:hover { background: #223265 }
    .tool:active { transform: translateY(1px) }
    .tool.ok { background: #17402a }
    .tool.ok:hover { background: #1f5234 }
    .tool.warn { background: #4a3a18 }
    .tool.warn:hover { background: #5a491f }
    .color {
      width: 36px; height: 36px;
      border: none; border-radius: 8px;
      background: #18244a; padding: 4px; cursor: pointer;
    }
    .note {
      color: var(--muted);
      font-size: 14px;
    }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="title">
      <h1>Simple Text Editor</h1>
      <span class="note">Select text to reveal the toolbar. Bold / Italic / Underline / Highlight / Color / Copy / Undo / Redo</span>
    </div>

    <section class="editor" id="editorBox">
      <div class="toolbar" id="selectionBar" role="toolbar" aria-label="Formatting">
        <button class="tool" id="tBold" title="Bold (Ctrl/Cmd+B)">🅱️</button>
        <button class="tool" id="tItalic" title="Italic (Ctrl/Cmd+I)"><span style="font-style:italic">I</span></button>
        <button class="tool" id="tUnderline" title="Underline (Ctrl/Cmd+U)"><span style="text-decoration:underline">U</span></button>
        <button class="tool warn" id="tHighlight" title="Toggle highlight">🖍️</button>
        <input class="color" id="tColor" type="color" value="#ffd166" title="Text color" />
        <button class="tool" id="tCopy" title="Copy selection">📋</button>
        <button class="tool" id="tUndo" title="Undo (Ctrl/Cmd+Z)"></button>
        <button class="tool" id="tRedo" title="Redo (Ctrl/Cmd+Y)"></button>
      </div>

      <div id="doc" class="surface" contenteditable="true" spellcheck="true">
        <h2>Write, select, format.</h2>
        <p>Start typing here. When you <strong>select</strong> text, a floating toolbar pops up right near your selection.</p>
        <p>Try bold, italic, underline, highlight, change color, copy, or use undo/redo. The toolbar stays within the editor bounds and repositions on resize.</p>
        <p>Tip: this demo uses the browser’s formatting commands for simplicity. For production, consider a more robust rich-text approach.</p>
      </div>
    </section>
  </div>

  <script>
    document.addEventListener("DOMContentLoaded", () => {
      // Elements
      const editorBox   = document.getElementById("editorBox");
      const surface     = document.getElementById("doc");
      const bar         = document.getElementById("selectionBar");
      const btnBold     = document.getElementById("tBold");
      const btnItalic   = document.getElementById("tItalic");
      const btnUnder    = document.getElementById("tUnderline");
      const btnMark     = document.getElementById("tHighlight");
      const inpColor    = document.getElementById("tColor");
      const btnCopy     = document.getElementById("tCopy");
      const btnUndo     = document.getElementById("tUndo");
      const btnRedo     = document.getElementById("tRedo");

      // Simple history (HTML snapshots)
      const history = { stack: [], index: -1 };
      function pushState() {
        const html = surface.innerHTML;
        if (history.stack[history.index] === html) return;
        history.stack = history.stack.slice(0, history.index + 1);
        history.stack.push(html);
        history.index++;
      }
      pushState(); // init snapshot

      // Toolbar positioning
      function positionBar() {
        const sel = window.getSelection();
        if (!sel || !sel.toString().trim() || sel.rangeCount === 0) {
          bar.style.display = "none";
          return;
        }
        const range = sel.getRangeAt(0);
        const rect = range.getBoundingClientRect();
        const box  = editorBox.getBoundingClientRect();

        // Skip zero-size rects (e.g., just caret)
        if (rect.width === 0 && rect.height === 0) {
          bar.style.display = "none";
          return;
        }

        let top  = rect.top + window.scrollY - bar.offsetHeight - 8;
        let left = rect.left + window.scrollX + rect.width/2 - bar.offsetWidth/2;

        // Keep inside editor bounds
        const minLeft = box.left + window.scrollX + 8;
        const maxLeft = box.right + window.scrollX - bar.offsetWidth - 8;

        if (top < window.scrollY) {
          top = rect.bottom + window.scrollY + 8; // place below if not enough room above
        }
        left = Math.max(minLeft, Math.min(left, maxLeft));

        bar.style.display = "flex";
        bar.style.top = `${top}px`;
        bar.style.left = `${left}px`;
      }

      function hideBarIfOutside(e) {
        if (!bar.contains(e.target)) {
          const sel = window.getSelection();
          if (!sel || !sel.toString().trim()) {
            bar.style.display = "none";
          }
        }
      }

      // Formatting helpers (uses execCommand for broad support)
      function run(cmd, value = null) {
        pushState();
        document.execCommand(cmd, false, value);
        surface.focus();
      }
      function toggleHighlight() {
        pushState();
        // If selection already has background, clear it — crude toggle
        document.execCommand("hiliteColor", false, "#fff176"); // soft yellow
        surface.focus();
      }

      // Copy selection
      function copySel() {
        const text = String(window.getSelection());
        if (!text) return;
        navigator.clipboard.writeText(text).then(() => {
          btnCopy.textContent = "✅";
          setTimeout(() => (btnCopy.textContent = "📋"), 1200);
        }).catch(() => {
          btnCopy.textContent = "⚠️";
          setTimeout(() => (btnCopy.textContent = "📋"), 1200);
        });
        surface.focus();
      }

      // Undo/redo
      function undo() {
        if (history.index > 0) {
          history.index--;
          surface.innerHTML = history.stack[history.index];
        }
        surface.focus();
      }
      function redo() {
        if (history.index < history.stack.length - 1) {
          history.index++;
          surface.innerHTML = history.stack[history.index];
        }
        surface.focus();
      }

      // Listeners
      surface.addEventListener("mouseup", positionBar);
      surface.addEventListener("keyup", positionBar);
      surface.addEventListener("input", pushState);
      window.addEventListener("resize", () => {
        if (bar.style.display === "flex") positionBar();
      });
      document.addEventListener("mousedown", hideBarIfOutside);

      btnBold.addEventListener("click",   () => run("bold"));
      btnItalic.addEventListener("click", () => run("italic"));
      btnUnder.addEventListener("click",  () => run("underline"));
      btnMark.addEventListener("click",   toggleHighlight);
      inpColor.addEventListener("input",  (e) => run("foreColor", e.target.value));
      btnCopy.addEventListener("click",   copySel);
      btnUndo.addEventListener("click",   undo);
      btnRedo.addEventListener("click",   redo);
    });
  </script>
</body>
</html>