Skip to main content
ArcBlock Community

Hero section-01 2.0.0 update is live

JustHODL
Developers
buildersdiscussion

Hero section 2.0.0 update is now live!

Now you will be able to: -Edit text on the screen -Customize font style for the subtitle -Add links and rich text format to text!

As you all know I'm not a dev and I was able to do this by playing with stuff and asking chat GPT. Check it out here: Hero Section-01

Leave a comment if you would like to learn to build something similar using chat GPT and I will commit to create a tutorial so you all can start building similar components!

Full script in case you want to use it/improve it for your own blocklet:

javascript
import React, { useState, useEffect, useRef } from '@blocklet/pages-kit/builtin/react';
import { Box } from '@blocklet/pages-kit/builtin/mui/material';

/**
 * OPTIONAL: If you need to parse a Google Fonts URL that includes italics and weight,
 * keep this. If not, you can remove it.
 */
function parseGoogleFontUrl(subtitleFontUrl) {
  try {
    const urlObj = new URL(subtitleFontUrl);
    const familyParam = urlObj.searchParams.get('family');
    if (!familyParam) {
      return { family: '', fontStyle: 'normal', fontWeight: 'normal' };
    }

    const familyNameAndParams = familyParam.split(':');
    const rawFamilyName = familyNameAndParams[0].replace(/\+/g, ' ');
    let fontStyle = 'normal';
    let fontWeight = 'normal';

    if (familyNameAndParams[1]) {
      const [styles, values] = familyNameAndParams[1].split('@');
      if (styles && values) {
        const stylesArr = styles.split(',');
        const valsArr = values.split(',');

        const italIndex = stylesArr.indexOf('ital');
        if (italIndex !== -1 && valsArr[italIndex] === '1') {
          fontStyle = 'italic';
        }

        const wghtIndex = stylesArr.indexOf('wght');
        if (wghtIndex !== -1 && valsArr[wghtIndex]) {
          fontWeight = valsArr[wghtIndex];
        }
      }
    }

    return { family: rawFamilyName, fontStyle, fontWeight };
  } catch (err) {
    console.warn('Invalid subtitleFontUrl:', subtitleFontUrl, err);
    return { family: '', fontStyle: 'normal', fontWeight: 'normal' };
  }
}

function execCommand(command, savedSelectionRef, value = null) {
  const sel = window.getSelection();
  if (savedSelectionRef.current && sel) {
    sel.removeAllRanges();
    sel.addRange(savedSelectionRef.current);
  }
  document.execCommand(command, false, value);
}

