portive / wysimark

The Wysiwyg Editor for Markdown: 100% CommonMark + GFM with Uploads and Image Resizing
Other
218 stars 40 forks source link

Ability to detach the toolbar #65

Open zareith opened 2 months ago

zareith commented 2 months ago

Hello, first of all, thank you for creating and open sourcing wysimark. It looks like this editor offers a great combination of both good end user experience and great DX.

Problem I'd like to be able to detach the toolbar from the editable area and move it to the top/bottom of the page.

Solution Perhaps the simplest solution would be to just allow a class to be added to the toolbar which can change its position to absolute. This works for my use case.

Some editors, like Quill, allow us to specify a toolbar container where it will render the toolbar. However, this may be harder to idiomatically support for different frameworks.

Use Case I'd like to use this in an app where we show multiple sections in a page, and some sections can become editable after the user clicks on an edit button. I'd like the toolbar to be present at the top of the page, rather than attached to the section so that the layout does not keep changing and transitioning a section from readonly to editable is as seamless as possible.

SaintPepsi commented 2 months ago

We're achieving this using a custom hook that updates when the user selection changes:

type UseTextSelectionReturn = {
    text: string;
    rects: DOMRect[];
    ranges: Range[];
    selection: Selection | null;
};

const getRangesFromSelection = (selection: Selection): Range[] => {
    const rangeCount = selection.rangeCount;
    return Array.from({ length: rangeCount }, (_, i) => selection.getRangeAt(i));
};

const isSelectionInsideRef = (selection: Selection, element: Element) => {
    if (!element || selection.rangeCount === 0) return true;

    const range = selection.getRangeAt(0);
    return element.contains(range.commonAncestorContainer);
};

export function useTextSelection(element?: Element): UseTextSelectionReturn {
    const [selection, setSelection] = useState<Selection | null>(null);
    const [text, setText] = useState('');
    const [ranges, setRanges] = useState<Range[]>([]);
    const [rects, setRects] = useState<DOMRect[]>([]);

    useEffect(() => {
        const onSelectionChange = () => {
            const newSelection = window.getSelection();

            if (newSelection && (!element || isSelectionInsideRef(newSelection, element))) {
                setSelection(newSelection);
                setText(newSelection.toString());
                const newRanges = getRangesFromSelection(newSelection);
                setRanges(newRanges);
                setRects(newRanges.map((range) => range.getBoundingClientRect()));
            } else {
                setText('');
                setRanges([]);
                setRects([]);
                setSelection(null);
            }
        };

        document.addEventListener('selectionchange', onSelectionChange);

        return () => {
            document.removeEventListener('selectionchange', onSelectionChange);
        };
    }, [element]);

    return {
        text,
        rects,
        ranges,
        selection
    };
}

interface UsePositionEditorElementParams {
    elementToCalculateCenterToRef: React.MutableRefObject<HTMLElement>;
    selectionElementRef: React.MutableRefObject<Element>;
    anchor?: 'bottom' | 'top';
    align?: 'left' | 'center';
    anchorOffset?: number;
}

const windowPadding = 5;

export function usePositionEditorElement({
    elementToCalculateCenterToRef,
    selectionElementRef,
    align = 'center',
    anchor = 'bottom',
    anchorOffset = 10
}: UsePositionEditorElementParams) {
    const { selection, ranges, rects } = useTextSelection(selectionElementRef);
    console.log('selection', selection);

    function positionEditorElement() {
        const editorElement = elementToCalculateCenterToRef?.current;

        if (editorElement && selection !== null /** && selectionElement.contains(nativeSelection.anchorNode) */ && rects.length) {
            const rangeRect = rects[0];

            const top = `${rangeRect.top + rangeRect.height}px`;
            const left = `${rangeRect.left}px`;

            editorElement.style.top = top;
            editorElement.style.left = left;

            const editorElementRect = editorElement?.getBoundingClientRect?.();

            const rangeWidthHalf = rangeRect.width / 2;

            const inputElementHalf = editorElementRect ? editorElementRect.width / 2 : editorElementRect.width / 2;
            const differenceX = rangeRect.left - editorElementRect.left + rangeWidthHalf - inputElementHalf;
            const differenceY = rangeRect.top - editorElementRect.top;

            if (anchor === 'bottom') {
                editorElement.style.top = `${_.round(parseFloat(editorElement.style.top) + differenceY + anchorOffset)}px`;
            } else if (anchor === 'top') {
                editorElement.style.top = `${_.round(parseFloat(editorElement.style.top) + differenceY - editorElementRect.height - anchorOffset)}px`;
            }

            if (align === 'center') {
                editorElement.style.left = `${_.round(parseFloat(editorElement.style.left) + differenceX)}px`;
            } else if (align === 'left') {
                editorElement.style.left = `${_.round(parseFloat(editorElement.style.left) + differenceX + inputElementHalf - rangeWidthHalf)}px`;
            }

            // Check Left and right bounding edge
            const editorElementBoundingRect = editorElement?.getBoundingClientRect?.();
            if (editorElementBoundingRect.left < windowPadding) {
                const missingSpace =
                    editorElementBoundingRect.left < 0
                        ? Math.abs(editorElementBoundingRect.left) + windowPadding
                        : windowPadding - editorElementBoundingRect.left;

                editorElement.style.left = `${_.round(parseFloat(editorElement.style.left) + missingSpace)}px`;
            }
            if (editorElementBoundingRect.right > window.innerWidth - windowPadding) {
                const missingSpace = editorElementBoundingRect.right - (window.innerWidth - windowPadding);
                editorElement.style.left = `${_.round(parseFloat(editorElement.style.left) - missingSpace)}px`;
            }
        }
    }

    useEffect(() => {
        positionEditorElement();
    }, [selection, ranges, rects]);
}
zareith commented 2 months ago

@SaintPepsi Thank you. This is super helpful.