HTML Preview Tool

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

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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;
  }
})();