Example: Split Text Lines

This plugin takes a text element, detects its visual line breaks (based on how the text actually wraps in the browser), and splits it into individual text nodes — one per line. This is useful for applying per-line animations, staggered effects, or independent styling to each line of text.

What it does

  1. User selects a text element
  2. The plugin reads the rendered DOM to detect where lines visually break
  3. A wrapper frame replaces the original text element
  4. Each visual line becomes its own text node inside the wrapper
  5. All typography styles are preserved on each line

Full source

import { definePlugin } from '@revyme/plugin-sdk';
 
// Detect visual lines from rendered DOM using the Range API
function getVisualLines(el: HTMLElement): string[] {
  const textNodes: Text[] = [];
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
  let node: Text | null;
  while ((node = walker.nextNode() as Text | null)) {
    if (node.textContent?.trim()) textNodes.push(node);
  }
 
  if (textNodes.length === 0) return [];
 
  const lines: string[] = [];
  let currentLine = '';
  let currentY = -Infinity;
 
  for (const textNode of textNodes) {
    for (let i = 0; i < textNode.length; i++) {
      const range = document.createRange();
      range.setStart(textNode, i);
      range.setEnd(textNode, i + 1);
      const rect = range.getBoundingClientRect();
 
      if (rect.height === 0) continue;
 
      // New line when Y position jumps by more than 2px
      if (Math.abs(rect.top - currentY) > 2 && currentLine) {
        lines.push(currentLine.trim());
        currentLine = '';
      }
 
      currentY = rect.top;
      currentLine += textNode.textContent![i];
    }
  }
 
  if (currentLine.trim()) {
    lines.push(currentLine.trim());
  }
 
  return lines;
}
 
export default definePlugin({
  name: 'Split Text Lines',
  icon: 'split',
  description: 'Split a text element into separate text nodes per visual line',
  submitText: 'Split Lines',
  controls: {},
  run(_values, sdk) {
    const selected = sdk.nodes.getSelected();
    const textNode = selected.find((n) => n.type === 'text');
    if (!textNode) return;
 
    // Find the actual DOM element
    const domEl = document.querySelector(
      `[data-node-id="${textNode.id}"]`
    ) as HTMLElement | null;
    if (!domEl) return;
 
    const detectedLines = getVisualLines(domEl);
    if (detectedLines.length <= 1) return;
 
    const parentId = textNode.parentId;
    if (!parentId) return;
 
    // Extract typography styles from original node
    const s = textNode.styles;
    const typoStyles: Record<string, any> = {};
    const typoKeys = [
      'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
      'lineHeight', 'letterSpacing', 'textAlign', 'textDecoration',
      'textTransform', 'color', 'whiteSpace',
    ];
    for (const key of typoKeys) {
      if (s[key] !== undefined) typoStyles[key] = s[key];
    }
 
    // Extract position styles
    const positionStyles: Record<string, any> = {};
    const posKeys = [
      'position', 'left', 'top', 'right', 'bottom',
      'flex', 'widthFill', 'heightFill', 'zIndex',
      'alignSelf', 'flexGrow', 'flexShrink', 'flexBasis',
    ];
    for (const key of posKeys) {
      if (s[key] !== undefined) positionStyles[key] = s[key];
    }
 
    // Get original position in parent
    const parent = sdk.nodes.getById(parentId);
    const originalIndex = parent
      ? parent.children.indexOf(textNode.id)
      : -1;
 
    // Get width from DOM
    const domRect = domEl.getBoundingClientRect();
 
    // Create wrapper frame
    const wrapperFrame = sdk.nodes.create('frame', parentId, {
      name: 'Split Text',
      styles: {
        ...positionStyles,
        display: 'flex',
        flexDirection: 'column',
        gap: '0px',
        width: s.widthFill === 'true'
          ? undefined
          : `${Math.round(domRect.width)}px`,
        height: 'auto',
        overflow: 'visible',
        backgroundColor: 'transparent',
      },
    });
 
    // Create a text node for each visual line
    for (const line of detectedLines) {
      const inlineStyle = [
        typoStyles.fontFamily && `font-family: ${typoStyles.fontFamily}, sans-serif`,
        typoStyles.fontSize && `font-size: ${typoStyles.fontSize}`,
        typoStyles.fontWeight && `font-weight: ${typoStyles.fontWeight}`,
        typoStyles.lineHeight && `line-height: ${typoStyles.lineHeight}`,
        typoStyles.textAlign && `text-align: ${typoStyles.textAlign}`,
      ].filter(Boolean).join('; ');
 
      const spanStyle = [
        typoStyles.color && `color: ${typoStyles.color}`,
        typoStyles.fontSize && `font-size: ${typoStyles.fontSize}`,
        typoStyles.fontWeight && `font-weight: ${typoStyles.fontWeight}`,
        typoStyles.fontFamily && `font-family: ${typoStyles.fontFamily}, sans-serif`,
      ].filter(Boolean).join('; ');
 
      const htmlText = `<p style="${inlineStyle}"><span style="${spanStyle}">${line}</span></p>`;
 
      sdk.nodes.create('text', wrapperFrame.id, {
        styles: {
          ...typoStyles,
          text: htmlText,
          position: 'relative',
          widthFill: 'true',
          flex: '1 0 0px',
          height: 'auto',
          heightFill: 'false',
          whiteSpace: 'nowrap',
        },
      });
    }
 
    // Move wrapper to original position in parent
    if (originalIndex >= 0) {
      sdk.nodes.reorder(parentId, wrapperFrame.id, originalIndex);
    }
 
    // Delete the original text node
    sdk.nodes.delete(textNode.id);
  },
});

How it works

Visual line detection

The getVisualLines function uses the browser's Range API to detect where text actually wraps. It walks every character in the text node, creates a Range for each character, and reads its bounding rect. When the Y position jumps by more than 2px, that's a new line.

This approach works regardless of font size, letter spacing, container width, or any other factor — it reads the actual rendered positions from the browser.

Style preservation

The plugin extracts two sets of styles from the original text element:

  • Typography styles — font family, size, weight, color, line height, letter spacing, alignment, decoration, transform
  • Position styles — position, left/top/right/bottom, flex properties, width/height fill, z-index

The wrapper frame inherits the position styles (so it takes the same spot in the layout), and each line node gets the typography styles.

HTML text format

Each line is created with inline HTML (<p> with <span> inside) that carries the typography as inline styles. This ensures the text renders correctly even before Revyme's style system applies the node styles.

Parent hierarchy

The plugin preserves the original element's position in the parent's children array using reorder. After creating the wrapper frame, it moves it to the exact index where the original text node was, then deletes the original.

Combining with stagger

Split Text Lines pairs well with the Stagger Animation plugin. First split the text into lines, then select the wrapper frame and apply a stagger animation. Each line will animate in with a delay, creating a line-by-line reveal effect.