lexical icon indicating copy to clipboard operation
lexical copied to clipboard

Feature: Is there a plugin that implements pagination similar to Google Docs?

Open pangguoqing opened this issue 1 year ago • 7 comments

Description

Is there a plugin that implements pagination similar to Google Docs?

image

I want to implement automatic pagination similar to Word and export the document as a Word file.

Impact

Does anyone have an implementation idea or an existing demo? I can try to implement it myself?

pangguoqing avatar Aug 16 '24 05:08 pangguoqing

There isn't one, but in the playground, there is 'page break' widget that makes it print per page. If you customize that plugin CSS to look like two split pages and somehow anchor it to a certain height to refer to A4 size (that's the hard part), might get you closer to what you are after

ivailop7 avatar Aug 16 '24 07:08 ivailop7

Alternatively easier solution would be an editor per page and just concat those editors' contents

ivailop7 avatar Aug 16 '24 07:08 ivailop7

Thank you very much for your reply. I am familiar with 'page break.' However, I am looking for more advanced features, such as the ability to set page size, headers, and footers. When a document spans multiple pages, I would like the overflow content to automatically move to the next page when the user inputs content at the beginning.

pangguoqing avatar Aug 16 '24 08:08 pangguoqing

Yeah, nothing out of the box for this.

ivailop7 avatar Aug 16 '24 20:08 ivailop7

I did some investing on the way to do it. First conclusion, is that we should not affect the text contents itself, which is the case for the page break solution.

Starting for this, I noticed the following approach to split:

  1. We can calculate the height of the elements and split them.
  2. We identify the element/paragraph that overflows the page. Once identified, we try to split by words, untill we have a sequence of strings fiting the page.
  3. We split the paragraph in 2.
  4. In between, we add a page break

Of course, this approch changes the HTML scructure and thus we need to do post processing of the lexical rendering.

is it possible to ovewrite the exportDOM of the rootnode?

I would need to get the exportedDOM for the whole document, traverse the HTML elements to calcule sizes, and split the HTML elements in correct points, and return it. It should not affect the editor state

ebengtso avatar Oct 23 '24 10:10 ebengtso

No, RootNode can't be overridden. I'm not sure exportDOM would be the right solution here because that is only for export, not for rendering in the editor. You definitely won't be able to do it with createDOM/updateDOM because lexical requires a strict 1:1 lexical node to dom element mapping, you won't be able to split the dom without also splitting the nodes.

etrepum avatar Oct 23 '24 13:10 etrepum

Ok, to automatically break and adjust paragraphs up or down the pages, we need to change the DOM, then the solution I proposed won't work.

A less powerfull solution is then to automatically add/remove page breaks in between paragraphs once the paragraph overflow the page size. This would leave empty spaces at the bottom of the page, because we will not split the paragraphs by words to occupy the full page space. Changing the paragraph nodes just for layout, for me, would be a misconception

ebengtso avatar Oct 23 '24 14:10 ebengtso

import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useEffect } from 'react'; import { $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_LOW, $isParagraphNode, } from 'lexical';

export function SelectionHighlightPlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { const removeHighlightCommand = editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { editor.update(() => { const all = document.querySelectorAll('.lexical-highlight'); all.forEach((el) => el.classList.remove('lexical-highlight'));

      const selection = $getSelection();
      if (
        !$isRangeSelection(selection) ||
        selection.isCollapsed() ||
        selection.getTextContent().length === 0
      ) {
        return;
      }

      const selectedNodes = selection.getNodes();
      selectedNodes.forEach((node) => {
        if ($isParagraphNode(node) && node.getChildren().length === 0) {
          const dom = editor.getElementByKey(node.getKey());
          if (dom) {
            dom.classList.add('lexical-highlight');
          }
        }
      });
    });
    return false;
  },
  COMMAND_PRIORITY_LOW
);

const editable: any = document.querySelector('.content-editable-grid');
const blockHeight = 1123;
const colors = [''];

if (!editable) return;

