Search code examples
svgdraggablesveltesvelte-3

Svelte lag while dragging two elements from two different components at the same time


I’ve been trying to build a simple svg editor to try out svelte. Its been going great until I built a select box for the element to drag along with the active selected element. While dragging that selected element the selection box lags behind the element itself. I’m not sure whats wrong.

I’ve tried a few things like using the store to pass location data and putting events on a parent element so everything calculates on the same component in case that might be the issue but still it doesn’t work. I’m not sure what I’m doing wrong. I’ve been trying to figure this out for a while but don’t have any idea what might be the issue.

select box lagging behind element

You can check my codesandbox simplified demo of it here: codesandbox.io

<script lang="ts">
    import ImageViewer from "../ImageViewer/ImageViewer.svelte";
    import EditorControls from "../EditorControls/EditorControls.svelte";

    import { app_data, app_state } from "../../stores";
    import {
        getBoundingBox,
        convertGlobalToLocalPosition,
    } from "../../helpers/svg";
    import { elementData, elementDataRect } from "../../helpers/variables";

    import { mousePointerLocation } from "../../helpers/mouse";

    let activeElement = {
        i: 0,
        bbox: {
            x: 0,
            y: 0,
            width: 0,
            height: 0,
        },
        active: false,
    };
    let elements = [{
        type: 'rect',
        x: 100,
        y: 100,
        width: 400,
        height: 280,
        active: true,
        fill: 'rgba(0, 0, 0, 1)',
        stroke: 'rgba(0, 0, 0, 1)'
    }];
    let app_data_value;
    const unsub_app_data = app_data.subscribe((value) => {
        app_data_value = value;
    });

    let pos = elementData;
    let posRect = elementDataRect;

    let strokeWidth = 15;

    let app_state_value;
    const unsub_app_state = app_state.subscribe((value) => {
        app_state_value = { ...value };
    });

    let moving = app_state_value.action === "move" ? true : false;
    let movePos;
    let active = false;

    const elementMoveDownHandler = (e) => {
        if (
            (e.button === 0 || e.button === -1) &&
            app_data_value.tool.selected === "select"
        ) {
            active = true;
            let i = (e.target as SVGElement).getAttribute("data-obj-id");
            if (!moving) {
                e.target.setPointerCapture(e.pointerId);
                let cursorpt: any = mousePointerLocation(e);
                let bbox: any;
                bbox = getBoundingBox(e.target);
                const offset = {
                    x: e.clientX - bbox.left,
                    y: e.clientY - bbox.top,
                };

                movePos = {
                    ...movePos,
                    init_x: cursorpt.x,
                    init_y: cursorpt.y,
                    offset: {
                        x: offset.x,
                        y: offset.y,
                    },
                    type: app_data_value.tool.selected,
                };

                let pt = convertGlobalToLocalPosition(e.target);
                activeElement = {
                    ...activeElement,
                    i: parseInt(i),
                    bbox: {
                        x: pt.x,
                        y: pt.y,
                        width: bbox.width,
                        height: bbox.height,
                    },
                    active: true,
                };

                moving = true;
            }
        }
    };
    const elementMoveMoveHandler = (e) => {
        if (
            e.button === 0 ||
            (e.button === -1 && app_data_value.tool.selected === "select")
        ) {
            active = true;
            let i = (e.target as SVGElement).getAttribute("data-obj-id");
            if (moving) {
                let cursorpt: any;
                let bbox: any;
                bbox = getBoundingBox(e.target);
                cursorpt = mousePointerLocation(e);
                const offset = {
                    x: e.clientX - bbox.left,
                    y: e.clientY - bbox.top,
                };
                let j;
                switch (e.target.nodeName) {
                    case "rect":
                        j = [...elements]

                        j[i]["x"] =
                            elements[i]["x"] - (movePos.offset.x - offset.x);
                        j[i]["y"] =
                            elements[i]["y"] - (movePos.offset.y - offset.y);
                        elements = j;
                        break;
                    default:
                        break;
                }
                // elements = elements;
                movePos = {
                    ...movePos,
                    move_x: cursorpt.x,
                    move_y: cursorpt.y,
                    type: app_data_value.tool.selected,
                };

                let pt = convertGlobalToLocalPosition(e.target);
                activeElement = {
                    ...activeElement,
                    bbox: {
                        x: pt.x,
                        y: pt.y,
                        width: bbox.width,
                        height: bbox.height,
                    },
                };
                // activeElement = activeElement;
                app_state.update((j) => {
                    j.action = "move";
                    return j;
                });
            }
        }
    };
    const elementMoveUpHandler = (e) => {
        moving = false;
        app_state.update((j) => {
            j.action = "standby";
            return j;
        });
        e.target.releasePointerCapture(e.pointerId);
    };
</script>

<div
    on:pointerdown={(e) => {
        elementMoveDownHandler(e);
    }}
    on:pointerup={(e) => {
        if (active) {
            elementMoveUpHandler(e);
        }
    }}
    on:pointermove={(e) => {
        if (active) {
            elementMoveMoveHandler(e);
        }
    }}
>
    <ImageViewer {strokeWidth} {elements} />
    <EditorControls {pos} {posRect} {activeElement} />
</div>

<style lang="scss">
    @import "./SVGEditor.scss";
</style>
<script lang="">
    import { app_data } from "../../stores";

    export let strokeWidth;
    export let elements;

    let app_data_value;
    const unsub_app_data = app_data.subscribe((value) => {
        app_data_value = value;
    });

</script>

<svg
    id="image-viewer"
    width={app_data_value.doc_size.width}
    height={app_data_value.doc_size.height}