export default function VideoHeroSection({
  className,
  STYLES = 'Standard', // 'Fixed' or 'Standard'
  editingState = false,
  initialTitle = 'YOU MADE IT HERE. NOW...',
  initialSubtitle = '#JustHODL',
  initialDescription = "Crafting a premium & curated selection of crypto tools and services. Don't miss out!",
  initialButtonText = 'Shop now',
  buttonLink = '#',
  videoSrc,
  textColor = '#FFFFFF',
  buttonBackgroundColor = '#FFFFFF',
  buttonTextColor = '#000000',
  subtitleFontUrl,
  storageKey = 'videoHeroData' // Key for localStorage
}) {
  // Editable states (store HTML for all, so partial formatting is preserved)
  const [title, setTitle] = useState(initialTitle);
  const [subtitle, setSubtitle] = useState(initialSubtitle);
  const [description, setDescription] = useState(initialDescription);
  const [buttonText, setButtonText] = useState(initialButtonText);

  // Right-click menu states
  const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
  const [showMenu, setShowMenu] = useState(false);

  // Submenu
  const [subMenuPosition, setSubMenuPosition] = useState({ x: 220, y: 0 });
  const subMenuRef = useRef(null);

  // Link pop-up
  const [showLinkInput, setShowLinkInput] = useState(false);
  const [linkInputValue, setLinkInputValue] = useState('');
  const [isEditingLink, setIsEditingLink] = useState(false);

  // For saving/restoring text selection
  const savedSelectionRef = useRef(null);

  // Refs to editable fields
  const editableRefs = {
    title: useRef(null),
    subtitle: useRef(null),
    description: useRef(null),
    buttonText: useRef(null),
  };

  // 3-second close timer reference (which is actually 300ms)
  const closeTimerRef = useRef(null);

  // Booleans to track if user’s mouse is in the menu or the link popup
  const [isHoveringMenu, setIsHoveringMenu] = useState(false);
  const [isHoveringPopup, setIsHoveringPopup] = useState(false);

  // Determine final video URL
  const videoUrl =
    typeof videoSrc === 'object' && videoSrc !== null && 'url' in videoSrc
      ? videoSrc.url
      : videoSrc;

  // Parse Google Fonts URL
  const { family, fontStyle, fontWeight } = parseGoogleFontUrl(subtitleFontUrl || '');

  /**
   * Dynamically load the Google Fonts link (if needed)
   */
  useEffect(() => {
    if (subtitleFontUrl) {
      const link = document.createElement('link');
      link.href = subtitleFontUrl;
      link.rel = 'stylesheet';
      document.head.appendChild(link);

      // cleanup function to remove link if unmounted
      return () => {
        document.head.removeChild(link);
      };
    }
  }, [subtitleFontUrl]);

  /**
   * Load data from localStorage on mount
   */
  useEffect(() => {
    const savedData = JSON.parse(localStorage.getItem(storageKey));
    if (savedData) {
      setTitle(savedData.title || initialTitle);
      setSubtitle(savedData.subtitle || initialSubtitle);
      setDescription(savedData.description || initialDescription);
      setButtonText(savedData.buttonText || initialButtonText);
    }
  }, [storageKey, initialTitle, initialSubtitle, initialDescription, initialButtonText]);

  /**
   * Save data to localStorage whenever any of these states change.
   * This ensures persistence even if user refreshes the page mid-edit.
   */
  useEffect(() => {
    const payload = {
      title,
      subtitle,
      description,
      buttonText,
    };
    localStorage.setItem(storageKey, JSON.stringify(payload));
    // eslint-disable-next-line
  }, [title, subtitle, description, buttonText, storageKey]);

  // Update state from refs on blur to ensure we keep changes in real-time
  const handleBlur = (field) => {
    const ref = editableRefs[field];
    if (ref.current) {
      const html = ref.current.innerHTML;
      switch (field) {
        case 'title':
          setTitle(html);
          break;
        case 'subtitle':
          setSubtitle(html);
          break;
        case 'description':
          setDescription(html);
          break;
        case 'buttonText':
          setButtonText(html);
          break;
        default:
          break;
      }
    }
  };

  // ----------------- Selection Helpers -----------------
  const saveSelection = () => {
    const sel = window.getSelection();
    if (sel && sel.rangeCount > 0) {
      savedSelectionRef.current = sel.getRangeAt(0);
    }
  };

  // ----------------- Close Timer Helpers -----------------
  const clearCloseTimer = () => {
    if (closeTimerRef.current) {
      clearTimeout(closeTimerRef.current);
      closeTimerRef.current = null;
    }
  };

  const startCloseTimer = () => {
    if (!closeTimerRef.current) {
      closeTimerRef.current = setTimeout(() => {
        setShowMenu(false);
        setShowLinkInput(false);
        closeTimerRef.current = null; // cleanup
      }, 300); // 300ms
    }
  };

  // Because we want to make sure if we open the menu again, we reset the timer
  const openMenu = (x, y) => {
    clearCloseTimer();
    setMenuPosition({ x, y });
    setShowMenu(true);
  };

  const openLinkInput = (isEditing, defaultValue = '') => {
    clearCloseTimer();
    setIsEditingLink(isEditing);
    setLinkInputValue(defaultValue);
    setShowLinkInput(true);
  };

  // ----------------- Right-Click Menu -----------------
  const handleRightClick = (e) => {
    e.preventDefault();
    setShowLinkInput(false);

    const { clientX, clientY } = e;
    const viewportWidth = window.innerWidth;
    const containerHeight = 522;

    const menuWidth = 220;
    const menuHeight = 300;

    let newX = clientX;
    let newY = clientY;

    // Flip left if not enough space on right
    if (clientX + menuWidth > viewportWidth) {
      newX = clientX - menuWidth;
      if (newX < 0) {
        newX = 0;
      }
    }

    // Flip above if not enough space below
    if (clientY + menuHeight > containerHeight) {
      newY = clientY - menuHeight;
      if (newY < 0) {
        newY = 0;
      }
    }

    saveSelection();
    openMenu(newX, newY);
  };

  // Submenu positioning (we still do this for flipping to the left/above)
  const handleSubMenuHover = () => {
    if (!subMenuRef.current) return;

    const viewportWidth = window.innerWidth;
    const containerHeight = 522;
    const submenuWidth = subMenuRef.current.offsetWidth || 160;
    const submenuHeight = subMenuRef.current.offsetHeight || 120;

    let x = 220;
    let y = 0;

    // Flip left if not enough space on right
    if (menuPosition.x + x + submenuWidth > viewportWidth) {
      x = -submenuWidth;
      if (menuPosition.x + x < 0) {
        x = -menuPosition.x;
      }
    }

    // Flip above if not enough space below
    if (menuPosition.y + y + submenuHeight > containerHeight) {
      y = -(submenuHeight - 10);
      if (menuPosition.y + y < 0) {
        y = -menuPosition.y + 10;
      }
    }

    setSubMenuPosition({ x, y });
  };

  // ----------------- Link Editing -----------------
  const handleAddLinkClick = () => {
    openLinkInput(false, '');
  };

  const handleEditLinkClick = () => {
    const sel = window.getSelection();
    if (!sel || sel.rangeCount < 1) return;

    const range = sel.getRangeAt(0);
    let linkNode = range.commonAncestorContainer;
    if (linkNode.nodeType === Node.TEXT_NODE) {
      linkNode = linkNode.parentNode;
    }
    while (linkNode && linkNode.nodeName !== 'A') {
      linkNode = linkNode.parentNode;
    }

    let linkHref = '';
    if (linkNode && linkNode.nodeName === 'A') {
      linkHref = linkNode.getAttribute('href') || '';
    }

    openLinkInput(true, linkHref);
  };

  /**
   * --------------- THE ONLY CHANGE: we prepend "https://" if missing ---------------
   */
  const applyLink = () => {
    if (isEditingLink) {
      execCommand('unlink', savedSelectionRef);
    }

    let linkValue = linkInputValue.trim();
    // If no protocol, prepend "https://"
    if (
      linkValue &&
      !linkValue.includes('://') &&
      !linkValue.startsWith('#') &&
      !linkValue.startsWith('mailto:')
    ) {
      linkValue = 'https://' + linkValue;
    }

    if (linkValue) {
      execCommand('createLink', savedSelectionRef, linkValue);
      execCommand('underline', savedSelectionRef);
    }

    setShowLinkInput(false);
    setShowMenu(false);
  };

  const removeLink = () => {
    execCommand('unlink', savedSelectionRef);
    setShowMenu(false);
  };

  // ----------------- Formatting Logic -----------------
  const applyFormatting = (command) => {
    execCommand(command, savedSelectionRef);
    setShowMenu(false);
  };

  const removeFormatting = () => {
    execCommand('removeFormat', savedSelectionRef);
    setShowMenu(false);
  };

  const copyText = () => {
    const selection = window.getSelection();
    if (selection && selection.rangeCount > 0) {
      const text = selection.toString();
      navigator.clipboard.writeText(text).then(() => alert('Text copied!'));
    }
    setShowMenu(false);
  };

  const pasteAsPlainText = async () => {
    const text = await navigator.clipboard.readText();
    if (!text) return;
    const sel = window.getSelection();
    if (savedSelectionRef.current && sel) {
      sel.removeAllRanges();
      sel.addRange(savedSelectionRef.current);

      const range = sel.getRangeAt(0);
      range.deleteContents();
      range.insertNode(document.createTextNode(text));
    }
    setShowMenu(false);
  };

  // ----------------- Mouse Enter/Leave for Main Menu + Popup -----------------
  const handleMenuMouseEnter = () => {
    setIsHoveringMenu(true);
    clearCloseTimer();
  };

  const handleMenuMouseLeave = () => {
    setIsHoveringMenu(false);
    if (!isHoveringPopup) {
      startCloseTimer();
    }
  };

  const handlePopupMouseEnter = () => {
    setIsHoveringPopup(true);
    clearCloseTimer();
  };

  const handlePopupMouseLeave = () => {
    setIsHoveringPopup(false);
    if (!isHoveringMenu) {
      startCloseTimer();
    }
  };

  // ----------------- Click Outside to Close Immediately -----------------
  const handleClickOutside = (event) => {
    const clickedInsideEditable = Object.values(editableRefs).some((ref) =>
      ref.current?.contains(event.target)
    );

    const menuEl = document.getElementById('custom-context-menu');
    const linkInputEl = document.getElementById('custom-link-input');

    if (
      (menuEl && menuEl.contains(event.target)) ||
      (linkInputEl && linkInputEl.contains(event.target))
    ) {
      return;
    }

    if (!clickedInsideEditable) {
      setShowMenu(false);
      setShowLinkInput(false);
      clearCloseTimer();
    }
  };

  useEffect(() => {
    document.addEventListener('click', handleClickOutside);
    return () => document.removeEventListener('click', handleClickOutside);
  }, []);

  // --- The logic that positions the link popup within the viewport ---
  const computeLinkPopupPosition = () => {
    const xBase = menuPosition.x;
    const yBase = menuPosition.y;

    let finalLeft = xBase + 230;
    let finalTop = yBase + 10;

    const popupWidth = 240;
    const popupHeight = 130;

    const viewportWidth = window.innerWidth;
    const viewportHeight = window.innerHeight;

    if (finalLeft + popupWidth > viewportWidth) {
      finalLeft = xBase - popupWidth;
      if (finalLeft < 0) finalLeft = 0;
    }
    if (finalTop + popupHeight > viewportHeight) {
      finalTop = yBase - popupHeight;
      if (finalTop < 0) finalTop = 0;
    }
    return { top: finalTop, left: finalLeft };
  };

  const { top: linkInputTop, left: linkInputLeft } = computeLinkPopupPosition();

  return (
    <>
      {/* Show green dashed outline on focused contentEditable */}
      <style>
        {`
          [contentEditable="true"]:focus {
            outline: 3px dashed #00ff00;
            outline-offset: 6px;
          }
          /* Ensure normal cursor by default */
          .hero-container {
            cursor: auto;
          }
          /* Show pointer on actual links only: */
          .hero-container a[href] {
            cursor: pointer;
          }
        `}
      </style>

      <Box
        className={`\n          ${className}\n          hero-container\n          relative w-full min-h-[500px] md:min-h-[522px]\n          overflow-hidden py-[80px] md:py-[80px] flex items-center\n          ${STYLES === 'Fixed' ? 'fixed' : 'relative'}\n        `}
        style={{
          zIndex: STYLES === 'Fixed' ? -10 : 'auto',
        }}
      >
        {/* Video layer */}
        {videoUrl ? (
          <video
            className={`\n              top-0 left-0 w-full h-full object-cover\n              ${STYLES === 'Fixed' ? 'fixed' : 'absolute'}\n            `}
            style={{
              pointerEvents: 'none',
              zIndex: STYLES === 'Fixed' ? -10 : 'auto',
            }}
            src={videoUrl}
            autoPlay
            loop
            playsInline
            muted
          />
        ) : (
          <div
            className={`\n              top-0 left-0 w-full h-full bg-gray-800\n              ${STYLES === 'Fixed' ? 'fixed' : 'absolute'}\n            `}
            style={{
              pointerEvents: 'none',
              zIndex: STYLES === 'Fixed' ? -10 : 'auto',
            }}
          />
        )}

        {/* Overlay */}
        <div
          className="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10"
          style={{ zIndex: 'auto', pointerEvents: 'none' }}
        />

        {/* Content */}
        <div
          className="relative z-10 flex flex-col items-center justify-center h-full text-center max-w-[1140px] mx-auto"
          style={{ color: textColor }}
        >
          {/* Title */}
          {editingState ? (
            <h2
              ref={editableRefs.title}
              contentEditable
              suppressContentEditableWarning
              className="text-base md:text-xl font-light tracking-wide mb-4 md:mb-8 lg:mb-10"
              onBlur={() => handleBlur('title')}
              onContextMenu={handleRightClick}
              dangerouslySetInnerHTML={{ __html: title }}
            />
          ) : (
            <h2
              className="text-base md:text-xl font-light tracking-wide mb-4 md:mb-8 lg:mb-10"
              dangerouslySetInnerHTML={{ __html: title }}
            />
          )}

          {/* Subtitle */}
          {editingState ? (
            <h1
              ref={editableRefs.subtitle}
              contentEditable
              suppressContentEditableWarning
              className="text-6xl md:text-8xl lg:text-9xl mb-6 md:mb-8 lg:mb-8"
              onBlur={() => handleBlur('subtitle')}
              onContextMenu={handleRightClick}
              style={{
                fontFamily: family ? `${family}, sans-serif` : undefined,
                fontStyle,
                fontWeight,
              }}
              dangerouslySetInnerHTML={{ __html: subtitle }}
            />
          ) : (
            <h1
              className="text-6xl md:text-8xl lg:text-9xl mb-6 md:mb-8 lg:mb-8"
              style={{
                fontFamily: family ? `${family}, sans-serif` : undefined,
                fontStyle,
                fontWeight,
              }}
              dangerouslySetInnerHTML={{ __html: subtitle }}
            />
          )}

          {/* Description */}
          {editingState ? (
            <div
              ref={editableRefs.description}
              contentEditable
              suppressContentEditableWarning
              className="text-base md:text-base lg:text-lg mb-5 md:mb-10 px-4 md:px-4"
              onBlur={() => handleBlur('description')}
              onContextMenu={handleRightClick}
              dangerouslySetInnerHTML={{ __html: description }}
            />
          ) : (
            <div
              className="text-base md:text-base lg:text-lg mb-5 md:mb-10 px-4 md:px-4"
              dangerouslySetInnerHTML={{ __html: description }}
            />
          )}

          {/* Button */}
          <a
            href={editingState ? undefined : buttonLink}
            target={editingState ? undefined : '_blank'}
            rel={editingState ? undefined : 'noopener noreferrer'}
          >
            <button
              className="font-bold py-2 px-4 md:px-6 rounded hover:bg-gray-200 transition duration-300 text-sm md:text-base"
              style={{
                backgroundColor: buttonBackgroundColor,
                color: buttonTextColor,
              }}
              disabled={editingState}
            >
              {editingState ? (
                <span
                  ref={editableRefs.buttonText}
                  contentEditable
                  suppressContentEditableWarning
                  onBlur={() => handleBlur('buttonText')}
                  onContextMenu={handleRightClick}
                  dangerouslySetInnerHTML={{ __html: buttonText }}
                  style={{ outline: 'none', border: 'none', display: 'inline-block' }}
                />
              ) : (
                <span
                  dangerouslySetInnerHTML={{ __html: buttonText }}
                  style={{ display: 'inline-block' }}
                />
              )}
            </button>
          </a>
        </div>
      </Box>

      {/* ---------- Right-Click Menu ---------- */}
      {showMenu && (
        <div
          id="custom-context-menu"
          className="absolute bg-white border border-gray-300 rounded shadow-lg"
          style={{
            top: menuPosition.y,
            left: menuPosition.x,
            zIndex: 50,
            width: '220px',
          }}
          onMouseEnter={() => {
            setIsHoveringMenu(true);
            clearCloseTimer();
          }}
          onMouseLeave={() => {
            setIsHoveringMenu(false);
            if (!isHoveringPopup) {
              startCloseTimer();
            }
          }}
        >
          <div className="relative group">
            <button
              className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left flex justify-between"
              onMouseEnter={handleSubMenuHover}
            >
              Font Style <span className="ml-auto">&raquo;</span>
            </button>
            <div
              ref={subMenuRef}
              className="absolute hidden group-hover:block bg-white border border-gray-300 rounded shadow-lg w-40 z-50"
              style={{
                top: subMenuPosition.y,
                left: subMenuPosition.x,
              }}
            >
              <button
                className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
                onClick={() => applyFormatting('bold')}
              >
                Bold
              </button>
              <button
                className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
                onClick={() => applyFormatting('italic')}
              >
                Italic
              </button>
              <button
                className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
                onClick={() => applyFormatting('underline')}
              >
                Underline
              </button>
            </div>
          </div>

          <hr className="my-1" />
          <button
            className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
            onClick={removeFormatting}
          >
            Remove Formatting
          </button>

          <hr className="my-1" />
          <button
            className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
            onClick={handleAddLinkClick}
          >
            Add Link
          </button>
          <button
            className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
            onClick={handleEditLinkClick}
          >
            Edit Link
          </button>
          <button
            className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
            onClick={removeLink}
          >
            Remove Link
          </button>

          <hr className="my-1" />
          <button
            className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
            onClick={copyText}
          >
            Copy
          </button>
          <button
            className="block w-full px-4 py-2 text-sm text-black hover:bg-gray-200 text-left"
            onClick={pasteAsPlainText}
          >
            Paste as Plain Text
          </button>
        </div>
      )}

      {/* ---------- Custom Link Input Popup ---------- */}
      {showLinkInput && (
        <div
          id="custom-link-input"
          className="absolute bg-white border border-gray-300 rounded shadow-lg p-3"
          style={{
            top: linkInputTop,
            left: linkInputLeft,
            zIndex: 60,
            width: '240px',
          }}
          onMouseEnter={() => {
            setIsHoveringPopup(true);
            clearCloseTimer();
          }}
          onMouseLeave={() => {
            setIsHoveringPopup(false);
            if (!isHoveringMenu) {
              startCloseTimer();
            }
          }}
        >
          <label className="block text-sm font-semibold mb-2">Enter Link URL:</label>
          <input
            type="text"
            className="w-full border px-2 py-1 mb-2"
            value={linkInputValue}
            onChange={(e) => setLinkInputValue(e.target.value)}
          />
          <div className="flex space-x-2">
            <button
              className="bg-blue-500 text-white px-3 py-1 rounded text-sm"
              onClick={applyLink}
            >
              {isEditingLink ? 'Update Link' : 'Add Link'}
            </button>
            <button
              className="bg-gray-300 px-3 py-1 rounded text-sm"
              onClick={() => setShowLinkInput(false)}
            >
              Cancel
            </button>
          </div>
        </div>
      )}
    </>
  );
}
Reply