function assignBackgroundsAndGaps() {
  const children = Array.from(editable.children);
  if (children.length === 0) return;

  children.forEach((child: any) => {
    child.classList.remove('block-end');
    child.style.minHeight = '0px';
  });

  children.forEach((child: any) => {
    if(child && child.tagName.toLowerCase() !== 'figure'){
      child.classList.add('padding-elem');

    }
  })


  const blocks = new Map();
  let cumulativeHeight = 0;

  children.forEach((child: any) => {
    const childHeight = child.offsetHeight;
    const blockIndex = Math.floor(cumulativeHeight / blockHeight);
    const color = colors[blockIndex % colors.length];
    child.style.backgroundColor = color;

    if (!blocks.has(blockIndex)) {
      blocks.set(blockIndex, []);
    }
    blocks.get(blockIndex).push(child);
    cumulativeHeight += childHeight;
  });

  blocks.forEach((divs) => {
    const totalHeight = divs.reduce((sum: any, div: any) => sum + div.offsetHeight, 0);
    const deficit = blockHeight - totalHeight;
    const lastDiv = divs[divs.length - 1];

    if (lastDiv) {
      lastDiv.classList.add('block-end');
      if(lastDiv && lastDiv.tagName.toLowerCase() !== 'figure'){
        lastDiv.classList.add('padding-elem')
      }
      if (deficit > 0) {
        lastDiv.style.minHeight = (lastDiv.offsetHeight + deficit) + 'px';

      }
    }
  });
}

// const handleKeyDown = (e: KeyboardEvent) => { // if (e.key === 'Enter') {

// const el :any= document.querySelector('.block-end') // if( el && el.offsetHeight < 1015){ // el.scrollIntoView({ // behavior: 'smooth', // block: 'center', // inline: 'center' // }); // window.scrollTo({ // top: 0, // or any fixed position // behavior: 'auto' // }); // } // // } // const selection = window.getSelection(); // if (!selection?.rangeCount) return;

// const range = selection.getRangeAt(0); // const newDiv = document.createElement('div'); // newDiv.innerHTML = '
';

// let currentDiv = range.startContainer as HTMLElement; // while (currentDiv && currentDiv !== editable && currentDiv.nodeName !== 'DIV') { // currentDiv = currentDiv.parentNode as HTMLElement; // }

// if (currentDiv && currentDiv !== editable) { // if (currentDiv.nextSibling) { // editable.insertBefore(newDiv, currentDiv.nextSibling); // } else { // editable.appendChild(newDiv); // }

// const newRange = document.createRange(); // newRange.setStart(newDiv, 0); // newRange.collapse(true); // selection.removeAllRanges(); // selection.addRange(newRange); // } else { // editable.appendChild(newDiv); // }

// assignBackgroundsAndGaps(); // } // };

// editable.addEventListener('keydown', handleKeyDown);
editable.addEventListener('input', assignBackgroundsAndGaps);
const observer = new MutationObserver(assignBackgroundsAndGaps);
observer.observe(editable, { childList: true, subtree: true });

assignBackgroundsAndGaps();

document.addEventListener('paste', function () {

  const children = Array.from(editable.children);
  children.forEach((child: any) => {
    if(child && child.tagName.toLowerCase() !== 'figure'){
      child.classList.add('padding-elem');
    }
  })

  const tableCells = document.querySelectorAll('td');
  tableCells.forEach(cell => {
    cell.classList.add('pasted-tables');
  });

  const contentMatches = document.querySelectorAll(".block-end");
  const pageBreaks = document.querySelectorAll('figure[type="page-break"]');
  const combined = Array.from(contentMatches).concat(Array.from(pageBreaks));
  if (combined.length === 0) return;
  const total = combined.length;
  combined.forEach((el, index) => {
    el.setAttribute('data-index', String(index + 1));
    el.setAttribute('data-total', String(total));
  });
});

return () => {
  removeHighlightCommand();
  // editable.removeEventListener('keydown', handleKeyDown);
  editable.removeEventListener('input', assignBackgroundsAndGaps);
  observer.disconnect();
};

}, [editor]);

return null; }

this will server the purpose

include in your Lexicalcomposer <SelectionHighlightPlugin/>

rajiv-codoxy avatar Jun 18 '25 11:06 rajiv-codoxy