>
    {#each elements as item, i}
        {#if typeof item === "object"}
            {#if "type" in item}
                {#if item.type === "line"}
                    {#if "x1" in item && "y1" in item && "x2" in item && "y2" in item}
                        <line
                            x1={item.x1}
                            y1={item.y1}
                            x2={item.x2}
                            y2={item.y2}
                            stroke="black"
                            stroke-width={strokeWidth}
                            data-obj-id={i}
                        />
                    {/if}
                {/if}
                {#if item.type === "rect"}
                    {#if "x" in item && "y" in item && "width" in item && "height" in item}
                        <rect
                            x={item.x}
                            y={item.y}
                            width={item.width}
                            height={item.height}
                            stroke="black"
                            stroke-width={strokeWidth}
                            data-obj-id={i}
                        />
                    {/if}
                {/if}
            {/if}
        {/if}
    {/each}
</svg>

<style lang="scss">
    @import "./ImageViewer.scss";
</style>
<script lang="ts">
    import { app_data } from "../../stores";
    import SelectCtrl from "../SelectCtrl/SelectCtrl.svelte";

    export let pos;
    export let posRect;
    export let activeElement;

    let app_data_value;
    const unsub_app_data = app_data.subscribe((value) => {
        app_data_value = value;
    });
    let active;
    $: active = activeElement.active;
    let strokeWidth = 2;
</script>

<svg
    id="editor-controls"
    width={app_data_value.doc_size.width}
    height={app_data_value.doc_size.height}
>
    {#if active}
        <SelectCtrl activeElement={activeElement} strokeWidth={2}/>
    {/if}
</svg>

<style lang="scss">
    @import "./EditorControls.scss";
</style>
<script lang="typescript">
    export let activeElement;
    export let strokeWidth;

    let x = 0;
    let y = 0;
    let width = 0;
    let height = 0;

    $: x = activeElement.bbox.x;
    $: y = activeElement.bbox.y;
    $: width = activeElement.bbox.width;
    $: height = activeElement.bbox.height;
    
    let fill = 'rgba(0,0,0,0)';
    let stroke = '#246bf0';

    
    let strokeWidthMain;
    $: strokeWidthMain = strokeWidth*2;

</script>
<g
    class="selector-parent-group"
>
    <g
        class="selection-box"
    >
        <rect
            class="bounding-box"
            x={x}
            y={y}
            width={width}
            height={height}
            fill={fill}
            stroke={stroke}
            stroke-width={strokeWidthMain}
        />
        <rect
            class="bounding-box-light"
            x={x-strokeWidthMain}
            y={y-strokeWidthMain}
            width={width+strokeWidthMain*2}
            height={height+strokeWidthMain*2}
            fill={fill}
            stroke={'#B9B9B9'}
            stroke-width={strokeWidthMain}
        />
    </g>
</g>

Edit: I didn't think to add the code for the convertGlobalToLocalPosition and getBoundingBox functions but thanks to the answer that solved my problem it would better illustrate the problem I had if I add that code as well.

export function convertGlobalToLocalPosition(element: any) {
    if (!element) return { x: 0, y: 0 };
    if (typeof element.ownerSVGElement === 'undefined') return { x: 0, y: 0 };
    var svg = element.ownerSVGElement;

    // Get the cx and cy coordinates
    var pt = svg.createSVGPoint();

    let boxParent = getBoundingBox(svg);
    let box = getBoundingBox(element);
    pt.x = box.x - boxParent.x;
    pt.y = box.y - boxParent.y;
    while (true) {
        // Get this elementents transform
        var transform = element.transform.baseVal.consolidate();
        // If it has a transform, then apply it to our point
        if (transform) {
            var matrix = element.transform.baseVal.consolidate().matrix;
            pt = pt.matrixTransform(matrix);
        }
        // If this elementent's parent is the root SVG elementent, then stop
        if (element.parentNode == svg)
            break;
        // Otherwise step up to the parent elementent and repeat the process
        element = element.parentNode;
    }
    return pt;
}
export function getBoundingBox(el: any) {
    let computed: any = window.getComputedStyle(el);
    let strokeWidthCalc: string = computed['stroke-width'];
    let strokeWidth: number = 0;
    if (strokeWidthCalc.includes('px')) {
        strokeWidth = parseFloat(strokeWidthCalc.substring(0, strokeWidthCalc.length - 2));
    } else {
        // Examine value further
    }
    let boundingClientRect = el.getBoundingClientRect();
    let bBox = el.getBBox();
    if (boundingClientRect.width === bBox.width) {
        boundingClientRect.x -= strokeWidth / 2;
        boundingClientRect.y -= strokeWidth / 2;
        boundingClientRect.width += strokeWidth;
        boundingClientRect.height += strokeWidth;
    }
    return boundingClientRect;
}

Solution

  • I think this is causing your issue:

    In elementMoveMoveHandler you are updating the element's position here:

    j = [...elements];
    
    j[i]["x"] = elements[i]["x"] - (movePos.offset.x - offset.x);
    j[i]["y"] = elements[i]["y"] - (movePos.offset.y - offset.y);
    elements = j;
    

    and right after that you are reading the position from the DOM in the convertGlobalToLocalPosition function. Svelte will batch DOM updates and it did not have time to update the DOM element. Therefore convertGlobalToLocalPosition will give you an old value. The easiest fix would be to add await tick(); right before let pt = convertGlobalToLocalPosition(e.target); and make elementMoveMoveHandler async. You can read more about the tick function here: https://svelte.dev/tutorial/tick

    A few more suggestions:

    1. You don't need to manually subscribe to stores or call the update function. You can use the $-symbol and Svelte will take care of subscribing, unsubscribing and notifying subscribers for you. https://svelte.dev/tutorial/auto-subscriptions

    2. You can certainly use immutability in Svelte, but it is not needed. Personally, I find mutable code more readable.