anseki / leader-line

Draw a leader line in your web page.
http://anseki.github.io/leader-line/
MIT License
3.03k stars 425 forks source link

Socket position is off when the element has a linebreak. #344

Closed Norlandz closed 2 years ago

Norlandz commented 2 years ago

Situation (Problem)

When I draw a LeaderLine between 2 elements.

If there is a linebreak in the element. The Socket position is off.

I have to use white-space: nowrap; to make sure there is no linebreak inside that element. Then The Socket position is correct.

As image shows:: image

Question

Is there a way to let the socket of the LeaderLine attach to the element properly, even there is a linebreak inside that element?

anseki commented 2 years ago

Hi @Norlandz, thank you for the comment. The sockets are positioned properly even if the element has line-breaks. Also, the sockets are positioned properly for an element that has multiple border boxes and a bounding rectangle that encloses those multiple border boxes. If you want to make the element avoid being split into multiple lines, specify CSS property e.g. display: inline-block.

Norlandz commented 2 years ago

Socket positioned properly?

The sockets are positioned properly even if the element has line-breaks.

I dont quite get this. @anseki Do you mean the Socket Position in the Left image I post is "positioned properly"?

But its in the middle of the page, not attached to the element.


What I want is:

Even if there is a linebreak in the element (or, in another word, the element span across 2 lines), the Socket should be attached to the element.

Is this possible?


Maybe I should show a simpler example:

image

<!DOCTYPE html>
<html>
<head>
<title>Page Title</title>
<script src="leader-line.min.js"></script>
</head>
<body>
<p style="width: 300px">AAAAA AAAA AAAAAA AAA This <span id="AA">[is a paragraph]</span> A AAA AA.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBB BBB BBBB This <span id="BB">[emmm]</span> BBBB BBBBB.</p>
<script>
window.addEventListener('load', function() {
  'use strict';

  let ele_ArrowStart = document.getElementById('AA');
  let ele_ArrowEnd = document.getElementById('BB');

  let leaderLine = new LeaderLine(
    ele_ArrowStart,
    ele_ArrowEnd
  );

});
</script>
</body>
</html>
anseki commented 2 years ago

See this: ss01 This blue box is a bounding rectangle of the element that you specified. You should see the sockets are positioned properly for this blue box. You seem to have mistaken that the library draws lines based on the bounding-boxes.

Anyway, you don't like this specification of DOM. So, where the point you want?

anseki commented 2 years ago

Of course I don't know where the point you want. If you want to get one of parts that are border boxes of the element that was split, you can get that by using getClientRects API.

Norlandz commented 2 years ago

(Currently working on the javascript implementation to solve this case. -- (Actually it was done 2 days ago... After I saw your comment on getClientRects @anseki (It was quite helpful (as for a workaround))) Just need some time to reconstruct my post. I will post it when I get time.)

Norlandz commented 2 years ago

About the blue outer rectangle area

--

This blue box is a bounding rectangle of the element that you specified. You should see the sockets are positioned properly for this blue box.

This correspond to my post above::

