HTML Preview Tool

Preview HTML code blocks with enhanced security and support for CSS animations

// ==UserScript==
// @name         HTML Preview Tool
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  Preview HTML code blocks with enhanced security and support for CSS animations
// @author       douCi
// @match        *://*/*
// @license      MIT
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @require      https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.11/purify.min.js
// ==/UserScript==

(function () {
  "use strict";

  /**
   * Waits for DOMPurify to load and returns the DOMPurify instance.
   * @returns {Promise} Promise that resolves with the DOMPurify instance.
   */
  function waitForDOMPurify() {
    return new Promise((resolve) => {
      function check() {
        if (typeof window.DOMPurify !== "undefined") {
          resolve(window.DOMPurify);
        } else {
          setTimeout(check, 100);
        }
      }
      check();
    });
  }

  /**
   * Initializes the HTML Preview Tool.
   */
  async function initializePreviewTool() {
    try {
      // Wait for DOMPurify to load
      const purify = await waitForDOMPurify();
      console.log("[HTML Preview] DOMPurify loaded successfully");

      /**
       * Creates the preview container with sanitized HTML content.
       * @param {string} htmlContent - The HTML content to preview.
       * @returns {HTMLElement} The preview container element.
       */
      function createPreviewContainer(htmlContent) {
        try {
          // Validate HTML content
          if (!htmlContent || typeof htmlContent !== "string") {
            throw new Error("Invalid HTML content");
          }

          const container = document.createElement("div");
          container.className = "preview-container";

          // Add animation frame tracking
          const animationFrames = new Set();
          container.animationFrames = animationFrames;

          // Use Shadow DOM for style isolation
          const shadow = container.attachShadow({ mode: "open" });

          // Create styles
          const style = document.createElement("style");
          style.textContent = `
                        @keyframes fadeIn {
                            from { opacity: 0; transform: translateY(-10px); }
                            to { opacity: 1; transform: translateY(0); }
                        }

                        @keyframes fadeOut {
                            from { opacity: 1; transform: translateY(0); }
                            to { opacity: 0; transform: translateY(-10px); }
                        }

                        @keyframes scaleButton {
                            0% { transform: scale(1); }
                            50% { transform: scale(0.95); }
                            100% { transform: scale(1); }
                        }

                        .wrapper {
                            width: 100%;
                            min-height: 400px;
                            border: 1px solid #e5e7eb;
                            border-radius: 0.375rem;
                            overflow: hidden;
                            padding: 1rem;
                            background-color: #f9fafb;
                            font-family: system-ui, -apple-system, sans-serif;
                            color: #111827;
                            position: relative;
                            animation: fadeIn 0.3s ease-out;
                            transition: transform 0.3s ease;
                        }

                        .wrapper.removing {
                            animation: fadeOut 0.3s ease-out;
                        }
                        .control-buttons {
                            position: absolute;
                            top: 8px;
                            left: 8px;
                            display: flex;
                            gap: 6px;  /* 增加间距 */
                            z-index: 10;
                        }

                        .control-buttons button {
                            width: 32px;     /* 增加按钮宽度 */
                            height: 32px;    /* 增加按钮高度 */
                            padding: 6px;    /* 增加内边距 */
                            border: 1px solid #e5e7eb;
                            background: white;
                            border-radius: 4px;
                            cursor: pointer;
                            display: flex;
                            align-items: center;
                            justify-content: center;
                            color: #64748b;
                            box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
                            transition: all 0.15s ease;
                        }


                        .control-buttons button:hover {
                            background-color: #f8fafc;
                            color: #475569;
                            border-color: #cbd5e1;
                        }

                        .control-buttons button:active {
                            background-color: #f1f5f9;
                            transform: translateY(1px);
                        }

                        .control-buttons svg {
                            width: 20px;     /* 增加 SVG 图标尺寸 */
                            height: 20px;    /* 增加 SVG 图标尺寸 */
                            stroke-linecap: round;
                            stroke-linejoin: round;
                        }

                        .fullscreen-transition {
                            transition: all 0.3s ease-in-out;
                        }

                        .zoom-transition {
                            transition: transform 0.3s ease-out;
                        }

                        .loading-indicator {
                            position: absolute;
                            top: 50%;
                            left: 50%;
                            transform: translate(-50%, -50%);
                            display: flex;
                            align-items: center;
                            gap: 0.5rem;
                            color: #6b7280;
                        }

                        .spinner {
                            width: 20px;
                            height: 20px;
                            border: 2px solid #e5e7eb;
                            border-top-color: #4f46e5;
                            border-radius: 50%;
                            animation: spin 1s linear infinite;
                        }

                        @keyframes spin {
                            to { transform: rotate(360deg); }
                        }

                        svg {
                            max-width: 100%;
                            height: auto;
                            display: block;
                            margin: 0 auto;
                        }

                        @keyframes fadeIn {
                            from { opacity: 0; transform: translateY(-10px); }
                            to { opacity: 1; transform: translateY(0); }
                        }

                        .resize-handle {
                            position: absolute;
                            bottom: 0;
                            right: 0;
                            width: 20px;
                            height: 20px;
                            cursor: se-resize;
                            color: #9ca3af;
                            display: flex;
                            align-items: center;
                            justify-content: center;
                            opacity: 0.5;
                            transition: opacity 0.2s;
                        }
                        
                        .resize-handle:hover {
                            opacity: 1;
                        }
                        
                        .wrapper {
                            min-height: 200px;
                            resize: both;
                            overflow: auto;
                        }
                    `;

          const wrapper = document.createElement("div");
          wrapper.className = "wrapper";

          // Configure DOMPurify with enhanced security and functionality
          const sanitizedHTML = purify.sanitize(htmlContent, {
            RETURN_TRUSTED_TYPE: true,
            ADD_TAGS: [
              "script",
              "style",
              "svg",
              "circle",
              "rect",
              "path",
              "line",
            ],
            ADD_ATTR: [
              "cx",
              "cy",
              "r",
              "x",
              "y",
              "width",
              "height",
              "viewBox",
              "xmlns",
              "class",
              "id",
              "fill",
              "stroke",
              "stroke-width",
              "transform",
            ],
            FORCE_BODY: true,
            WHOLE_DOCUMENT: true,
            SANITIZE_DOM: true,
          });

          // Parse the sanitized HTML
          const parser = new DOMParser();
          const doc = parser.parseFromString(sanitizedHTML, "text/html");

          // Extract and process style tags
          const styleElements = Array.from(doc.querySelectorAll("style"));
          styleElements.forEach((styleEl) => {
            const newStyle = document.createElement("style");
            newStyle.textContent = styleEl.textContent;
            shadow.appendChild(newStyle);
            styleEl.remove();
          });

          // Extract and process script tags
          const scriptTags = Array.from(doc.querySelectorAll("script"));
          const scriptContents = scriptTags.map((script) => ({
            content: script.textContent,
            type: script.type || "text/javascript",
          }));
          scriptTags.forEach((script) => script.remove());

          // Set the HTML content
          wrapper.innerHTML = doc.body.innerHTML;

          // Create and append control buttons
          const controlButtons = createControlButtons(wrapper);
          wrapper.appendChild(controlButtons);

          // Append wrapper to shadow DOM
          shadow.appendChild(style);
          shadow.appendChild(wrapper);

          // Execute scripts within Shadow DOM context
          scriptContents.forEach(({ content, type }) => {
            try {
              if (
                type === "text/javascript" ||
                type === "application/javascript"
              ) {
                const scriptElement = document.createElement("script");
                scriptElement.textContent = `
                                    try {
                                        (function() {
                                            ${content}
                                        })();
                                    } catch (error) {
                                        console.error('[HTML Preview] Script execution error:', error);
                                    }
                                `;
                shadow.appendChild(scriptElement);
              }
            } catch (error) {
              console.error("[HTML Preview] Script creation error:", error);
            }
          });

          // Add resize functionality
          addResizeCapability(wrapper);

          // Add loading indicator
          const loadingIndicator = createLoadingIndicator();
          wrapper.appendChild(loadingIndicator);

          // Remove loading indicator after content is loaded
          requestAnimationFrame(() => {
            loadingIndicator.remove();
          });

          // Enhanced cleanup function
          const cleanup = createCleanupFunction(wrapper, animationFrames);
          container.cleanup = cleanup;

          return container;
        } catch (error) {
          console.error(
            "[HTML Preview] Preview container creation failed:",
            error
          );
          return createErrorElement("Failed to create preview container");
        }
      }

      /**
       * Creates control buttons for the preview container.
       * @param {HTMLElement} wrapper - The wrapper element.
       * @returns {HTMLElement} The control buttons container.
       */
      function createControlButtons(wrapper) {
        const controlButtons = document.createElement("div");
        controlButtons.className = "control-buttons";

        const buttons = [
          {
            label: "Toggle fullscreen",
            icon: `
                            <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="1.5">
                                <path d="M4 4h4v4M4 4l5 5M20 4h-4v4M20 4l-5 5M4 20h4v-4M4 20l5-5M20 20h-4v-4M20 20l-5-5"/>
                            </svg>
                        `,
            action: () => toggleFullscreen(wrapper),
          },
          {
            label: "Zoom in",
            icon: `
                            <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="1.5">
                                <circle cx="10.5" cy="10.5" r="5.5"/>
                                <line x1="14.5" y1="14.5" x2="19" y2="19"/>
                                <line x1="8.5" y1="10.5" x2="12.5" y2="10.5"/>
                                <line x1="10.5" y1="8.5" x2="10.5" y2="12.5"/>
                            </svg>
                        `,
            action: () => zoomContent(wrapper, 1.2),
          },
          {
            label: "Zoom out",
            icon: `
                            <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="1.5">
                                <circle cx="10.5" cy="10.5" r="5.5"/>
                                <line x1="14.5" y1="14.5" x2="19" y2="19"/>
                                <line x1="8.5" y1="10.5" x2="12.5" y2="10.5"/>
                            </svg>
                        `,
            action: () => zoomContent(wrapper, 0.8),
          },
          {
            label: "Reset zoom",
            icon: `
                            <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="1.5">
                                <circle cx="10.5" cy="10.5" r="5.5"/>
                                <line x1="14.5" y1="14.5" x2="19" y2="19"/>
                                <path d="M8.5 8.5l4 4m0-4l-4 4"/>
                            </svg>
                        `,
            action: () => resetZoom(wrapper),
          },
        ];

        buttons.forEach(({ label, icon, action }) => {
          const button = document.createElement("button");
          button.setAttribute("aria-label", label);
          button.innerHTML = icon;
          button.addEventListener("click", action);
          controlButtons.appendChild(button);
        });

        return controlButtons;
      }

      /**
       * Creates a cleanup function for the preview container.
       * @param {HTMLElement} wrapper - The wrapper element.
       * @param {Set} animationFrames - Set of animation frame IDs.
       * @returns {Function} The cleanup function.
       */
      function createCleanupFunction(wrapper, animationFrames) {
        const listeners = new Set();

        return () => {
          // Cancel all animation frames
          animationFrames.forEach((id) => {
            cancelAnimationFrame(id);
            animationFrames.delete(id);
          });

          // Remove all event listeners
          listeners.forEach(({ element, type, handler }) => {
            element.removeEventListener(type, handler);
            listeners.delete({ element, type, handler });
          });

          // Remove fullscreen listener
          document.removeEventListener("fullscreenchange", () => {
            if (!document.fullscreenElement) {
              wrapper.classList.remove("fullscreen");
            }
          });

          // Clear any remaining timeouts or intervals
          const scripts = wrapper.getElementsByTagName("script");
          Array.from(scripts).forEach((script) => script.remove());
        };
      }

      /**
       * Toggles the preview visibility for a given code block.
       * @param {HTMLElement} codeBlock - The <code> element to toggle preview for.
       */
      function togglePreview(codeBlock) {
        try {
          const container = codeBlock.parentElement;
          const existingPreview = container.querySelector(".preview-container");

          if (existingPreview) {
            const wrapper =
              existingPreview.shadowRoot.querySelector(".wrapper");
            wrapper.classList.add("removing");

            if (existingPreview.cleanup) {
              existingPreview.cleanup();
            }

            wrapper.addEventListener(
              "animationend",
              () => {
                existingPreview.remove();
              },
              { once: true }
            );
          } else {
            const content = codeBlock.textContent;
            // Check if content is HTML
            if (
              content.trim().toLowerCase().startsWith("<!doctype html>") ||
              content.trim().toLowerCase().startsWith("<html")
            ) {
              console.log("[HTML Preview] Rendering HTML document");
            }

            const preview = createPreviewContainer(content);
            container.appendChild(preview);
          }
        } catch (error) {
          console.error("[HTML Preview] Toggle preview failed:", error);
        }
      }

      /**
       * Creates the preview button and appends it to the code block container.
       * @param {HTMLElement} codeBlock - The <code> element to create a preview button for.
       * @returns {HTMLElement} The created preview button.
       */
      function createPreviewButton(codeBlock) {
        const button = document.createElement("button");
        button.className = "preview-button";
        button.textContent = "Preview";
        button.style.cssText = `
                    position: absolute;
                    right: 10px;
                    top: 10px;
                    padding: 4px 8px;
                    background: #4f46e5;
                    color: white;
                    border: none;
                    border-radius: 4px;
                    cursor: pointer;
                    font-size: 12px;
                    z-index: 1000;
                `;

        button.addEventListener("click", () => togglePreview(codeBlock));
        return button;
      }

      /**
       * Initializes the preview tool by adding preview buttons to all code blocks.
       */
      function initialize() {
        try {
          const codeBlocks = document.querySelectorAll("pre code");
          codeBlocks.forEach((block) => {
            const container = block.parentElement;
            if (container && !container.querySelector(".preview-button")) {
              container.style.position = "relative";
              const button = createPreviewButton(block);
              container.appendChild(button);
            }
          });
        } catch (error) {
          console.error("[HTML Preview] Initialization failed:", error);
        }
      }

      // Initialize on DOMContentLoaded or immediately if already loaded
      if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", initialize);
      } else {
        initialize();
      }

      // Observe dynamic content changes to add preview buttons to newly added code blocks
      const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          if (mutation.addedNodes.length) {
            initialize();
          }
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
    } catch (error) {
      console.error(
        "[HTML Preview] Failed to initialize HTML Preview Tool:",
        error
      );
    }
  }

  // Start the main program
  initializePreviewTool().catch((error) => {
    console.error("[HTML Preview] Critical error in HTML Preview Tool:", error);
  });

  /**
   * Toggles fullscreen mode for the preview wrapper.
   * @param {HTMLElement} element - The wrapper element to toggle fullscreen for.
   */
  function toggleFullscreen(element) {
    element.classList.add("fullscreen-transition");

    if (!document.fullscreenElement) {
      // Add fullscreen class before requesting fullscreen
      element.classList.add("fullscreen");
      element.requestFullscreen().catch((err) => {
        console.error(
          `[HTML Preview] Error attempting to enable full-screen mode: ${err.message}`
        );
        element.classList.remove("fullscreen");
      });
    } else {
      document
        .exitFullscreen()
        .then(() => {
          element.classList.remove("fullscreen");
        })
        .catch((err) => {
          console.error(
            `[HTML Preview] Error attempting to exit full-screen mode: ${err.message}`
          );
        });
    }

    const fullscreenChangeHandler = () => {
      if (!document.fullscreenElement) {
        element.classList.remove("fullscreen");
      }
      element.classList.remove("fullscreen-transition");
    };

    document.addEventListener("fullscreenchange", fullscreenChangeHandler, {
      once: true,
    });
  }

  /**
   * Zooms the preview content in or out.
   * @param {HTMLElement} element - The wrapper element to zoom.
   * @param {number} scaleFactor - The factor by which to scale the content.
   */
  function zoomContent(element, scaleFactor) {
    const currentScale = element.getAttribute("data-scale")
      ? parseFloat(element.getAttribute("data-scale"))
      : 1;
    const newScale = currentScale * scaleFactor;

    element.classList.add("zoom-transition");
    element.style.transform = `scale(${newScale})`;
    element.style.transformOrigin = "0 0";
    element.setAttribute("data-scale", newScale);

    element.addEventListener(
      "transitionend",
      () => {
        element.classList.remove("zoom-transition");
      },
      { once: true }
    );
  }

  /**
   * Handles errors by logging them and optionally displaying a message.
   * @param {Error} error - The error object.
   * @param {string} context - The context in which the error occurred.
   * @returns {string} The error message.
   */
  function handleError(error, context) {
    console.error(`[HTML Preview] ${context}:`, error);
    return `Error: ${context}. Please check console for details.`;
  }

  /**
   * Creates an error message element.
   * @param {string} message - The error message to display.
   * @returns {HTMLElement} The error message element.
   */
  function createErrorElement(message) {
    const errorContainer = document.createElement("div");
    errorContainer.style.cssText = `
            padding: 1rem;
            background-color: #fee2e2;
            border: 1px solid #ef4444;
            border-radius: 0.375rem;
            color: #991b1b;
            animation: fadeIn 0.3s ease-out;
        `;
    errorContainer.textContent = `Error: ${message}`;
    return errorContainer;
  }

  // Add new helper function for resetting zoom
  function resetZoom(element) {
    element.classList.add("zoom-transition");
    element.style.transform = "scale(1)";
    element.setAttribute("data-scale", "1");

    element.addEventListener(
      "transitionend",
      () => {
        element.classList.remove("zoom-transition");
      },
      { once: true }
    );
  }

  /**
   * Adds resize capability to the wrapper element.
   * @param {HTMLElement} wrapper - The wrapper element to make resizable.
   */
  function addResizeCapability(wrapper) {
    // Create resize handle
    const resizeHandle = document.createElement("div");
    resizeHandle.className = "resize-handle";
    resizeHandle.innerHTML = `
            <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
                <path d="M22 22L12 12M22 12L12 22"/>
            </svg>
        `;

    // Add resize functionality
    let isResizing = false;
    let startHeight;
    let startWidth;
    let startX;
    let startY;

    const handleMouseDown = (e) => {
      isResizing = true;
      startHeight = wrapper.offsetHeight;
      startWidth = wrapper.offsetWidth;
      startX = e.clientX;
      startY = e.clientY;

      document.addEventListener("mousemove", handleMouseMove);
      document.addEventListener("mouseup", handleMouseUp);
    };

    const handleMouseMove = (e) => {
      if (!isResizing) return;

      const deltaX = e.clientX - startX;
      const deltaY = e.clientY - startY;

      const newWidth = Math.max(200, startWidth + deltaX);
      const newHeight = Math.max(200, startHeight + deltaY);

      wrapper.style.width = `${newWidth}px`;
      wrapper.style.height = `${newHeight}px`;
    };

    const handleMouseUp = () => {
      isResizing = false;
      document.removeEventListener("mousemove", handleMouseMove);
      document.removeEventListener("mouseup", handleMouseUp);
    };

    resizeHandle.addEventListener("mousedown", handleMouseDown);
    wrapper.appendChild(resizeHandle);
  }

  /**
   * Creates a loading indicator element.
   * @returns {HTMLElement} The loading indicator element.
   */
  function createLoadingIndicator() {
    const loadingIndicator = document.createElement("div");
    loadingIndicator.className = "loading-indicator";
    loadingIndicator.innerHTML = `
            <div class="spinner"></div>
            <span>Loading preview...</span>
        `;
    return loadingIndicator;
  }
})();