(It looks like that, its calculated by treating the multi span line element to be contained in an larger rectangle area, then it gets the middle position of that larger rectangle.

(I was suspecting that, its good to have a confirmation.)

--

You seem to have mistaken that the library draws lines based on the bounding-boxes.

Indeed, I was thinking in that way.

--

Anyway, you don't like this specification of DOM.

There wasnt really any like or dislike about it. I actually didnt think this was related to the specification of DOM. (I dont know too much about html & javascript)

--

So, where the point you want?

I was saying to "let the socket attach to the element"; so that was referring to "let the socket attach to the text (text bounding box) inside the element"

I think this visually makes more sense in terms of readability.

Norlandz commented 2 years ago

[^ continues]

Implementation that makes the Socket attach to the text bounding box

But it seems, LeaderLine is not able to achieve that (nativly).

you can get that by using getClientRects API.

So, I read through the API about the getClientRects, and found that this could be helpful, for implementing a solution (workaround) to achieve what I want. =v @anseki

I wrote a javascript to "make the Socket attach to the text bounding" with the use of getClientRects.

The key idea is this line: // ele_ArrowLinkPoint_Rect = an empty span that has the exact same position as rect_ArrowLinkPoint_lastLine The js dynamically creates an ele_ArrowLinkPoint_Rect and new LeaderLine() will use it for linking, instead of using the original element.


Below is the code & demo:

<!DOCTYPE html>
<html>
<head>
<title>Page Title</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> 
<script src="leader-line.min.js"></script> 
<script src="leaderLineRectNt.js"></script>
<link href="leaderLineRectNt.css" rel="stylesheet" type="text/css" />
</head>
<body>
<canvas id="debugCanvas" width="800" height="1200" style="position: absolute; margin: 0; padding: 0; left: 0px; top: 0px; z-index: -1;"></canvas>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">P1.</p>
<p style="width: 300px">AAAAA AAAA AAAAAA AAA This <span class="arrowLinkPoint" id="AA">[is a paragraph]</span> A AAA AA.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBBBBBBB.</p>
<p style="width: 300px">BBB BBB BBBB This <span class="arrowLinkPoint" id="BB">[emmm]</span> BBBB BBBBB.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>P2</p>
<p>What if this <span class="arrowLinkPoint" id="CC">[is a very veryveryveryvery very long line? (Try to drag the Window of your Browser, <span>it may be laggy though</span><span>... <span>(Nest more <span>span in</span>, to test)</span></span> --- A)]</span> Are you able to link?</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>This is <span class="arrowLinkPoint" id="DD">[where you want to link to (to ooooooo ----- )]</span>?</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>P3</p>
<p>Try with a even very very very long line. <span class="arrowLinkPoint" id="EE">[Try to link to the point. (This may span over 3 lines, or more. Doesnt matter. Just need to be long. (And yet, this is not long enough, so I type more and more... What else should I say?...))]</span> See if this is linked.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>Once <span class="arrowLinkPoint" id="FF">[there is a line. We know its very very long... And we need to link to it. Sample sample sample length length ..... Imporve length. Longer longer.]</span> Ahhhhhh</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<p>BBBBBBBB.</p>
<script>
window.addEventListener('load', function() {
  'use strict';

  // >> add LeaderLine (ori, for comparison)
  let ele_ArrowStart = document.getElementById('AA');
  let ele_ArrowEnd = document.getElementById('BB');

  let leaderLine = new LeaderLine(
    ele_ArrowStart,
    ele_ArrowEnd
  );

  // >> add LeaderLine with Rect
  addLeaderLineRect(ele_ArrowStart, ele_ArrowEnd);

//  // >> 
//  addLeaderLineAnchor(ele_ArrowStart, ele_ArrowEnd);

});
</script> 
<script>
window.addEventListener('load', function() {
  'use strict';

  // >> add LeaderLine (ori, for comparison)
  let ele_ArrowStart = document.getElementById('CC');
  let ele_ArrowEnd = document.getElementById('DD');

  let leaderLine = new LeaderLine(
    ele_ArrowStart,
    ele_ArrowEnd
  );

  // >> add LeaderLine with Rect
  addLeaderLineRect(ele_ArrowStart, ele_ArrowEnd);

//  // >> 
//  addLeaderLineAnchor(ele_ArrowStart, ele_ArrowEnd);

});
</script> 
<script>
window.addEventListener('load', function() {
  'use strict';

  // >> add LeaderLine (ori, for comparison)
  let ele_ArrowStart = document.getElementById('EE');
  let ele_ArrowEnd = document.getElementById('FF');

  let leaderLine = new LeaderLine(
    ele_ArrowStart,
    ele_ArrowEnd
  );

  // >> add LeaderLine with Rect
  addLeaderLineRect(ele_ArrowStart, ele_ArrowEnd);

//  // >> 
//  addLeaderLineAnchor(ele_ArrowStart, ele_ArrowEnd);

});
</script> 
</body>
</html>
span.arrowLinkPoint {
  background-color: rgba(240,255,255,0.50); /*the red dots are from debug Canvas*/
  /*display: inline-block;*/
  /*white-space: nowrap;*/
}
function addLeaderLineRect(ele_ArrowStart, ele_ArrowEnd) {

  let leaderLine_RectLink_prev;
  let ele_ArrowStart_Rect_prev;
  let ele_ArrowEnd_Rect_prev;
  let func_repositionArrowLinkPointRect = function() { // (local var can be read from the inside the scope...)

    // note: this may return the ori element, if Rect is not needed.
    let ele_ArrowStart_Rect = getLeaderLineArrowLinkPointEsClientRect(ele_ArrowStart);
    let ele_ArrowEnd_Rect = getLeaderLineArrowLinkPointEsClientRect(ele_ArrowEnd);

    let leaderLine_RectLink = new LeaderLine(
      ele_ArrowStart_Rect,
      ele_ArrowEnd_Rect,
      {color: 'rgba(0,128,0,0.5)', size: 1, endPlugSize: 3}
    );
    // remove the old Leaderline & ele_ArrowLinkPoint (cant just reposition, cuz its a new ele_ArrowLinkPoint being created everytime)
    if (leaderLine_RectLink_prev != undefined) {
      leaderLine_RectLink_prev.remove();  
    }
    // since the returned ele_ArrowStart_Rect could be the ori element, dont remove that -> use idRef to det. 
    if (ele_ArrowStart_Rect_prev != undefined && ele_ArrowStart_Rect_prev.dataset.idRef != undefined) {
      ele_ArrowStart_Rect_prev.remove();
    }
    if (ele_ArrowEnd_Rect_prev != undefined && ele_ArrowEnd_Rect_prev.dataset.idRef != undefined) {
      ele_ArrowEnd_Rect_prev.remove();
    }
    ele_ArrowStart_Rect_prev = ele_ArrowStart_Rect;
    ele_ArrowEnd_Rect_prev = ele_ArrowEnd_Rect;
    leaderLine_RectLink_prev = leaderLine_RectLink;

  };
  // (init call function not required, just let ResizeObserver do it)

  new ResizeObserver(func_repositionArrowLinkPointRect).observe(document.body);
  // <strike> //to_investigate: since the impl is using getClientRects(), the effect of resizing the viewport is unknown...
  // ^ resize does make that not working properly -- the Rect remains at ori position. need add listener. 
  // @note,to-fix: 
  // use an inner function inside is not good, since you may only have arr_rect_ArrowLinkPoint[0] sometimes -- after resize, not arr_rect_ArrowLinkPoint[1] -> better redo the whole function.
  // actually, recall the whole thing makes things worse -- there are many LeaderLine & ele_ArrowLinkPoint_Rect <span> created
  // ... inner seems harder, for creation... 
  //TODO-performance: if there is no new Rect -- the Element doesnt change the num of spanning lines

  // ;not_working; new ResizeObserver(func_repositionArrowLinkPointRect).observe(ele_ArrowStart);
  // ;not_working; new ResizeObserver(func_repositionArrowLinkPointRect).observe(ele_ArrowEnd);
  // ;not_working; // ^ observe the element than body may be better performance

}

//note: <br> may not be supported yet
// <!--<p style="width: 300px">AAAAAAAA AAA This <span id="AA">[is a <br/>
//  paragraph]</span> A AAA AA.</p> // dont use br, its count as a Rect too...-->

function getLeaderLineArrowLinkPointEsClientRect(ele_ArrowLinkPoint) {
  let amount_Rects = ele_ArrowLinkPoint.getClientRects().length;

  if (amount_Rects == 1) {
    // (do nothing)
    return ele_ArrowLinkPoint;
  } else {

    // >> add an ele_ArrowLinkPoint_Rect
    // ele_ArrowLinkPoint_Rect = an empty span that has the exact same position as rect_ArrowLinkPoint_lastLine
    // - rect_ArrowLinkPoint_lastLine = the Last ClientRect (rectangle area) of the element that span across multi lines
    // -|

    // >>> create the element
    // add reference to id of the origianl ele_ArrowLinkPoint (ele_ArrowLinkPoint)
    let ele_ArrowLinkPoint_Rect = document.createElement('span');
    ele_ArrowLinkPoint_Rect.id = ele_ArrowLinkPoint.id + '_Rect';
    ele_ArrowLinkPoint_Rect.dataset.idRef = ele_ArrowLinkPoint.id;

    // append to the Parent element of the ele_ArrowLinkPoint // ;pb; position, relative, absolute
    let ele_ArrowLinkPoint_parent = ele_ArrowLinkPoint.parentElement;
    ele_ArrowLinkPoint_parent.appendChild(ele_ArrowLinkPoint_Rect);

    // >>> get the ClientRect
    let arr_rect_ArrowLinkPoint = ele_ArrowLinkPoint.getClientRects();
    //~ let rect_ArrowLinkPoint_0 = arr_rect_ArrowLinkPoint[0];
    //~ let rect_ArrowLinkPoint_1 = arr_rect_ArrowLinkPoint[1];
    // ;pb; actually cannot just use 2nd one.. if inner span inside there will be more Rect Obj -> use the last one
    let rect_ArrowLinkPoint_lastOne = arr_rect_ArrowLinkPoint[arr_rect_ArrowLinkPoint.length-1];

    // >>> merge the ClientRects at last line <- (when there is an inner span element -> there will be more ClientRects)

    // ;not_working; let rect_ArrowLinkPoint_lastLine = structuredClone(rect_ArrowLinkPoint_lastOne); // ;pb; Each DOMRect object contains read-only
    let rect_ArrowLinkPoint_lastLine = {
      top: rect_ArrowLinkPoint_lastOne.top,
      left: 0,
      width: 0,
      height: rect_ArrowLinkPoint_lastOne.height
    };

    let det_FirstOneInLastLine = true;
    for (let rect_curr of arr_rect_ArrowLinkPoint) {
      if (rect_curr.top == rect_ArrowLinkPoint_lastOne.top) {
        rect_ArrowLinkPoint_lastLine.width += rect_curr.width;
        if (det_FirstOneInLastLine) {
          rect_ArrowLinkPoint_lastLine.left = rect_curr.left;
          det_FirstOneInLastLine = false;
        }
      }
    }

    // >>>>< debug Canvas
    // draw a red dot, to show position of each of the ClientRect
    for (let rect_curr of arr_rect_ArrowLinkPoint) {
      let rect_curr_PosRelToDoc = getCoordsRelativeToDocument(rect_curr);
      let canvas = document.getElementById('debugCanvas');
      let ctx = canvas.getContext('2d');
      ctx.fillStyle = '#FF0000';
      // ;not_working; ctx.fillRect(rect_curr.left,rect_curr.top,5,5);
      // ;debug; ctx.fillRect(0,0,5,5); // Calibration: top left -> canvas "left: 0px; top: 0px;"
      ctx.fillRect(rect_curr_PosRelToDoc.left,rect_curr_PosRelToDoc.top,5,5);
    }

    // >>> setup the Postion of the ele_ArrowLinkPoint_Rect
    let eleJq_ArrowLinkPoint = $(ele_ArrowLinkPoint);
    let eleJq_ArrowLinkPoint_Rect = $(ele_ArrowLinkPoint_Rect);
    //~  console.log(arr_rect_ArrowLinkPoint);
    //~  console.log(rect_ArrowLinkPoint_lastLine);
    //~  console.log(eleJq_ArrowLinkPoint.offset());

    let rect_ArrowLinkPoint_lastLine_PosRelToDoc = getCoordsRelativeToDocument(rect_ArrowLinkPoint_lastLine);
    eleJq_ArrowLinkPoint_Rect.css({
      'width': rect_ArrowLinkPoint_lastLine.width,
      'height': rect_ArrowLinkPoint_lastLine.height,
      'top': rect_ArrowLinkPoint_lastLine_PosRelToDoc.top,
      'left': rect_ArrowLinkPoint_lastLine_PosRelToDoc.left,

      'position': 'absolute',
      'border': '1px solid green'
    });

    // ;M2; if (amount_Rects == 2) {
    // ;M2; } else {
    // ;M2;   console.log('This element: ' + ele_ArrowLinkPoint.id +' spans over 3 lines, May Not Supported');
    // ;M2; }
    // ;M2; eleJq_ArrowLinkPoint_Rect.offset({
    // ;M2;   top: eleJq_ArrowLinkPoint.offset().top + rect_ArrowLinkPoint_lastLine.height * (amount_Rects - 1),
    // ;M2;   left: eleJq_ArrowLinkPoint.offset().left
    // ;M2; });
    // ;M2; // ^ use jQuery offset(), cuz this offset() is relative to the document; 
    // ;M2; // 1.
    // ;M2; // rect_ArrowLinkPoint_lastLine.top kinda works, but if your window is very small & you scroll away, its posistion may stay at ori position -- position is relative to the viewport (window).
    // ;M2; // you may get relative to document, see https://stackoverflow.com/questions/5598743/finding-elements-position-relative-to-the-document , but may be buggy? (dont seem so)
    // ;M2; // 2. 
    // ;M2; // top: eleJq_ArrowLinkPoint.offset().top + rect_ArrowLinkPoint_lastLine.height,
    // ;M2; // cuz this is offset() is of the `outer container rectangle area`, not the RectClient.
    // ;M2; // @atten: this only works (workaround) for element spans over only 2 lines, & with consistent-fixed height ...

    return ele_ArrowLinkPoint_Rect;
  }
}

// >>><
// ; https://stackoverflow.com/questions/5598743/finding-elements-position-relative-to-the-document
function getCoordsRelativeToDocument(box) { // crossbrowser version
  var body = document.body;
  var docEl = document.documentElement;

  var scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
  var scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;

  var clientTop = docEl.clientTop || body.clientTop || 0;
  var clientLeft = docEl.clientLeft || body.clientLeft || 0;

  var top  = box.top +  scrollTop - clientTop;
  var left = box.left + scrollLeft - clientLeft;

  return { top: Math.round(top), left: Math.round(left) };
}

Here is the output html (drag & resize you browser's window to see the effect & compare):

image

jsfiddle: LeaderLine Socket Position span across line

anseki commented 2 years ago

:smile: