65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 SVG 和 JavaScript 构建原型 Web 图表应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (43投票s)

2018年4月2日

CPOL

43分钟阅读

viewsIcon

90091

downloadIcon

1208

学习如何用 JavaScript 以编程方式操作 SVG

下载并解压缩文件,然后打开“FlowSharpWeb.html”文件,即可在默认浏览器中启动应用程序。

或者...

目录

引言

我一直想学习 SVG,网上有很多关于创建 SVG 图画和动画的有用网站。但我不想学习如何创建静态(甚至动画)的 SVG 图画,我想学习如何动态地使用 SVG。

  • 动态创建、修改和删除 SVG 元素。
  • 为移动元素、更改属性等挂钩事件。
  • 保存和恢复图画。
  • 发现怪癖和如何规避它们。

这就是本文的主题——它仅教授 SVG 和 JavaScript 在实现上述目标方面的应用。但是,它将教会你如何创建动态 SVG 图画,还有什么比实际创建一个简单的绘图程序更好的方法呢?再说,我在撰写本文时也学习了很多关于 SVG 和现代 JavaScript 的知识。

无第三方库

此代码中没有使用任何第三方库。 此处包含的一些有用的 JavaScript 函数(特别是 FileSaver)来自其他地方(请参阅文章了解来源),但没有任何 SVG 操作框架的依赖。此代码甚至不使用 jQuery。依我看,这使得从头开始学习 SVG 容易得多——你**不是**在处理 SVG + YAF(另一个框架)。

解释原因和方法

此代码和代码注释的目的是描述我为什么以某种方式做事情,以及我如何做。由于这对我自己来说是一次学习经历,任何时候我需要查找信息,我都会引用信息来源——这大部分都是 StackOverflow 的引用!

draw.io

最好的在线 SVG 绘图程序之一是 draw.io,我不会试图重新创建它。但是,像许多事情一样,“自己动手”通常有助于理解技术的用法。在线程序 draw.io 是 mxgraph 的一个不错的前端,它在 此链接处有出色的文档。还可以查看其 API 规范,支持 PHP、.NET、Java 和 JavaScript。如果您正在寻找一款精美的绘图程序,类似于 Visio,请查看 draw.io。如果您想学习这是如何完成的,那么这就是本文的目的。

话不多说,我们开始吧!

原型构建

本文的前三分之二是一个原型构建。它通过 UI 交互和图表持久化验证了 SVG DOM 的基本功能和操作。特别是,我实现了一个非常浅层的视图-控制器架构,该架构在文章后面完全被替换。在“重构原型”部分,我迁移到一个完整的模型-视图-控制器,这清理了许多我们在这里会看到的粗糙的变通方法。过渡并不痛苦——90% 的代码在正确的 MVC 模型中被重用,最显著的变化发生在鼠标控制器和工具箱控制器中。并且移除了所有 instanceof 的情况,我认为这本身就是一种粗糙的做法。

创建可滚动网格

我想学习的第一件事是创建一个可滚动的网格。在网上很容易找到一个示例,我以此为起点。

<svg id="svg" width="801" height="481" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <pattern id="smallGrid" width="8" height="8" patternUnits="userSpaceOnUse">
      <path d="M 8 0 H 0 V 8" fill="none" stroke="gray" stroke-width="0.5" />
    </pattern>
    <pattern id="grid" width="80" height="80" patternUnits="userSpaceOnUse">
      <rect width="80" height="80" fill="url(#smallGrid)" />
      <!-- draw from upper right to upper left, then down to lower left -->
      <!-- This creates the appearance of an 80x80 grid when stacked -->
      <path d="M 80 0 H 0 V 80" fill="none" stroke="gray" stroke-width="2" />
    </pattern>
  </defs>

  <!-- a trick from my old Commodore 64 days is to extend 
       the scrolling region beyond the viewport 
  and use mod 80 to reset the position to simulate a virtual space. -->
  <rect transform="translate(0, 0)" id="surface" x="-80" y="-80" 
   width="961" height="641" fill="url(#grid)" />
</svg>

如我所说,我不会深入讲解 SVG 的细节,但我会指出核心功能。

  • 有两个网格——每 80 像素一个外部网格,每 8 像素一个内部网格。
  • “网格”实际上是通过绘制两条线来创建的:顶部线(从右到左)和左边缘,从左上角到左下角。“M 80 0 H 0 V 80”的作用是——它创建一个从 (80, 0) 开始的路径,绘制一条水平线到 (0, 0),然后绘制一条垂直线到 (0, 80)
  • 初始变换是一个占位符——“translate(0, 0)”实际上什么也不做。

模拟虚拟表面

请注意,矩形是用屏幕外缓冲区绘制的,其缓冲区大小为 (-80, -80)(width + 80*2, height + 80*2)。这是我过去在 Commodore 64 上编程滚动游戏时使用的一个老技巧——你会渲染视图区域,包括一个屏幕外缓冲区,这样就可以通过简单的平移(或者在 C64 上,改变屏幕内存指针)来滚动。滚动重复模式时,可以“平移”视图区域 +/- 80 mod 80(网格的 widthheight),用户会觉得有一个无限的虚拟表面。

滚动网格 - 鼠标事件

用户通过“拖动”操作滚动网格。

  • 鼠标按下开始。
  • 移动鼠标,滚动网格。
  • 鼠标抬起结束。

我们将跟踪以下变量。

var mouseDown = false;
var mouseDownX = 0;
var mouseDownY = 0;
var gridX = 0;
var gridY = 0;

连接鼠标事件

这很简单(但我们稍后会看到它变得更复杂,因为对于可能从图中删除的实际形状,我们需要解除事件处理程序的绑定)。

function initializeSurface() {
  var svg = document.getElementById("svg");
  var surface = svg.getElementById("surface");
  surface.addEventListener("mousedown", onMouseDown, false);
  surface.addEventListener("mouseup", onMouseUp, false);
  surface.addEventListener("mousemove", onMouseMove, false);
  surface.addEventListener("mouseleave", onMouseLeave, false);
} 

initializeSurface();

最佳实践

技术上来说,我们可以直接从文档中获取 surface 元素。

var svgSurface = document.getElementById("surface");

但我认为使用 svg 元素有助于防止 HTML 中存在同名元素的可能性,特别是考虑到我们不知道程序员会如何创建额外的 HTML。

事件处理程序

在这里,我们处理 mousedownmouseupmousemove 事件。

const LEFT_MOUSE_BUTTON = 0;

function onMouseDown(evt) {
  if (evt.button == LEFT_MOUSE_BUTTON) {
    evt.preventDefault();
    mouseDown = true;
    mouseDownX = evt.clientX;
    mouseDownY = evt.clientY;
  }
}

function onMouseUp(evt) {
  if (evt.button == LEFT_MOUSE_BUTTON) {
    evt.preventDefault();
    mouseDown = false;
  }
}

function onMouseMove(evt) {
  if (mouseDown) {
    evt.preventDefault();
    var mouseX = evt.clientX;
    var mouseY = evt.clientY;
    var mouseDX = mouseX - mouseDownX;
    var mouseDY = mouseY - mouseDownY;
    gridX += mouseDX;
    gridY += mouseDY;
    mouseDownX = mouseX;
    mouseDownY = mouseY;
    var svg = document.getElementById("svg");
    var surface = svg.getElementById("surface");
    var dx = gridX % 80;
    var dy = gridY % 80;
    surface.setAttribute("transform", "translate(" + dx + "," + dy + ")");
  }
}

需要注意的一些事项。

  • 显然,有些浏览器(如 Firefox)有默认的拖放处理,所以我们调用 evt.preventDefault() 来,嗯,阻止事件的默认处理。
  • 变量 gridXgridY 跟踪绝对偏移网格。
  • 网格通过此绝对偏移模 80 进行平移,因此我们不会超出缓冲区边界。
  • 显然,W3C 标准(左按钮 == 0)和微软的概念(左按钮 == 1)之间曾经存在一些混淆,但这似乎是过时的信息——在 Chrome 和 Edge 上测试,左按钮的值(以及右键和中键的值)在这些浏览器中是一致的。

处理鼠标离开网格的情况

没有“鼠标捕获”的概念,因此当用户拖动表面并且鼠标移出 SVG 元素时,将不再接收 mouseup 等事件。如果用户通过释放鼠标按钮在 SVG 元素外部停止拖动,代码仍处于拖动状态,因为 mouseup 事件未触发。因此,当鼠标光标离开元素时,我们通过处理 mouseleave 事件来模拟 mouseup 事件。

surface.addEventListener("mouseleave", onMouseLeave, false);

// If the mouse moves out of the surface area, the mouse up event will not trigger,
// so we clear the mouseDown flag so that scrolling does not resume "by itself" 
// when the user moves the mouse back onto the surface, which would otherwise 
// require the user to click to clear the mouseDown flag.
function onMouseLeave(evt) {
  evt.preventDefault();
  mouseDown = false;
}

调整网格大小 - 我们的第一个动态 SVG

当然,以上所有代码都是针对 80x80 的网格尺寸和 8x8 的内部网格间距进行硬编码的。我们希望这实际上是可以由用户配置的。为此,重命名一些 ID 并为模式定义添加其他 ID 会很有用。

<defs>
  <pattern id="smallGrid" width="8" height="8" patternUnits="userSpaceOnUse">
    <path id="smallGridPath" d="M 8 0 H 0 V 8" fill="none" 
     stroke="gray" stroke-width="0.5" />
  </pattern>
  <pattern id="largeGrid" width="80" height="80" patternUnits="userSpaceOnUse">
    <rect id="largeGridRect"width="80" height="80" fill="url(#smallGrid)" />
    <!-- draw from upper right to upper left, then down to lower left -->
    <!-- This creates the appearance of an 80x80 grid when stacked -->
    <path id="largeGridPath" d="M 80 0 H 0 V 80" fill="none" 
     stroke="gray" stroke-width="2" />
  </pattern>
</defs>

为了下一节的内容清晰起见,我还将一个组添加到了代表网格的矩形周围。

<g id="surface" transform="translate(0, 0)" x="-80" y="-80" width="961" height="641" >
  <rect id="grid" x="-80" y="-80" width="961" height="641" fill="url(#largeGrid)" />
</g> 

我们需要跟踪较大矩形的 widthheight 设置以进行模运算。

// The default:
var gridCellW = 80;
var gridCellH = 80;

并在 mousemove 处理程序中使用。

var dx = gridX % gridCellW;
var dy = gridY % gridCellH;

给定此函数,它将网格间距更改为本节开头截图所示,大网格为 100x100,小网格为 20x20。

resizeGrid(100, 100, 20, 20);

这是实现。

 // Programmatically change the grid spacing for the larger grid cells 
 // and smaller grid cells.
function resizeGrid(lw, lh, sw, sh) {
  gridCellW = lw;
  gridCellH = lh;
  var elLargeGridRect = document.getElementById("largeGridRect");
  var elLargeGridPath = document.getElementById("largeGridPath");
  var elLargeGrid = document.getElementById("largeGrid");

  var elSmallGridPath = document.getElementById("smallGridPath");
  var elSmallGrid = document.getElementById("smallGrid");

  var elSvg = document.getElementById("svg");
  var elSurface = document.getElementById("surface");
  var elGrid = document.getElementById("grid");

  elLargeGridRect.setAttribute("width", lw);
  elLargeGridRect.setAttribute("height", lh);

  elLargeGridPath.setAttribute("d", "M " + lw + " 0 H 0 V " + lh);
  elLargeGrid.setAttribute("width", lw);
  elLargeGrid.setAttribute("height", lh);

  elSmallGridPath.setAttribute("d", "M " + sw + " 0 H 0 V " + sh);
  elSmallGrid.setAttribute("width", sw);
  elSmallGrid.setAttribute("height", sh);

  elGrid.setAttribute("x", -lw);
  elGrid.setAttribute("y", -lh);

  var svgW = +elSvg.getAttribute("width");
  var svgH = +elSvg.getAttribute("height");

  elSurface.setAttribute("width", svgW + lw * 2);
  elSurface.setAttribute("height", svgH + lh * 2);

  elSurface.setAttribute("x", -lw);
  elSurface.setAttribute("y", -lh);

  elSurface.setAttribute("width", svgW + lw * 2);
  elSurface.setAttribute("height", svgH + lh * 2);
}

这是对 DOM 元素的大量操作。我们正在做的是:

  • 重置外部网格矩形和模式的 widthheight
  • 重置内部网格模式的 widthheight
  • 更改外部和内部网格的路径以反映新尺寸。
  • 调整缓冲区区域和表面尺寸。

添加一些静态形状

还记得我添加到网格矩形周围的组吗?现在我们将添加另一个用于形状的组,并将一些静态形状放入该组。

<g id="objects" transform="translate(0, 0)">
  <circle cx="150" cy="100" r="40" stroke="black" stroke-width="1" fill="#FFC0C0" />
  <circle cx="175" cy="125" r="40" stroke="black" stroke-width="1" fill="#C0FFC0" />
</g>

现在,通过对 mousemove 事件进行简单添加,我们也可以平移“objects”组中的所有元素,以便它们在表面滚动时移动。

function onMouseMove(evt) {
  if (mouseDown) {
    evt.preventDefault();
    var mouseX = evt.clientX;
    var mouseY = evt.clientY;
    var mouseDX = mouseX - mouseDownX;
    var mouseDY = mouseY - mouseDownY;
    gridX += mouseDX;
    gridY += mouseDY;
    mouseDownX = mouseX;
    mouseDownY = mouseY;
    var surface = document.getElementById("surface");

    var dx = gridX % gridCellW;
    var dy = gridY % gridCellH;
    surface.setAttribute("transform", "translate(" + dx + "," + dy + ")");

    var objects = document.getElementById("objects");
    objects.setAttribute("transform", "translate(" + gridX + "," + gridY + ")");
  }
}

我们使用两个单独的组的原因是:

  • 表面始终根据大网格大小进行模平移。
  • 表面上的对象必须按绝对滚动偏移量进行平移。

如果我们不分开这两个区域,我们就会得到一个奇怪的效果,即形状由于模运算而返回到其原始位置。显然,我们不希望这样。

移动形状

此时,我们必须对鼠标事件的捕获方式进行更复杂的处理——每个形状(包括表面)都必须处理自己的鼠标事件。但是,事件的作用并不总是相同的——例如,滚动表面网格与在“objects”组中移动形状不同。稍后,更复杂的鼠标移动活动将需要跟踪操作的状态——我们是在移动形状、调整大小、旋转形状,还是其他?

这是一个很大的飞跃,但创建实际的 MouseController 类以及为不同形状的专门行为创建形状控制器类确实非常有益。如果我们现在这样做,继续扩展到目前为止只是一个测试场所的功能将变得容易得多。

鼠标控制器

MouseController 类为我们做了几件事:

  • 它跟踪被拖动的形状。这一点很重要,因为用户可以以比形状大小更大的增量移动鼠标。发生这种情况时,鼠标会“逃离”形状,它不再接收 mousemove 事件。因此,一旦一个形状(包括表面网格)被 mousedown 事件“捕获”,mousemove 事件就会被传递给负责该形状的控制器。
  • 它将形状 ID 映射到形状控制器。这允许鼠标控制器将鼠标事件路由到与形状关联的控制器。
  • 它实现了一些基本的行为功能,例如用户单击的位置以及基本的鼠标按下 -> 拖动 -> 鼠标抬起操作逻辑。稍后,除了拖动之外,还可以添加其他状态——例如调整大小。

实现目前相当基础,建立在我们之前所做的基础上。

const LEFT_MOUSE_BUTTON = 0;

class MouseController {
  constructor() {
    this.mouseDown = false;
    this.controllers = {};
    this.activeController = null;
  }

  // Create a map between then SVG element 
  // (by it's ID, so ID's must be unique) and its controller.
  attach(svgElement, controller) {
    var id = svgElement.getAttribute("id");
    this.controllers[id] = controller;
  }

  detach(svgElement) {
    var id = svgElement.getAttribute("id");
    delete this.controllers[id];
  }

  // Get the controller associated with the event and remember where the user clicked.
  onMouseDown(evt) {
    if (evt.button == LEFT_MOUSE_BUTTON) {
      evt.preventDefault();
      var id = evt.currentTarget.getAttribute("id");
      this.activeController = this.controllers[id];
      this.mouseDown = true;
      this.mouseDownX = evt.clientX;
      this.mouseDownY = evt.clientY;
    }
  }

  // If the user is dragging, call the controller's onDrag function.
  onMouseMove(evt) {
    evt.preventDefault();

    if (this.mouseDown && this.activeController != null) {
      this.activeController.onDrag(evt);
    }
  }

  // Any dragging is now done.
  onMouseUp(evt) {
    if (evt.button == LEFT_MOUSE_BUTTON) {
      evt.preventDefault();
      this.clearSelectedObject();
    }
  }

  // Any dragging is now done.
  onMouseLeave(evt) {
    evt.preventDefault();
    if (this.mouseDown && this.activeController != null) {
      this.activeController.onMouseLeave();
    }
  }

  clearSelectedObject() {
    this.mouseDown = false;
    this.activeController = null;
  }
}

形状对象模型

上图说明了我已经构建的形状对象模型。

SvgObject 类

这是根类,它跟踪:

  • 鼠标控制器(所有形状共享的对象)。
  • 形状的平移(它相对于原点的偏移量)。我看到过使用元素标签中的属性而不是解析 transform="translate(x, y)" 字符串来更新平移的各种技术,但我更愿意将其保留为形状类实例中的变量。
  • 事件注册方法,以便在删除形状时,可以解除其所有关联的事件处理程序。
  • 基本的拖动操作数学和其他事件的默认实现。
  • 将事件处理程序绑定到“this”(默认类实例)或指定的类实例(通常是鼠标控制器)。
class SvgObject {
  constructor(mouseController, svgElement) {
    this.mouseController = mouseController;
    this.events = [];

    // These two parameters are actually the shape TRANSLATION, 
    // not the absolute coordinates!!!
    this.X = 0;
    this.Y = 0;

    // These two parameters are the relative change during the CURRENT translation.
    // These is reset to 0 at the beginning of each move.
    // We use these numbers for translating the anchors because anchors are always 
    // placed with an initial translation of (0, 0)
    this.dragX = 0;
    this.dragY = 0;

    this.mouseController.attach(svgElement, this);
  }

  // Register the event so that when we destroy the object, 
  // we can unwire the event listeners.
  registerEvent(element, eventName, callbackRef) {
    this.events.push({ element: element, 
                       eventName: eventName, callbackRef: callbackRef });
  }

  destroy() {
    this.unhookEvents();
  }

  registerEventListener(element, eventName, callback, self) {
    var ref;

    if (self == null) {
      self = this;
    }

    element.addEventListener(eventName, ref = callback.bind(self));
    this.registerEvent(element, eventName, ref);
  }

  unhookEvents() {
    for (var i = 0; i < this.events.length; i++) {
      var event = this.events[i];
      event.element.removeEventListener(event.eventName, event.callbackRef);
    }

    this.events = [];
  }

  startMove() {
    this.dragX = 0;
    this.dragY = 0;
  }

  updatePosition(evt) {
    var mouseX = evt.clientX;
    var mouseY = evt.clientY;
    var mouseDX = mouseX - this.mouseController.mouseDownX;
    var mouseDY = mouseY - this.mouseController.mouseDownY;
    this.X += mouseDX;
    this.Y += mouseDY;
    this.mouseController.mouseDownX = mouseX;
    this.mouseController.mouseDownY = mouseY;
  }

  onMouseLeave(evt) { }
}

SvgElement 类

此类扩展了 SvgObject 类,提供了默认的鼠标事件注册和形状拖动实现。

class SvgElement extends SvgObject {
  constructor(mouseController, svgElement) {
    super(mouseController, svgElement);
    this.element = svgElement;
    this.registerEventListener(this.element, "mousedown", 
                               mouseController.onMouseDown, mouseController);
    this.registerEventListener(this.element, "mouseup", 
                               mouseController.onMouseUp, mouseController);
    this.registerEventListener(this.element, "mousemove", 
                               mouseController.onMouseMove, mouseController);
  }

  onDrag(evt) {
    this.updatePosition(evt);
    this.element.setAttribute("transform", "translate(" + this.X + "," + this.Y + ")");
  }
}

大多数时候,“this”(用于将事件回调绑定到处理类实例)将是鼠标控制器,但已提供了使用注册事件的类实例(这是默认行为)或我们想绑定处理程序的任何其他类实例的功能。

Circle 类

Circle 类演示了最基本的元素,其中所有默认行为都可以得到利用。它仅扩展 SvgElement 类。

class Circle extends SvgElement {
  constructor(mouseController, svgElement) {
    super(mouseController, svgElement);
  }
}

Surface 类

此类要复杂得多,因为它必须处理我们之前讨论过的关于滚动网格和网格上对象的所有内容。注意它如何扩展 mouseleave 事件。我们希望此事件通过鼠标控制器的测试,以确保在鼠标“离开”形状时正在进行拖动操作。根据所选形状(活动控制器),行为有所不同。

  • 在离开表面的情况下,surface 类实现为清除拖动操作。
  • 在离开形状的情况下,什么也不做,因为我们希望形状追赶鼠标位置。
class Surface extends SvgElement {
  constructor(mouseController, svgSurface, svgObjects) {
    super(mouseController, svgSurface);
    this.svgObjects = svgObjects;
    this.gridCellW = 80;
    this.gridCellH = 80;

    this.registerEventListener(this.svgSurface, "mouseleave", 
         mouseController.onMouseLeave, mouseController);
  }

  onDrag(evt) {
    this.updatePosition();
    var dx = this.X % this.gridCellW;
    var dy = this.Y % this.gridCellH;
    this.scrollSurface(dx, dy, this.X, this.Y);
  }

  onMouseLeave() {
    this.mouseController.clearSelectedObject();
  }

  scrollSurface(dx, dy, x, y) {
    // svgElement is the surface.
    this.svgElement.setAttribute("transform", "translate(" + dx + "," + dy + ")");
    this.svgObjects.setAttribute("transform", "translate(" + x + "," + y + ")");
  }

 function resizeGrid(lw, lh, sw, sh) {
    this.gridCellW = lw;
    this.gridCellH = lh;
    var elLargeGridRect = document.getElementById("largeGridRect");
    var elLargeGridPath = document.getElementById("largeGridPath");
    var elLargeGrid = document.getElementById("largeGrid");

    var elSmallGridPath = document.getElementById("smallGridPath");
    var elSmallGrid = document.getElementById("smallGrid");

    var elSvg = document.getElementById("svg");
    var elSurface = document.getElementById("surface");
    var elGrid = document.getElementById("grid");

    elLargeGridRect.setAttribute("width", lw);
    elLargeGridRect.setAttribute("height", lh);

    elLargeGridPath.setAttribute("d", "M " + lw + " 0 H 0 V " + lh);
    elLargeGrid.setAttribute("width", lw);
    elLargeGrid.setAttribute("height", lh);

    elSmallGridPath.setAttribute("d", "M " + sw + " 0 H 0 V " + sh);
    elSmallGrid.setAttribute("width", sw);
    elSmallGrid.setAttribute("height", sh);

    elGrid.setAttribute("x", -lw);
    elGrid.setAttribute("y", -lh);

    var svgW = elSvg.getAttribute("width");
    var svgH = elSvg.getAttribute("height");

    elSurface.setAttribute("width", svgW + lw * 2);
    elSurface.setAttribute("height", svgH + lh * 2);
    
    elSurface.setAttribute("x", -lw);
    elSurface.setAttribute("y", -lh);

    elSurface.setAttribute("width", svgW + lw * 2);
    elSurface.setAttribute("height", svgH + lh * 2);
  }
}

完成形状移动

为了让这一切正常工作,我们需要向 objects 组中的两个静态圆添加 ID。

<g id="objects" transform="translate(0, 0)">
  <circle id="circle1" cx="150" cy="100" r="40" stroke="black" 
   stroke-width="1" fill="#FFC0C0" />
  <circle id="circle2" cx="175" cy="125" r="40" stroke="black" 
   stroke-width="1" fill="#C0FFC0" />
</g>

然后我们创建类实例,并在构造函数中传入鼠标控制器实例和形状元素。

(function initialize() {
  var mouseController = new MouseController();
  var svgSurface = document.getElementById("surface");
  var svgObjects = document.getElementById("objects");
  var svgCircle1 = document.getElementById("circle1");
  var svgCircle2 = document.getElementById("circle2");
  var surface = new Surface(mouseController, svgSurface, svgObjects);
  surface.resizeGrid(100, 100, 20, 20);
  new Circle(mouseController, svgCircle1);
  new Circle(mouseController, svgCircle2);
})();

就是这样!但我们在哪里实际拖动形状?这可能被粗心的读者忽略了——它发生在 SvgElement 类中!

onDrag(evt) {
  this.updatePosition(evt);
  this.element.setAttribute("transform", "translate(" + this.X + "," + this.Y + ")");
}

任何派生自 SvgElement 的形状都继承了在表面上拖动的能力。例如,我们将添加一个矩形。

<rect id="nose" x="200" y="150" width="40" height="60" 
 stroke="black" stroke-width="1" fill="#C0C0FF" />

定义 Rectangle 类,它目前还没有覆盖任何东西,就像 Circle 一样。

class Rectangle extends SvgElement {
  constructor(mouseController, svgElement) {
    super(mouseController, svgElement);
  }
}

并实例化与关联的 SVG 元素。

new Rectangle(mouseController, document.getElementById("nose"));

然后我们得到(在移动形状后):

工具箱和动态形状创建

让我们通过添加一个 toolbox 来使我们的工作更有用,这样我们就可以将新形状拖放到表面上。toolbox 将是第三个组,使其成为最顶层的组,以便所有其他元素(网格和对象)始终渲染在 toolbox后面

<g id="toolboxGroup" x="0" y="0" width="200" height="480">
  <rect id="toolbox" x="0" y="0" width="200" height="480" 
   fill="#FFFFFF" stroke="black" stroke-width="0.5" />
  <rect id="toolboxRectangle" x="10" y="10" width="40" 
   height="40" stroke="black" stroke-width="1" fill="#FFFFFF" />
  <circle id="toolboxCircle" cx="85" cy="29" r="21" 
   stroke="black" stroke-width="1" fill="#FFFFFF" />
  <path id="toolboxDiamond" d="M 140 10 L 115 30 L 140 50 
   L 165 30 Z" stroke="black" stroke-width="1" fill="#FFFFFF" />
</g>

支持类

我们将需要一些额外的类(用红色文本表示)。

初始化

这是完整的初始化代码(我们之前移除的static形状)。

const SVG_ELEMENT_ID = "svg";
const SVG_SURFACE_ID = "surface";
const SVG_TOOLBOX_SURFACE_ID = "toolboxSurface";
const SVG_OBJECTS_ID = "objects";

(function initialize() {
  var mouseController = new MouseController();
  var svgSurface = getElement(SVG_SURFACE_ID);
  var svgToolboxSurface = getElementBy(SVG_TOOLBOX_SURFACE_ID);
  var svgObjects = getElement(SVG_OBJECTS_ID);

  var surface = new Surface(mouseController, svgSurface, svgObjects);
  surface.resizeGrid(100, 100, 20, 20);

  var toolboxController = new ToolboxController(mouseController);

  // So we can handle mouse drag operations 
  // when the mouse moves onto the toolbox surface...
  var toolboxSurface = new ToolboxSurface(toolboxController, svgToolboxSurface);

  // The surface mouse controller needs to know the toolbox controller to finish
  // a toolbox drag & drop operation.
  mouseController.setToolboxController(toolboxController);
  // To compensate for translations when doing a toolbox drag&drop
  mouseController.setSurfaceShape(surface);
  toolboxController.setSurfaceShape(surface);

  new ToolboxRectangle(toolboxController, getElement("toolboxRectangle"));
  new ToolboxCircle(toolboxController, getElement("toolboxCircle"));
  new ToolboxDiamond(toolboxController, getElement("toolboxDiamond"));
})();

注意一些变化(将在下文解释)。

  • 有 setter 方法可以告知鼠标控制器(负责表面)关于 toolbox 控制器和表面“shape”。这将在稍后解释。
  • toolbox 控制器派生自鼠标控制器,因为它是处理拖放操作以及“单击并放置”操作的专用鼠标控制器。
  • toolbox 控制器需要了解表面“shape”。
  • 最后,我们初始化 toolbox 形状的后端对象。

我还添加了一个简单的辅助方法,尽管我没有到处使用它,但它输入的字符更少。

function getElement(id) {
  var svg = document.getElementById(SVG_ELEMENT_ID);
  var el = svg.getElementById(id);

  return el;
}

此外,这里的最佳实践是我们在“svg”元素中查找元素 ID,而不是在文档中查找。

工具箱形状

工具箱形状都实现以下函数:

  • createElement - 此函数创建一个具有起始位置的元素,用于将其放置在工具箱旁边的表面上。这用于“单击并放置”操作。
  • createElementAt - 此函数在指定位置创建元素。这用于“拖放”操作。
  • createShape - 实例化关联的非工具箱形状。

例如(选择菱形,因为它稍微复杂一些):

class ToolboxDiamond extends SvgToolboxElement {
  constructor(toolboxController, svgElement) {
    super(toolboxController, svgElement);
  }

  // For click and drop
  createElement() {
    var el = super.createElement('path', 
      { d: "M 240 100 L 210 130 L 240 160 L 270 130 Z", 
        stroke: "black", "stroke-width": 1, fill: "#FFFFFF" });

    return el;
  }

  // For drag and drop
  createElementAt(x, y) {
    var points = [
      { cmd: "M", x: x-15, y: y-30 }, 
      { cmd: "L", x: x - 45, y: y }, 
      { cmd: "L", x: x-15, y: y + 30 }, 
      { cmd: "L", x: x + 15, y: y }];

    var path = points.reduce((acc, val) => acc = acc + 
               val.cmd + " " + val.x + " " + val.y, "");
    path = path + " Z";
    var el = super.createElement('path', 
             { d: path, stroke: "black", "stroke-width": 1, fill: "#FFFFFF" });

    return el;
  }

  createShape(mouseController, el) {
    var shape = new Diamond(mouseController, el);

    return shape;
  }
}

所有工具箱形状都遵循上述模板。

SvgToolboxElement 类

所有工具箱元素的基类将工具箱形状元素的鼠标事件连接到 toolboxController。它还提供了一个通用的方法来创建元素并设置其属性,包括为元素创建唯一 ID。

class SvgToolboxElement extends SvgObject {
  constructor(toolboxController, svgElement) {
    super(toolboxController, svgElement);
    this.toolboxController = toolboxController;
    this.registerEventListener(svgElement, "mousedown", 
                 toolboxController.onMouseDown, toolboxController);
    this.registerEventListener(svgElement, "mouseup", 
                 toolboxController.onMouseUp, toolboxController);
    this.registerEventListener(svgElement, "mousemove", 
                 toolboxController.onMouseMove, toolboxController);
    this.svgns = "<a href="http://www.w3.org/2000/svg">http://www.w3.org/2000/svg</a>";
  }

  // Create the specified element with the attributes provided in a key-value dictionary.
  createElement(elementName, attributes) {
    var el = document.createElementNS(this.svgns, elementName);

    // Create a unique ID for the element 
    // so we can acquire the correct shape controller
    // when the user drags the shape.
    el.setAttributeNS(null, "id", this.uuidv4());

    // Create a class common to all shapes so that, 
    // on file load, we can get them all and re-attach them
    // to the mouse controller.
    el.setAttributeNS(null, "class", SHAPE_CLASS_NAME);

    // Add the attributes to the element.
    Object.entries(attributes).map(([key, val]) => el.setAttributeNS(null, key, val));

    return el;
  }

  // From SO: <a href="https://stackoverflow.com/questions/105034/
  // create-guid-uuid-in-javascript">https://stackoverflow.com/questions/
  // 105034/create-guid-uuid-in-javascript</a>
  uuidv4() {
    return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => 
      (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16))
  }
}

ToolboxController 类

“单击并放置”和“拖放”行为的大部分在这里处理。请记住,此类派生自 MouseController,但它还需要使用表面鼠标控制器进行初始化——在这个类中拥有两个鼠标控制器会很有趣(或者可能令人困惑)!

构造函数

class ToolboxController extends MouseController {
  // We pass in the mouse controller that the surface is using so we can 
  // pass control over to the surface mouse controller when dragging a shape.
  constructor(mouseController) {
    super();
    this.mouseController = mouseController;
    this.draggingShape = false;
  }

正如注释所述,我们需要表面鼠标控制器,以便对于拖放操作,我们可以将形状拖动传递给表面鼠标控制器。拖动时,会创建一个非工具箱形状。此形状使用表面鼠标控制器来连接鼠标事件,这就是为什么我们需要将控制权移交给该控制器。另一种选择是告诉形状如何路由鼠标事件,并且一旦形状被放到表面上,事件就必须从工具箱控制器中分离出来,并附加到表面鼠标控制器。所以它只是将问题转移了一下。不过,可能还有更好的方法。

ToolboxController 的 onMouseDown 事件

onMouseDown(evt) {
  super.onMouseDown(evt);
}

我们让基类处理此行为。事件连接到 toolbox 控制器。

确定单击事件

isClick(evt) {
  var endDownX = evt.clientX;
  var endDownY = evt.clientY;

  var isClick = Math.abs(this.startDownX - endDownX) < TOOLBOX_DRAG_MIN_MOVE &&
                Math.abs(this.startDownY - endDownY) < TOOLBOX_DRAG_MIN_MOVE;

  return isClick;
}

虽然我们可以使用“onclick”事件,但我想要更精细地控制,并且我不想处理单击事件是在鼠标抬起“单击并拖动”后触发,还是在鼠标抬起“拖放”后触发。好吧,我仍然需要担心这一点,但仅仅在鼠标抬起事件中处理它(对我来说)更有意义。

ToolboxController 的 onMouseUp 事件

// If this is a "click", create the shape in a fixed location on the surface.
// If this is the end of a drag operation, place the shape on the surface at
// the current mouse position.
onMouseUp(evt) {
  if (this.isClick(evt) && !(this.activeController instanceof ToolboxSurface)) {
    // Treat this as a click.
    var el = this.activeController.createElement();

    // The new shape is attached to the grid surface's mouse controller.
    var shape = this.activeController.createShape(this.mouseController, el);
    this.setShapeName(el, shape);

    // Account for surface translation (scrolling)
    shape.translate(-this.surfaceShape.X, -this.surfaceShape.Y);

    // Use the mouse controller associated with the surface.
    this.dropShapeOnSurface(SVG_OBJECTS_ID, el, shape);
    this.mouseDown = false;
  }
}

请注意,如果用户单击 toolbox 表面本身,我们会阻止任何事情发生。

这是“单击并拖动”行为的核心。单击由在运动“窗口”内发生的鼠标抬起事件确定。之后:

  • “真实”形状被创建。
  • 为了考虑表面平移而进行平移。
  • 放置在表面上。
  • 清理。

将形状放置在表面上涉及将形状附加到“objects”组,并告知表面鼠标控制器有关该形状的信息。

dropShapeOnSurface(groupName, svgElement, shapeController) {
  getElement(groupName).appendChild(svgElement);
  this.mouseController.attach(svgElement, shapeController);
}

ToolboxController 的 onMouseMove 事件

// If the user is dragging, we create a new shape that can be dragged onto
// the surface. When the drag operation ends, the shape is transferred to the surface.
onMouseMove(evt) {
  if (this.mouseDown) {
    evt.preventDefault();
    if (this.draggingShape) {
      // Our toolbox surface picked up the event instead of the shape. 
      // Handle as if the shape got the event.
      super.onMouseMove(evt);
    } else {
      // Make sure a shape has been selected rather than dragging the toolbox surface.
      if (!(this.activeController instanceof ToolboxSurface)) {
        if (!this.isClick(evt)) {
          var endDownX = evt.clientX;
          var endDownY = evt.clientY;
          var el = this.activeController.createElementAt(endDownX, endDownY);
          // Here, because we're dragging, the shape needs to be attached 
          // to both the toolbox controller and the surface's mouse controller
          // so that if the user moves the shape too quickly, 
          // either the toolbox controller or the surface controller will pick it up.
          var shape = this.activeController.createShape(this.mouseController, el);
          this.setShapeName(el, shape);
          // set the shape name so we can map shape names to shape constructors 
          // when loading a diagram.
          el.setAttributeNS(null, "shapeName", shape.constructor.name);
          shape.mouseController.mouseDownX = endDownX;
          shape.mouseController.mouseDownY = endDownY + 30; // Offset so shape 
                                                            // is drawn under mouse.
          this.createShapeForDragging(el, shape);
          this.draggingShape = true;
        }
      }
    }
  }
}

这是最复杂的部分。上面的代码处理:

  • 如果用户移动形状太快,toolbox 表面可能会收到事件,因此我们处理默认行为,即更新形状的平移。
    • 一个警告——如果在单击窗口内移动鼠标,表面鼠标控制器还没有活动形状,因此什么都不会发生。
  • 我们也不希望用户拖动 toolbox 表面本身。至少目前还不行。也许以后这会滚动工具箱中的形状。
  • 只有当用户移动鼠标足够多,不被认为是单击事件时,拖动操作才开始。
  • 此时,形状被移交给表面鼠标控制器。

创建用于拖动的形状时,它实际上附加到 toolbox SVG 组,因此它停留在前景中,直到用户将其移动到网格上。稍后,我们必须将形状移动到 objects SVG

// Place the shape into the toolbox group so it's topmost, 
// and attach the shape to mouse our toolbox mouse controller
// and the surface mouse controller so off-shape mouse events are handled correctly.
createShapeForDragging(el, shape) {
  // The shape is now under the control of the surface mouse controller 
  // even though we added it to our toolbox group.
  // This is because the shape wires up the surface mouse controller events.
  // The only thing the toolbox controller will see is the onMouseMove 
  // when the user moves the mouse too fast and the
  // mouse events end up being handled by the toolbox controller 
  // (or, if over the surface, the surface controller.)
  this.dropShapeOnSurface(SVG_TOOLBOX_ID, el, shape);

  // We need to know what shape is being moved, 
  // in case we (the tookbox controller) start to receive mouse move events.
  this.attach(el, shape);
  this.activeController = shape;

  // The surface mouse controller also needs to know what shape is active 
  // and that we are in the "mouse down" state.
  this.mouseController.activeController = shape;
  this.mouseController.mouseDown = true;
}

此时,表面鼠标控制器已获得控制权!

MouseController 的 onMouseUp 事件

// Any dragging is now done.
onMouseUp(evt) {
  if (evt.button == LEFT_MOUSE_BUTTON && this.activeController != null) {
    evt.preventDefault();
    // Allows the toolbox controller to finish the drag & drop operation.
    this.toolboxController.mouseUp();
    this.clearSelectedObject();
  }
}

如上所述,当拖动操作开始时,表面鼠标控制器控制着形状。当收到鼠标抬起事件时,它会给 toolbox 控制器机会来完成任何 toolbox 拖放操作。

// Handles end of drag & drop operation, otherwise, 
// does nothing -- toolbox item was clicked.
mouseUp() {
  if (this.draggingShape) {
    // Account for surface translation (scrolling)
    this.activeController.translate(-this.surfaceShape.X, -this.surfaceShape.Y);

    var el = this.activeController.svgElement;

    // Move element out of the toolbox group and into the objects group.
    getElement(SVG_TOOLBOX_ID).removeChild(el);
    getElement(SVG_OBJECTS_ID).appendChild(el);
    this.dragComplete(el);
  }
}

在这里,元素从最顶层的्री前景位置(在 toolbox SVG 组中)移动到 objects SVG 组。我们还必须考虑任何表面平移,以便形状在用户完成拖动操作时准确地出现在其位置。最后,我们清理 toolbox 控制器的状态。

dragComplete(el) {
  this.draggingShape = false;
  this.detach(el);
  this.mouseDown = false;
  this.activeController = null;
}

哇!全部完成!(除了也许让形状以更均匀的位置单击并放置。)

本地保存和恢复图表

我们现在有很多东西需要处理,在做任何其他事情之前,我认为最好看看如何将形状本地保存和加载。我在这里提出的实现非常基础——它会自动启动一个下载,该下载将进入下载文件夹,在 Chrome 中,任何现有文件都会导致文件名后附加 (n),其中 n 是递增的数字。在某个时候,我将使用 HTML5 FileSystem API(WebAPI 的一部分)来增强此功能。但现在,它写入data.svg ,在 load 时,让您选择目录和文件。与其专注于保存/加载图表的 UI,不如关注实际保存和加载图表本身的机制。在弄清楚如何编写 JavaScript、处理 SVG DOM 和修复错误的过程中,这花了整整两天!

首先,我在页面顶部添加了保存加载按钮。

<div>
  <!-- <a href="https://stackoverflow.com/questions/1944267/
   how-to-change-the-button-text-of-input-type-file%20--">
   https://stackoverflow.com/questions/1944267/
   how-to-change-the-button-text-of-input-type-file --</a>>
  <!-- creates a hidden file input on routes the button to clicking on that tag -->
  <button onclick="saveSvg()">Save</button>
  <button onclick="document.getElementById('fileInput').click();">Load</button>
  <input type="file" id="fileInput" style="display:none;"/>
</div>

这里的窍门是,为了避免文件输入元素的默认行为,我们将输入元素隐藏起来,正如 SO 链接向我展示的那样。

保存图表

将 SVG 本地保存需要一些研究,并产生了这段代码。

document.getElementById(FILE_INPUT).addEventListener('change', readSingleFile, false);
// https://stackoverflow.com/questions/23582101/
// generating-viewing-and-saving-svg-client-side-in-browser
function saveSvg() {
  var svg = getElement(SVG_OBJECTS_ID);
  // <a href="https://mdn.org.cn/en-US/docs/Web/API/XMLSerializer">
  // https://mdn.org.cn/en-US/docs/Web/API/XMLSerializer</a>
  var serializer = new XMLSerializer();
  var xml = serializer.serializeToString(svg);
  // Prepend the XML with other things we want to save, 
  // like the surface translation and grid spacing.
  xml = "<diagram>" + surface.serialize() + "</diagram>" + xml;
  var blob = new Blob([xml], { 'type': "image/svg+xml" });

  // We're using <a href="https://github.com/eligrey/FileSaver.js/">
  // https://github.com/eligrey/FileSaver.js/</a>
  // but with the "export" (a require node.js thing) removed.
  // There are several forks of this, not sure if there's any improvements in the forks.
  saveAs(blob, FILENAME);
}

正如注释所示,我正在使用 FileSaver.js,由“eligrey”编写。感谢开源——它在 Chrome 和 Edge(我测试过的两个浏览器)中运行,并且还支持其他浏览器的细微差别。特别值得注意的是:

  • 我们让表面(以及将来可能出现的其他对象)有机会保存其状态。表面需要保存:
    • 它的平移。
    • 网格间距。
  • 这些数据以 XML string 的形式添加到 SVG 数据的前面。

在 Surface 类中,这是这样实现的:

// Create an XML fragment for things we want to save here.
serialize() {
  var el = document.createElement("surface");
  // DOM adds elements as lowercase, so let's just start with lowercase keys.
  var attributes = {x : this.X, y : this.Y, 
                    gridcellw : this.gridCellW, gridcellh : this.gridCellH, 
                    cellw : this.cellW, cellh : this.cellH}
  Object.entries(attributes).map(([key, val]) => el.setAttribute(key, val));
  var serializer = new XMLSerializer();
  var xml = serializer.serializeToString(el);

  return xml;
}

那是容易的部分——文件被下载到浏览器的默认下载位置。

加载图表

第一步是实际在本地读取文件数据。

// https://w3c.github.io/FileAPI/
// https://stackoverflow.com/questions/3582671/
// how-to-open-a-local-disk-file-with-javascript
// Loading the file after it has been loaded doesn't trigger 
// this event again because it's
// hooked up to "change", and the filename hasn't changed!
function readSingleFile(e) {
  var file = e.target.files[0];
  var reader = new FileReader();
  reader.onload = loadComplete;
  reader.readAsText(file);
  // Clears the last filename(s) so loading the same file will work again.
  document.getElementById(FILE_INPUT).value = "";
}

此函数使用 WebAPI 的 FileReader 类。最有趣的是清除输入元素中的文件名。正如注释所示,如果我们不这样做,如果文件名相同,我们将无法重新加载图表。这对测试非常烦人。

加载完成后(我没有实现任何错误检查/验证文件是否真的是图表文件)。

function loadComplete(e) {
  var contents = e.target.result;
  var endOfDiagramData = contents.indexOf(END_OF_DIAGRAM_TAG);
  var strDiagram = contents.substr(0, endOfDiagramData).substr
                   (START_OF_DIAGRAM_TAG.length);
  var xmlDiagram = stringToXml(strDiagram);
  // Deserialize the diagram's surface XML element 
  // to restore grid spacing and grid translation.
  surface.deserialize(xmlDiagram);
  var svgData = contents.substr(endOfDiagramData + END_OF_DIAGRAM_TAG.length)
  replaceObjects(contents);
}

发生了一些事情:

  • 数据(作为 string)被分离为图表“状态”信息——目前只有表面状态——以及 SVG 数据。
  • 表面状态被恢复。
  • objects”元素被替换。

表面状态被反序列化和恢复。

// Deserialize the xml fragment that contains the surface translation 
// and grid dimensions on a file load.
deserialize(xml) {
  var obj = xmlToJson(xml);
  var attributes = obj.surface.attributes;
  // Note the attributes, because they were serialized by the DOM, are all lowercase.
  // OK to assume all ints?
  this.X = parseInt(attributes.x);
  this.Y = parseInt(attributes.y);
  this.gridCellW = parseInt(attributes.gridcellw);
  this.gridCellH = parseInt(attributes.gridcellh);
  this.cellW = parseInt(attributes.cellw);
  this.cellH = parseInt(attributes.cellh);
  var dx = this.X % this.gridCellW;
  var dy = this.Y % this.gridCellH;
  this.resizeGrid(this.gridCellW, this.gridCellH, this.cellW, this.cellH);
  this.svgElement.setAttribute("transform", "translate(" + dx + "," + dy + ")");
}

反序列化器 xmlToJson 在注释的链接处找到。我对链接中描述的代码做了一个小的修改。

function stringToXml(xmlStr) {
  // <a href="https://stackoverflow.com/a/3054210/2276361">
  // https://stackoverflow.com/a/3054210/2276361</a>
  return (new window.DOMParser()).parseFromString(xmlStr, "text/xml");
}

// https://davidwalsh.name/convert-xml-json
function xmlToJson(xml) {
  var obj = {};

  if (xml.nodeType == 1) { // element
  // do attributes
    if (xml.attributes.length > 0) {
      obj["attributes"] = {};

      for (var j = 0; j < xml.attributes.length; j++) {
        var attribute = xml.attributes.item(j);
        obj["attributes"][attribute.nodeName] = attribute.nodeValue;
      }
   }
  } else if (xml.nodeType == 3) { // text
    obj = xml.nodeValue;
  }

  // do children
  if (xml.hasChildNodes()) {
    for(var i = 0; i < xml.childNodes.length; i++) {
      var item = xml.childNodes.item(i);
      var nodeName = item.nodeName;

      if (typeof(obj[nodeName]) == "undefined") {
        obj[nodeName] = xmlToJson(item);
      } else {
        if (typeof(obj[nodeName].push) == "undefined") {
          var old = obj[nodeName];
          obj[nodeName] = [];
          obj[nodeName].push(old);
        }

      obj[nodeName].push(xmlToJson(item));
      }
    }
  }

  return obj;
};

接下来,替换 objects 元素。为此,我将 objects 元素包装在一个组中,以便可以操作该包装组的子元素。

<!-- Also, we create an outer group so that on file load, we can remove
     the "objectGroup" and replace it with what got loaded. -->
<g id="objectGroup">
  <g id="objects" transform="translate(0, 0)"></g>
</g>

JavaScript 代码如下:

// Replace "objects" with the contents of what got loaded.
function replaceObjects(contents) {
  mouseController.destroyAllButSurface();
  var objectGroup = getElement(OBJECT_GROUP_ID);
  var objects = getElement(SVG_OBJECTS_ID);
  objectGroup.removeChild(objects);
  // <a href="https://stackoverflow.com/questions/38985998/
  // insert-svg-string-element-into-an-existing-svg-tag">
  // https://stackoverflow.com/questions/38985998/
  // insert-svg-string-element-into-an-existing-svg-tag</a>
  objectGroup.innerHTML = contents;
  createShapeControllers();
  // re-acquire the objects element after adding the contents.
  var objects = getElement(SVG_OBJECTS_ID);
  surface.svgObjects = objects;
}

为了替换 objects 元素,发生了一些事情:

  • 当前表面上的所有对象(除了 surface)都被“销毁”。这意味着:
    • 它们的事件被解绑。
    • 它们被从鼠标控制器中分离。
  • objects 子元素被移除。
  • 外部组的内部 HTML 被从文件中加载的 SVG 数据替换。
  • 接下来,需要实例化后备的形状控制器类。这包括:
    • 连接它们的事件。
    • 将它们附加到鼠标控制器。
    • 修复它们的位置,以便对象知道它们如何被平移。
  • 最后:
    • 获取新的 objects 元素。
    • 表面控制器被告知新的 objects 元素。

为什么我们要销毁除 surface 元素以外的所有 SVG 元素?surface 元素实际上是我们的网格占位符元素,并处理表面的滚动。我们不需要替换该元素,因此我们忽略它。

destroyAllButSurface() {
  Object.entries(this.controllers).map(([key, val]) => {
    if (!(val instanceof Surface)) {
      val.destroy();
    }
  });
}

创建 shape 控制器是通过查找来完成的,以将 shapename 属性映射到实例化正确 shape 控制器的函数。

var elementNameShapeMap = {
  Rectangle: (mouseController, svgElement) => new Rectangle(mouseController, svgElement),
  Circle: (mouseController, svgElement) => new Circle(mouseController, svgElement),
  Diamond: (mouseController, svgElement) => new Diamond(mouseController, svgElement)
};

顺便说一句,shapename 属性是从哪里来的?当 shape 被 toolbox 单击并放置或拖放时,它会被创建。在 ToolboxController 类中:

setShapeName(el, shape) {
  // set the shape name so we can map shape names to shape constructors 
  // when loading a diagram.
  // <a href="https://stackoverflow.com/questions/1249531/
  // how-to-get-a-javascript-objects-class">https://stackoverflow.com/
  // questions/1249531/how-to-get-a-javascript-objects-class</a>
  el.setAttributeNS(null, SHAPE_NAME_ATTR, shape.constructor.name);
}

同样,在 SvgToolboxElement 类中,我们添加了一个 class 属性,可以轻松获取 objects 组中的所有 SVG 元素。

// Create a class common to all shapes so that, on file load, 
// we can get them all and re-attach them
// to the mouse controller.
el.setAttributeNS(null, "class", SHAPE_CLASS_NAME);

用于创建 shape 控制器的 JavaScript。

// The difficult part -- creating the shape controller based 
// on the element's shapeName attribute to the shape controller class counterpart.
function createShapeControllers() {
  var els = getElements(SHAPE_CLASS_NAME);

  for (let el of els) { // note usage "of" - ES6. 
                        // note usage "let" : scope limited to block.
    let shapeName = el.getAttribute(SHAPE_NAME_ATTR);
    let creator = elementNameShapeMap[shapeName];
    let shape = creator(mouseController, el);
    // Annoyingly, we DO have to parse the translation 
    // to set the X and Y properties of the shape!
    let transform = el.getAttribute("transform");
    let transforms = parseTransform(transform);
    let translate = transforms["translate"];
    // We assume integers?
    shape.X = parseInt(translate[0]);
    shape.Y = parseInt(translate[1]);
  }
}

令人讨厌的是,我们必须实际解析变换,因为我没有将属性添加到 SVG 元素中供 shape 控制器使用。这是通过我在 SO 上找到的一些代码完成的。

// https://stackoverflow.com/questions/17824145/
// parse-svg-transform-attribute-with-javascript
function parseTransform(transform) {
  var transforms = {};
  for (var i in a = transform.match(/(\w+\((\-?\d+\.?\d*e?\-?\d*,?)+\))+/g)) {
    var c = a[i].match(/[\w\.\-]+/g);
    transforms[c.shift()] = c;
  }

  return transforms;
}

现在我们有了一个简单的保存和加载图表的机制。当添加新形状时,只需要更新 elementNameShapeMap

线条和锚点

我希望为本文创建的最后一项功能是绘制可以连接形状的简单线条。说起来容易做起来难,因为这意味着我们需要一些额外的图表状态信息,以便我们知道哪些线条连接到哪些形状,以便在形状移动时,线条也能更新。我甚至还没有处理 箭头

选择线条的复杂性。

在创建了 LineToolboxLine 类,遵循与其他 shape 控制器类相同的模板,并将线条元素添加到 toolbox 组并连接 shape 控制器之后。

<line id="toolboxLine" x1="10" y1="70" x2="50" y2="110" 
 stroke="black" stroke-width="1" fill="#FFFFFF" />

new ToolboxLine(toolboxController, getElement(TOOLBOX_LINE_ID));

我们遇到了第一个问题——实际上几乎不可能选择线条,因为线条太细了——你必须精确地单击线条的像素才能选择它。解决此问题的最佳方法似乎是创建一个包含两条线的组:实际线条和一条具有较大宽度的透明线条(参考)。这是我们想要在形状位于图上时做的事情,但对于工具箱,我们不希望用户如此精确,因此,我们将创建一个透明矩形,以便在视觉上,toolbox 线条形状形成的任何地方都可以正常工作。回到 toolbox 组。

这效果很好(注释掉的透明线供将来参考)。

<g id="toolboxLine">
  <line id="line" x1="10" y1="70" x2="50" y2="110" stroke="black" 
   stroke-width="1" fill="#FFFFFF" />
  <rect id="hiddenLine" x="10" y="70" width="40" height="40" 
   stroke="black" stroke-opacity="0" fill-opacity="0"/>
  <!--<line id="line2" x1="10" y1="70" x2="50" y2="110" 
   fill="#FFFFFF" stroke-opacity="0" fill-opacity="0"/>-->
</g>

既然我们已经开始做了,我们可以预见下一个问题——单击并拖动线条的端点,以便更容易地更改线条的长度和方向。让我们看看这个组是如何渲染的。

<g id="toolboxLine">
  <rect id="hiddenLine" x="10" y="70" width="40" height="40" 
   stroke="black" stroke-opacity="0" fill-opacity="0"/>
  <line id="line2" x1="10" y1="70" x2="50" y2="110" 
   fill="#FFFFFF" stroke="black" stroke-width="20"/>
  <line id="line" x1="10" y1="70" x2="50" y2="110" 
   stroke="red" stroke-width="1" fill="#FFFFFF" />
</g>

请注意,描边宽度不会导致较长的线超出红色线(描边宽度为 1)的范围。从 UI 的角度来看,这意味着用户必须“进入”线条内部才能选择线条的端点——选择靠近“外部”的线条端点将不会导致线条处理鼠标事件。同样,我们可以通过在线条端点周围创建透明矩形来解决这个问题,这些矩形将代表选择线条端点的可单击区域。不透明渲染时,会得到这个——红色区域是 toolbox 中选择形状以及在表面上绘制线条后选择端点的可单击区域。

<g id="toolboxLine">
  <rect id="lineHiddenSelectionArea" x="10" y="70" width="40" 
   height="40" stroke="red" stroke-width="1" fill="#FFFFFF"/> 
  <rect id="endpoint1" transform="translate(10, 70)" x="-5" y="-5" 
   width="10" height="10" stroke="red" stroke-width="1" fill="#FFFFFF"/>
  <rect id="endpoint2" transform="translate(50, 110)" x="-5" y="-5" 
   width="10" height="10" stroke="red" stroke-width="1" fill="#FFFFFF" />
  <line id="line" x1="10" y1="70" x2="50" y2="110" fill="#FFFFFF" 
   stroke="black" stroke-width="1" />
</g>

实际上,当在表面上创建线条时,我们将以稍有不同的方式处理锚点,而不是将它们添加到 toolboxLine 组中。

  • 对于 toolbox,我们不需要端点矩形或较大的透明描边线。
  • 对于图表表面的线条,我们需要端点矩形和较大的透明描边线,以便鼠标可以轻松选择。
    • 实际上,当在表面上创建线条时,我们将以稍有不同的方式处理锚点,而不是将它们添加到 toolboxLine 组中,如下一节所述。

这意味着,在放置元素到表面时,我们需要做一些专门的工作(仅显示 createElement 函数)。

createElement() {
  var el = super.createElement('g', {});
  el.appendChild(super.createChildElement('line', 
  { x1: 240, y1: 100, x2: 300, y2: 160, "stroke-width": 20, stroke: "black", 
    "stroke-opacity": "0", "fill-opacity": "0" }));
  el.appendChild(super.createChildElement('line', { x1: 240, y1: 100, x2: 300, 
     y2: 160, fill: "#FFFFFF", stroke: "black", "stroke-width": 1 }));

  return el;
}

这里有一个重要的注意事项:

  • 描边“color”必须在透明线中初始化,否则外部组不包含它在区域内,较宽的透明线将不可选!

我还必须编写一个 createChildElement 函数,它仅在不创建 class 属性的情况下与 createElement 不同,因为我们不希望这些子元素映射到形状控制器——只映射到外部组。

我们现在可以单击并放置线条到表面上,然后拖动线条。

Point 类和 Shape Rectangle

此时,我将简要地偏离主题——是时候创建一个 Point 类以及帮助我们获取形状对角线角函数的。每个形状都有细微差别。线条有 (x1,y1)(x2,y2) 属性,矩形有 (x, y)(width, height) 属性,圆形有 (cx, cy)(r) 属性,路径,嗯,有一个边界矩形。我想统一这个混乱。我们将定义一个简单的 Point 类。

class Point {
  constructor(x, y) {
    this.X = x;
    this.Y = y;
  }

  translate(x, y) {
    this.X += x;
    this.Y += y;

    return this;
  }
}

是的,已经有一个 SVGPoint 对象以及一个 DOMPoint 对象,但我两者都没有使用,因为我想要这些对象不提供的行为,比如平移。

现在我们可以实现获取每个形状的左上角和右下角,平移到形状的绝对坐标。

矩形

getULCorner() {
  var p = new Point(+this.svgElement.getAttribute("x"), 
                    +this.svgElement.getAttribute("y"));
  p = this.getAbsoluteLocation(p);

  return p;
}
getLRCorner() {
  var p = new Point(+this.svgElement.getAttribute("x") + 
                    +this.svgElement.getAttribute("width"), 
                    +this.svgElement.getAttribute("y") + 
                    +this.svgElement.getAttribute("height"));
  p = this.getAbsoluteLocation(p);

  return p;
}

getULCorner() {
  var p = new Point(+this.svgElement.getAttribute("cx") - 
                    +this.svgElement.getAttribute("r"),
                    +this.svgElement.getAttribute("cy") - 
                    +this.svgElement.getAttribute("r"));
  p = this.getAbsoluteLocation(p);

  return p;
}

getLRCorner() {
  var p = new Point(+this.svgElement.getAttribute("cx") + 
                    +this.svgElement.getAttribute("r"), 
                    +this.svgElement.getAttribute("cy") + 
                    +this.svgElement.getAttribute("r"));
  p = this.getAbsoluteLocation(p);

  return p;
}

Line

getULCorner() {
  var line = this.svgElement.children[0];
  var p = new Point(+line.getAttribute("x1"), +line.getAttribute("y1"));
  p = this.getAbsoluteLocation(p);

  return p;
}

getLRCorner() {
  var line = this.svgElement.children[0];
  var p = new Point(+line.getAttribute("x2"), +line.getAttribute("y2"));
  p = this.getAbsoluteLocation(p);

  return p;
}

菱形。

路径很有趣,因为 getBoundingClientRect 返回形状的已经平移过的位置。顾名思义,该函数返回客户端(屏幕坐标)位置,所以我们必须将其平移到根 SVG 元素的位置。

getULCorner() {
  var rect = this.svgElement.getBoundingClientRect();
  var p = new Point(rect.left, rect.top);
  this.translateToSvgCoordinate(p);

  return p;
}

getLRCorner() {
  var rect = this.svgElement.getBoundingClientRect();
  var p = new Point(rect.right, rect.bottom);
  this.translateToSvgCoordinate(p);

  return p;
}

以及两个辅助函数。

getAbsoluteLocation(p) {
  p.translate(this.X, this.Y);
  p.translate(this.mouseController.surface.X, this.mouseController.surface.Y);

  return p;
}

// https://stackoverflow.com/questions/22183727/
// how-do-you-convert-screen-coordinates-to-document-space-in-a-scaled-svg
translateToSvgCoordinate(p) {
  var svg = document.getElementById(SVG_ELEMENT_ID);
  var pt = svg.createSVGPoint();
  var offset = pt.matrixTransform(svg.getScreenCTM().inverse());
  p.translate(offset.x, offset.y);
}

锚点

接下来,我们希望能够更改线条的长度和方向。这将是一个有用的练习,因为其行为类似于调整形状大小。在 WinForm 应用程序 FlowSharp 中,我让每个形状确定用于调整大小的锚点。我们在这里也将做同样的事情。我们终于有东西可以在形状的控制器类中实现了!想法是,当鼠标悬停在形状上时,锚点会神奇地出现,以便用户知道在哪里单击并拖动以修改形状。在鼠标控制器中,我们将添加一个 onMouseOver 事件处理程序,并将其添加到形状控制器 SvgElement 基类中连接的事件中。

this.registerEventListener(svgElement, "mouseover", 
                           mouseController.onMouseOver, mouseController);

事件处理程序。

onMouseOver(evt) {
  var id = evt.currentTarget.getAttribute("id");
  var hoverShape = this.controllers[id];
  
  // On drag & drop, anchors are not shown because of this first test.
  // We do this test so that if the user moves the mouse quickly, we don't
  // re-initialize the anchors when the shape catches up (resulting in
  // a mousemove event again.
  if (this.activeController == null) {
    if (hoverShape instanceof SvgElement &&
        !(hoverShape instanceof ToolboxController) &&
        !(hoverShape instanceof Surface)) {
      this.displayAnchors(hoverShape);
    } else {
      this.removeAnchors();
    this.anchors = [];
    }
  }
}

displayAnchors(hoverShape) {
  var anchors = hoverShape.getAnchors();
  this.showAnchors(anchors);
  this.anchors = anchors;
}

锚点将显示在“objects”和“toolbox”组之间,因此锚点位于所有其他形状的顶部,但位于 toolbox 的下方。

<g id="objectGroup">
  <g id="objects" transform="translate(0, 0)"></g>
</g>
<g id="anchors"></g>
<g id="toolbox" x="0" y="0" width="200" height="480">
...

创建锚点。

showAnchors(anchors) {
  // not showing?
  if (this.anchors.length == 0) {
    var anchorGroup = getElement(ANCHORS_ID);
    // Reset any translation because the next mouse hover will set 
    // the anchors directly over the shape.
    anchorGroup.setAttribute("transform", "translate(0, 0)");

    anchors.map(anchor => {
      var el = this.createElement("rect", 
        { x: anchor.X - 5, y: anchor.Y - 5, width: 10, height: 10, 
          fill: "#FFFFFF", stroke: "black", "stroke-width": 0.5});
      anchorGroup.appendChild(el);
    });
  }
}

// TODO: Very similar to SvgToolboxElement.createElement. 
// Refactor for common helper class?
createElement(name, attributes) {
  var svgns = "<a href="http://www.w3.org/2000/svg">http://www.w3.org/2000/svg</a>";
  var el = document.createElementNS(svgns, name);
  el.setAttribute("id", Helpers.uuidv4());
  Object.entries(attributes).map(([key, val]) => el.setAttributeNS(null, key, val));

  return el;
}

移除 anchors

removeAnchors() {
  // already showing?
  if (this.anchors.length > 0) {
    var anchorGroup = getElement(ANCHORS_ID);

    // <a href="https://stackoverflow.com/questions/3955229/
    // remove-all-child-elements-of-a-dom-node-in-javascript">
    // https://stackoverflow.com/questions/3955229/
    // remove-all-child-elements-of-a-dom-node-in-javascript</a>
    // Will change later.
    anchorGroup.innerHTML = "";
    // Alternatively:
    //while (anchorGroup.firstChild) {
    // anchorGroup.removeChild(anchorGroup.firstChild);
    //}
  }
}

请注意上面 showAnchors 中在 anchors 首次绘制时重置锚组平移。一次修正是,当对象在 SvgElement 类中拖动时,我们现在还需要平移 anchors 组。

onDrag(evt) {
  this.updatePosition(evt);
  this.svgElement.setAttribute("transform", "translate(" + this.X + "," + this.Y + ")");
  getElement(ANCHORS_ID).setAttribute("transform", 
             "translate(" + this.X + "," + this.Y + ")");
  getElement(ANCHORS_ID).setAttribute("transform", 
             "translate(" + this.dragX + "," + this.dragY + ")");
}

dragXdragY 坐标在 MouseControllermousedown 事件中重置。

onMouseDown(evt) {
  if (evt.button == LEFT_MOUSE_BUTTON) {
    evt.preventDefault();
    var id = evt.currentTarget.getAttribute("id");
    this.activeController = this.controllers[id];
    this.mouseDown = true;
    this.mouseDownX = evt.clientX;
    this.mouseDownY = evt.clientY;
    this.startDownX = evt.clientX;
    this.startDownY = evt.clientY;
    this.activeController.startMove();  // <----- added this
  }
}

我们这样做是因为 anchor 组始终以 (0,0) 的平移开始,所以我们需要知道相对于当前拖动操作的平移。还有两个细微差别:

  • 由于用户可以快速移动鼠标(离开形状),因此 mouseover 事件会重新触发(鼠标离开形状,当形状追赶时,mouseover 事件会再次触发)。为此,我们检查是否存在活动的形状控制器(即正在拖动形状),这在用户单击形状时设置。这带来的副作用实际上是一个不错的——当形状从工具箱拖放时,不会显示锚点,因为表面鼠标控制器有一个活动的形状控制器。
  • 但是,这有一个无意的副作用,即在拖放操作后形状被放置,并且鼠标仍然位于刚刚放置的形状上时,不会显示锚点。

为了解决第二个问题,toolbox 控制器必须在拖放操作完成后启动锚点的显示。

dragComplete(el) {
  this.draggingShape = false;
  this.detach(el);
  this.mouseDown = false;
  this.mouseController.displayAnchors(this.activeController); // <--- I added this line
  this.activeController = null;
}

形状现在有锚点了!我们还获得了视觉效果,向用户展示了如果用户想拖动形状,将选择哪个形状。

线条长度和方向

现在我们有了显示锚点,我们必须让锚点起作用。我们将专注于线条形状。请注意上图,锚点画在线条的上方。这有助于我们选择锚点进行拖动,而不是整个线条。如果线条位于锚点的上方,用户就可以精确地单击线条,错过锚点。

首先,我们需要能够指定当锚点移动时调用的函数。这是传递回鼠标控制器以显示锚点时锚数组中的一个更改。

getAnchors() {
  var corners = this.getCorners(); 
  var anchors = [{ anchor: corners[0], onDrag: this.moveULCorner.bind(this) }, 
                 { anchor: corners[1], onDrag: this.moveLRCorner.bind(this) }];

  return anchors;
}
  • 请注意 bind,以便事件处理函数中的“this”是 Line 对象。唉。

Line 类实现了处理程序(注意额外的锚点参数,我稍后会讨论)。

// Move the (x1, y1) coordinate.
moveULCorner(anchor, evt) {
  // Use movementX and movementY - this is much better than 
  // dealing with the base class X or dragX stuff.
  // Do both the transparent line and the visible line.
  this.moveLine("x1", "y1", this.svgElement.children[0], evt.movementX, evt.movementY);
  this.moveLine("x1", "y1", this.svgElement.children[1], evt.movementX, evt.movementY);
  this.moveAnchor(anchor, evt.movementX, evt.movementY);
}

// Move the (x2, y2) coordinate.
moveLRCorner(anchor, evt) {
  this.moveLine("x2", "y2", this.svgElement.children[0], evt.movementX, evt.movementY);
  this.moveLine("x2", "y2", this.svgElement.children[1], evt.movementX, evt.movementY);
  this.moveAnchor(anchor, evt.movementX, evt.movementY);
}

moveLine(ax, ay, line, dx, dy) {
  var x1 = +line.getAttribute(ax) + dx;
  var y1 = +line.getAttribute(ay) + dy;
  line.setAttribute(ax, x1);
  line.setAttribute(ay, y1);
}

请注意,我们必须同时移动透明线条和可见线条。

  • 在编码的这一点,我了解到事件的 movementXmovementY 属性,如果我早知道这些,我的某些代码实现就会不同了!

moveAnchor 函数将是所有形状共有的,因此它位于 SvgElement 基类中。

moveAnchor(anchor, dx, dy) {
  var tx = +anchor.getAttribute("tx") + dx;
  var ty = +anchor.getAttribute("ty") + dy;
  anchor.setAttribute("transform", "translate(" + tx + "," + ty + ")");
  anchor.setAttribute("tx", tx);
  anchor.setAttribute("ty", ty);
}

接下来,我们需要一个实际的 Anchor 形状类。

class Anchor extends SvgObject {
  constructor(anchorController, svgElement, onDrag) {
    super(anchorController, svgElement);
    this.wireUpEvents(svgElement);
    this.onDrag = onDrag;
  }

  wireUpEvents(svgElement) {
    // The mouse controller is actually the derived anchor controller.
    this.registerEventListener(svgElement, "mousedown", 
                 this.mouseController.onMouseDown, this.mouseController);
    this.registerEventListener(svgElement, "mousemove", 
                 this.mouseController.onMouseMove, this.mouseController);
    this.registerEventListener(svgElement, "mouseup", 
                 this.mouseController.onMouseUp, this.mouseController);
    this.registerEventListener(svgElement, "mouseleave", 
                 this.mouseController.onMouseLeave, this.mouseController);
  }
}

同样,为了处理表面在锚点被拖动“太快”时接收到的鼠标事件,AnchorController 模仿(正确地)表面鼠标控制器,使其认为它正在移动一个锚点元素。

class AnchorController extends MouseController {
  constructor(mouseController) {
    super();
    // For handling dragging an anchor but the surface or shape gets the mousemove events.
    this.mouseController = mouseController;
  }

  onMouseDown(evt) {
    super.onMouseDown(evt);
    // For handling dragging an anchor but the surface or shape gets the mousemove events.
    this.mouseController.mouseDown = true;
    this.mouseController.activeController = this.activeController;
  }

  onMouseUp(evt) {
    super.onMouseUp(evt);
    // For handling dragging an anchor but the surface or shape gets the mousemove events.
    this.mouseController.mouseDown = false;
    this.mouseController.activeController = null;
  }

  // Ignore mouse leave events when dragging an anchor.
  onMouseLeave(evt) { }
}
  • 作为旁注,这越来越烦人,需要实现,并且表明潜在的设计缺陷。

真正的有趣部分是当绘制锚点时,锚点控制器、锚点形状和拖动事件处理程序是如何设置的。这是对前面描述的 showAnchors 函数初版的修改。

showAnchors(anchors) {
  // not showing?
  if (this.anchors.length == 0) {
    var anchorGroup = getElement(ANCHORS_ID);
    // Reset any translation because the next mouse hover 
    // will set the anchors directly over the shape.
    anchorGroup.setAttributeNS(null, "transform", "translate(0, 0)");
    // We pass in the shape (which is also the surface) mouse controller so we can
    // handle when the shape or surface gets the mousemove event, which happens if
    // the user moves the mouse too quickly and the pointer leaves the anchor rectangle.
    this.anchorController = new AnchorController(this);

    anchors.map(anchorDefinition => {
      var anchor = anchorDefinition.anchor;
      // Note the additional translation attributes tx and ty which we use 
      // for convenience (so we don't have to parse the transform) when translating 
      // the anchor.
      var el = this.createElement("rect", { 
          x: anchor.X - 5, y: anchor.Y - 5, tx: 0, ty: 0, width: 10, height: 10, 
          fill: "#FFFFFF", stroke: "#808080", "stroke-width": 0.5 });
      // Create anchor shape, wire up anchor events, 
      // and attach it to the MouseController::AnchorController object.
      new Anchor(this.anchorController, el, 
                 this.partialCall(el, anchorDefinition.onDrag));
      anchorGroup.appendChild(el);
    });
  }
}

请注意额外的 txty 属性,用于跟踪锚点平移。partialCall 函数允许我们在 onDrag 回调中传入锚点元素。

// We need to set up a partial call so that we can include 
// the anchor being dragged when we call
// the drag method for moving the shape's anchor. 
// At that point, we also pass in the event data.
partialCall(anchorElement, onDrag) {
  return (function (anchorElement, onDrag) {
    return function (evt) { onDrag(anchorElement, evt); }
  })(anchorElement, onDrag);
}

其他形状的锚点拖动操作

圆形和其他需要保持其纵横比的形状很麻烦,因为所有锚点都必须移动!我们对其他形状也有这个问题(例如调整矩形或菱形的大小),因为调整形状的大小会改变其他锚点位置。因此,另一次重构(未显示)会传入整个锚点集合,以便形状在拖动某个特定锚点时可以平移其他锚点。如果我们移除所有锚点,只留下要移动的锚点,我们就不会有这个问题,这也是一种可能性,但我不想依赖 UI 行为来控制对象如何操作的内部逻辑。因此,对锚点创建方式的又一次重构:

// We need to set up a partial call 
// so that we can include the anchor being dragged when we call
// the drag method for moving the shape's anchor. 
// At that point we also pass in the event data.
partialCall(anchors, anchorElement, onDrag) {
  return (function (anchors, anchorElement, onDrag) {
    return function (evt) { onDrag(anchors, anchorElement, evt); }
  })(anchors, anchorElement, onDrag);
}

showAnchors(anchors) {
  // not showing?
  if (this.anchors.length == 0) {
    var anchorGroup = getElement(ANCHORS_ID);
    // Reset any translation because the next mouse hover 
    // will set the anchors directly over the shape.
    anchorGroup.setAttributeNS(null, "transform", "translate(0, 0)");
    // We pass in the shape (which is also the surface) mouse controller so we can
    // handle when the shape or surface gets the mousemove event, which happens if
    // the user moves the mouse too quickly and the pointer leaves the anchor rectangle.
    this.anchorController = new AnchorController(this);
    var anchorElements = [];

    anchors.map(anchorDefinition => {
      var anchor = anchorDefinition.anchor;
      // Note the additional translation attributes tx and ty 
      // which we use for convenience 
      // (so we don't have to parse the transform) when translating the anchor.
      var el = this.createElement("rect", 
          { x: anchor.X - 5, y: anchor.Y - 5, tx: 0, ty: 0, width: 10, height: 10, 
            fill: "#FFFFFF", stroke: "#808080", "stroke-width": 0.5 });
      anchorElements.push(el);
      anchorGroup.appendChild(el);
    });

    // Separate iterator so we can pass in all the anchor elements 
    // to the onDrag callback once they've been accumulated.
    for (var i = 0; i < anchors.length; i++) {
      var anchorDefinition = anchors[i];
      var el = anchorElements[i];
      // Create anchor shape, wire up anchor events, 
      // and attach it to the MouseController::AnchorController object.
      new Anchor(this.anchorController, el, 
      this.partialCall(anchorElements, el, anchorDefinition.onDrag));
    }
  }
}

圆形

圆形的锚点是顶部、底部、中间和右侧。

getAnchors() {
  var corners = this.getCorners();
  var middleTop = new Point((corners[0].X + corners[1].X) / 2, corners[0].Y);
  var middleBottom = new Point((corners[0].X + corners[1].X) / 2, corners[1].Y);
  var middleLeft = new Point(corners[0].X, (corners[0].Y + corners[1].Y) / 2);
  var middleRight = new Point(corners[1].X, (corners[0].Y + corners[1].Y) / 2);

  var anchors = [
    { anchor: middleTop, onDrag: this.topMove.bind(this) },
    { anchor: middleBottom, onDrag: this.bottomMove.bind(this) },
    { anchor: middleLeft, onDrag: this.leftMove.bind(this) },
    { anchor: middleRight, onDrag: this.rightMove.bind(this) }
  ];

  return anchors;
}

圆半径和锚点的调整。

topMove(anchors, anchor, evt) {
  this.changeRadius(-evt.movementY);
  this.moveAnchor(anchors[0], 0, evt.movementY);
  this.moveAnchor(anchors[1], 0, -evt.movementY);
  this.moveAnchor(anchors[2], evt.movementY, 0);
  this.moveAnchor(anchors[3], -evt.movementY, 0);
}

bottomMove(anchors, anchor, evt) {
  this.changeRadius(evt.movementY);
  this.moveAnchor(anchors[0], 0, -evt.movementY);
  this.moveAnchor(anchors[1], 0, evt.movementY);
  this.moveAnchor(anchors[2], -evt.movementY, 0);
  this.moveAnchor(anchors[3], evt.movementY, 0);
}

leftMove(anchors, anchor, evt) {
  this.changeRadius(-evt.movementX);
  this.moveAnchor(anchors[0], 0, evt.movementX);
  this.moveAnchor(anchors[1], 0, -evt.movementX);
  this.moveAnchor(anchors[2], evt.movementX, 0);
  this.moveAnchor(anchors[3], -evt.movementX, 0);
}

rightMove(anchors, anchor, evt) {
  this.changeRadius(evt.movementX);
  this.moveAnchor(anchors[0], 0, -evt.movementX);
  this.moveAnchor(anchors[1], 0, evt.movementX);
  this.moveAnchor(anchors[2], -evt.movementX, 0);
  this.moveAnchor(anchors[3], evt.movementX, 0);
}

changeRadius(amt) {
  var r = +this.svgElement.getAttribute("r") + amt;
  this.svgElement.setAttribute("r", r)
}

菱形

菱形是上下和左右对称调整大小的。这意味着在垂直或水平调整大小时,只需要更新顶部-底部或左-右锚点的位置。最令人头疼的问题是重新计算路径(例如:d: "M 240 100 L 210 130 L 240 160 L 270 130 Z"),因为这不仅仅是设置 (x, y) 坐标。鉴于边界矩形是 SVG 表面上的绝对坐标,在设置新的路径值时,我们必须移除任何平移(形状和平移)。

updatePath(ulCorner, lrCorner) {
  // example path: d: "M 240 100 L 210 130 L 240 160 L 270 130 Z"
  this.getRelativeLocation(ulCorner);
  this.getRelativeLocation(lrCorner);
  var mx = (ulCorner.X + lrCorner.X) / 2;
  var my = (ulCorner.Y + lrCorner.Y) / 2;
  var path = "M " + mx + " " + ulCorner.Y;
  path = path + " L " + ulCorner.X + " " + my;
  path = path + " L " + mx + " " + lrCorner.Y;
  path = path + " L " + lrCorner.X + " " + my;
  path = path + " Z"
  this.svgElement.setAttribute("d", path);
}

并在 SvgElement 类中:

getRelativeLocation(p) {
  p.translate(-this.X, -this.Y);
  p.translate(-this.mouseController.surface.X, -this.mouseController.surface.Y);

  return p;
}

在“移动锚点”事件上,以下是四个函数中的两个(另外两个完全相同,只是符号相反)。

topMove(anchors, anchor, evt) {
  var ulCorner = this.getULCorner();
  var lrCorner = this.getLRCorner();
  this.changeHeight(ulCorner, lrCorner, -evt.movementY);
  this.moveAnchor(anchors[0], 0, evt.movementY); // top
  this.moveAnchor(anchors[1], 0, -evt.movementY); // bottom
}

leftMove(anchors, anchor, evt) {
  var ulCorner = this.getULCorner();
  var lrCorner = this.getLRCorner();
  this.changeWidth(ulCorner, lrCorner, -evt.movementX);
  this.moveAnchor(anchors[2], evt.movementX, 0);
  this.moveAnchor(anchors[3], -evt.movementX, 0);
}

矩形

为了简单起见,我们将只使用我们一直为圆形和菱形使用的四个锚点。与菱形不同,移动锚点不是对称的,因此除了锚点本身,还必须更新对角线锚点。最细微的问题在于操作 (x, width) 和 (y, height) 值。同样,仅说明顶部和左侧锚点移动的代码(右侧和底部符号改变,并且只调整 widthheight)。

topMove(anchors, anchor, evt) {
  // Moving the top affects "y" and "height"
  var y = +this.svgElement.getAttribute("y") + evt.movementY;
  var height = +this.svgElement.getAttribute("height") - evt.movementY;
  this.svgElement.setAttribute("y", y);
  this.svgElement.setAttribute("height", height);
  this.moveAnchor(anchors[0], 0, evt.movementY);
  this.adjustAnchorY(anchors[2], evt.movementY/2);
  this.adjustAnchorY(anchors[3], evt.movementY / 2);
}

leftMove(anchors, anchor, evt) {
  // Moving the left affects "x" and "width"
  var x = +this.svgElement.getAttribute("x") + evt.movementX;
  var width = +this.svgElement.getAttribute("width") - evt.movementX;
  this.svgElement.setAttribute("x", x);
  this.svgElement.setAttribute("width", width);
  this.moveAnchor(anchors[2], evt.movementX, 0);
  this.adjustAnchorX(anchors[0], evt.movementX / 2);
  this.adjustAnchorX(anchors[1], evt.movementX / 2);
}

并且 SvgElement 类中有两个新的辅助函数。

adjustAnchorX(anchor, dx) {
  var tx = +anchor.getAttribute("tx") + dx;
  var ty = +anchor.getAttribute("ty");
  anchor.setAttribute("transform", "translate(" + tx + "," + ty + ")");
  anchor.setAttribute("tx", tx);
  anchor.setAttribute("ty", ty);
}

adjustAnchorY(anchor, dy) {
  var tx = +anchor.getAttribute("tx");
  var ty = +anchor.getAttribute("ty") + dy;
  anchor.setAttribute("transform", "translate(" + tx + "," + ty + ")");
  anchor.setAttribute("tx", tx);
  anchor.setAttribute("ty", ty);
}

我们现在可以调整线条的大小和方向了!当前实现的一个令人烦恼的问题是,锚点仅在鼠标进入形状时出现。这导致了一种轻微但尴尬的鼠标手势,即鼠标必须进入形状,然后再回到边缘才能选择锚点。连接点也存在这个问题。处理这个问题的一种方法是,每个形状都需要位于一个带有透明但稍大的镜像形状的组中。这在待办事项列表中。

文本

本文的最后一项功能是让应用程序至少具有最基本的可使用性——能够向图表中添加文本。目前,没有花哨的字体、字号、对齐或自动换行功能。此外,文本是一个独立的形状——如果你在矩形上叠加文本,当你移动矩形时,文本不会移动。这是最基本的功能!

toolbox 文本形状添加到 toolbox 组。

<text id="toolboxText" x="73" y="100" font-size="32" font-family="Verdana">A</text>

还有一个支持的 Text 和 ToolboxClass ,实现了典型的实现,只有一个小的变体——设置内部 HTML。

createElement() {
  var el = super.createElement('text', 
  { x: 240, y: 100, "font-size": 12, "font-family": "Verdana" });
  el.innerHTML = "[text]";

  return el;
}

createElementAt 函数也是如此。

我也不希望鼠标光标移到文本元素上方时变成 I 形光标,所以我们的第一个(也是唯一的)CSS。

<style>
  text {cursor:default}
</style>

更改文本

如我之前提到的,我现在对花哨的 UI 兴趣不大,更多的是对解决基本行为问题感兴趣。所以要设置文本,你需要选择形状,然后在该部分的开头所示的屏幕截图中的编辑框中输入文本。实现,在 Text 类中,很简单。

class Text extends SvgElement {
  constructor(mouseController, svgElement) {
    super(mouseController, svgElement);
    this.registerEventListener(svgElement, "mousedown", this.onMouseDown);
  }

  // Update the UI with the text associated with the shape.
  onMouseDown(evt) {
    var text = this.svgElement.innerHTML;
    document.getElementById("text").value = text;
  }

  setText(text) {
    this.svgElement.innerHTML = text;
  }
}

这里唯一值得注意的事情是,Text 类添加了第二个 mousedown 事件处理程序,以便它可以将文本形状的文本设置到屏幕上的输入框中。当输入框中的文本更改时,会调用所选形状的 setText 方法。

// Update the selected shape's text. Works only with text shapes right now.
function setText() {
  if (mouseController.selectedShape != null) {
    var text = document.getElementById("text").value;
    mouseController.selectedShape.setText(text);
  }
}

这有点粗糙,使用了全局 mouseController 等,但我们可以稍后扩展它,让所有形状都包含一个文本区域。

将原型重构为使用 MVC 模式

到目前为止,我们避免了维护和持久化单独的模型。shape 类 RectangleTextCircle 等等,更多的是控制器而不是模型,尽管在 createShapeControllers 函数(加载图表时调用)中存在一些纠缠。这段代码:

shape.X = +translate[0];
shape.Y = +translate[1];

是控制器和模型纠缠的线索。同样,在 Surface 类中,serialize / deserialize 函数是控制器和模型纠缠的另一个线索。序列化表面数据的代码本身相当 hacky。回顾一下:

serialize() {
  var el = document.createElement("surface");
  // DOM adds elements as lowercase, so let's just start with lowercase keys.
  var attributes = {x : this.X, y : this.Y, 
  gridcellw : this.gridCellW, gridcellh : this.gridCellH, 
  cellw : this.cellW, cellh : this.cellH}
  Object.entries(attributes).map(([key, val]) => el.setAttribute(key, val));
  var serializer = new XMLSerializer();
  var xml = serializer.serializeToString(el);

  return xml;
}

除了处理小写属性的粗糙做法外,我们还有将数据序列化为 XML 的 hack,以保持与实际 SVG 对象图序列化(也以 XML 格式)的一致性。有几个选项应该被考虑。

  • 仅序列化模型,并从模型重建 SVG 对象图。缺点是,SVG(序列化为 XML)可以轻松导入到其他应用程序中。当然,如果我们想要这种行为,我们可以添加一个 export 函数。
  • 将 SVG 对象图序列化为 XML,并将形状模型序列化为 JSON,JSON 更原生于 JavaScript。我们可以在同一个文件中纠缠 XML 和 JSON,或者将它们保存为单独的文件。

最终,我认为首选方法是序列化模型,并从模型重建 SVG 对象图。

模型、视图和控制器

以下是基本的 MVC 模式。

每个形状实现其自己的特定模型、视图和控制器。例如(因为这很有趣),以下是文本形状的 MVC 类:

文本模型

class TextModel extends ShapeModel {
  constructor() {
    super();
    this._x = 0;
    this._y = 0;
    this._text = "";
  }

  get x() { return this._x; }
  get y() { return this._y; }
  get text() { return this._text; }

  set x(value) {
    this._x = value;
    this.propertyChanged("x", value);
  }

  set y(value) {
    this._y = value;
    this.propertyChanged("y", value);
  }

  set text(value) {
    this._text = value;
    this.propertyChanged("text", value);
  }
}

文本控制器

class TextController extends Controller {
  constructor(mouseController, view, model) {
    super(mouseController, view, model);
  }

  // Update the UI with the text associated with the shape.
  onMouseDown(evt) {
    super.onMouseDown(evt);
    var text = this.model.text;
    document.getElementById("text").value = text;
    this.mouseController.selectedShapeController = this;
  }
}

文本视图

class TextView extends View{
  constructor(svgElement, model) {
    super(svgElement, model);
  }

  // Custom handling for property "text"
  onPropertyChange(property, value) {
    if (property == "text") {
      this.svgElement.innerHTML = value;
    } else {
      super.onPropertyChange(property, value);
    }
  }
}

基础模型

每个形状都需要平移,因此基础 Model 类处理这些属性并提供一些辅助方法。

class Model {
  constructor() {
    this.eventPropertyChanged = null;

    this._tx = 0;
    this._ty = 0;
  }

  get tx() { return this._tx; }
  get ty() { return this._ty; }

  propertyChanged(propertyName, value) {
    if (this.eventPropertyChanged != null) {
      this.eventPropertyChanged(propertyName, value);
    }
  }

  translate(x, y) {
    this._tx += x;
    this._ty += y;
    this.setTranslate(this._tx, this._ty);
  }

  // Update our internal translation and set the translation immediately.
  setTranslation(x, y) {
    this._tx = x;
    this._ty = y;
    this.setTranslate(x, y);
  }

  // Deferred translation -- this only updates _tx and _ty
  // Used when we want to internally maintain the true _tx and _ty
  // but set the translation to a modulus, as in when translating
  // the grid.
  updateTranslation(dx, dy) {
    this._tx += dx;
    this._ty += dy;
  }

  // Sets the "translate" portion of the "transform" property.
  // All models have a translation. Notice we do not use _tx, _ty here
  // nor do we set _tx, _ty to (x, y) because (x, y) might be mod'ed by
  // the grid (w, h). We want to use exactly the parameters passed in
  // without modifying our model.
  // See SurfaceController.onDrag and note how the translation is updated
  // but setTranslate is called with the mod'ed (x, y) coordinates.
  setTranslate(x, y) {
    this.translation = "translate(" + x + "," + y + ")";
    this.transform = this.translation;
  }

  // TODO: Later to be extended to build the transform so that it includes rotation and other things we can do.
  set transform(value) {
    this._transform = value;
    this.propertyChanged("transform", value);
  }

  set tx(value) {
    this._tx = value;
    this.translation = "translate(" + this._tx + "," + this._ty + ")";
    this.transform = this.translation;
  }

  set ty(value) {
    this._ty = value;
  this.translation = "translate(" + this._tx + "," + this._ty + ")";
  this.transform = this.translation;
  }
}

基础视图

基础 View 类有一个用于获取 SVG 元素 ID 的辅助函数,并设置关联 SVG 元素的属性。

class View {
  constructor(svgElement, model) {
    this.svgElement = svgElement;
    model.eventPropertyChanged = this.onPropertyChange.bind(this);
  }

  get id() {
    return this.svgElement.getAttribute("id");
  }

  onPropertyChange(property, value) {
    this.svgElement.setAttribute(property, value);
  }
}

另请注意,构造函数连接了模型触发的属性更改 event

以编程方式创建形状

给定新的 MVC 架构,以下是程序化创建形状的方式。请注意,模型必须初始化为匹配形状属性值。另请注意,目前,我们的模型不处理其他属性,如 fill、stroke 和 stroke-width。我们还没有 UI 支持,所以还没有实现模型的这些属性。

var rectEl = Helpers.createElement('rect', 
             { x: 240, y: 100, width: 60, height: 60, 
               fill: "#FFFFFF", stroke: "black", "stroke-width": 1 });
var rectModel = new RectangleModel();
rectModel._x = 240;
rectModel._y = 100;
rectModel._width = 60;
rectModel._height = 60;
var rectView = new ShapeView(rectEl, rectModel);
var rectController = new RectangleController(mouseController, rectView, rectModel);
Helpers.getElement(Constants.SVG_OBJECTS_ID).appendChild(rectEl);
mouseController.attach(rectView, rectController);
// Most shapes also need an anchor controller
mouseController.attach(rectView, anchorGroupController);

另外请注意,鼠标控制器现在如何支持多个形状控制器!

序列化

快速查看序列化现在是如何工作的可能值得一看。

// Returns JSON of serialized models.
serialize() {
  var uberModel = [];
  var model = surfaceModel.serialize();
  model[Object.keys(model)[0]].id = Constants.SVG_SURFACE_ID;
  uberModel.push(model);

  this.models.map(m => {
    var model = m.model.serialize();
    model[Object.keys(model)[0]].id = m.id;
    uberModel.push(model);
  });

  return JSON.stringify(uberModel);
}

具体的模型负责自行序列化。序列化器附加形状的 ID,而 ID 实际上不是模型的一部分,而是视图的一部分!这段代码看起来有点奇怪,因为当形状被放置在表面上时,只有模型和形状的 ID 被注册在图表控制器中,如下所示:

addModel(model, id) {
  this.models.push({ model: model, id: id });
}

因此,这个丑陋的代码:model[Object.keys(model)[0]].id = m.id;

model 字典只有一个条目,键是形状名称,值是属性集合,我们正在向其中添加 id。例如,一个空白表面序列化如下:

[{"Surface":{"tx":0,"ty":0,"gridCellW":100,"gridCellH":100,
  "cellW":20,"cellH":20,"id":"surface"}}]

反序列化

恢复图表要复杂一些,因为必须创建适当的模型、视图和控制器类以及 SVG 元素。反序列化实际的 SVG 元素属性再次留给具体的模型。

// Creates an MVC for each model of the provided JSON.
deserialize(jsonString) {
  var models = JSON.parse(jsonString);
  var objectModels = [];
  surfaceModel.setTranslation(0, 0);
  objectsModel.setTranslation(0, 0);

  models.map(model => {
    var key = Object.keys(model)[0];
      var val = model[key];

      if (key == "Surface") {
      // Special handler for surface, we keep the existing MVC objects.
      // We set both the surface and objects translation, but the surface translation
      // is mod'd by the gridCellW/H.
      surfaceModel.deserialize(val);
      objectsModel.setTranslation(surfaceModel.tx, surfaceModel.ty);
    } else {
      var model = new this.mvc[key].model();
      objectModels.push(model);
      var el = this.mvc[key].creator();
      // Create the view first so it hooks into the model's property change event.
      var view = new this.mvc[key].view(el, model);
      model.deserialize(val, el);
      view.id = val.id;
      var controller = new this.mvc[key].controller(mouseController, view, model);

      // Update our diagram's model collection.
      this.models.push({ model: model, id: val.id });

      Helpers.getElement(Constants.SVG_OBJECTS_ID).appendChild(el);
      this.mouseController.attach(view, controller);

      // Most shapes also need an anchor controller. 
      // An exception is the Text shape, at least for now.
      if (controller.shouldShowAnchors) {
        this.mouseController.attach(view, anchorGroupController);
      }
    }
  });
}

整个过程由一个表驱动,该表确定实例化哪些实际的 MVC 类,以及任何自定义 SVG 元素实例化要求。

this.mvc = {
  Rectangle: { model: RectangleModel, view: ShapeView, 
    controller: RectangleController, creator : () => this.createElement("rect") },
  Circle: { model: CircleModel, view: ShapeView, 
    controller: CircleController, creator: () => this.createElement("circle") },
  Diamond: { model: DiamondModel, view: ShapeView, 
    controller: DiamondController, creator: () => this.createElement("path") },
  Line: { model: LineModel, view: LineView, controller: LineController, 
    creator: () => this.createLineElement() },
  Text: { model: TextModel, view: TextView, controller: TextController, 
    creator: () => this.createTextElement() },
};

连接线条

现在我们有了一个坚实的 MVC 架构,管理连接线条(线条端点连接到形状)所需的额外粘合剂就可以完成了。这包括:

  • 形状上的连接点。
  • 显示这些连接点。
  • 管理哪个线条端点连接到哪个形状的连接点。
    • 附加端点。
    • 分离端点。
    • 持久化连接。
  • 移动线条时更新线条。
  • 调整形状大小时移动线条。

这很多!希望我们能有效地实现所有这些。

连接点

目前,我将使连接点保持简单,这意味着对于圆形和菱形,没有 45/135/225/315 度点(甚至没有其他点)。对于矩形,边缘和中点之间没有中间连接点。连接点,与锚点一样,目前将是基本罗盘点:N、S、E、W。但是,我们将实现其结构,以便以后可以扩展。因此,定义连接点看起来非常像定义锚点,只是连接点没有行为,它只是一个视觉辅助。在完整的实现中,可以添加、删除、移动连接点等,所以重要的是我们有一些方法可以将名称(即使是自动生成的 GUID)与连接点关联起来,以便有一个具体的东西可以用作线条端点和形状连接点之间的引用,而不是仅仅一个数组索引。

与锚点一样,我们有一个函数返回形状可用的连接点。目前不支持自定义连接点。我还在实现上做出了妥协,即不是给连接点一个实际的 ID,而是通过它们在连接点列表中的索引来“记住”连接点。这对于将来用户应该能够添加/删除形状上的连接点来说并不理想。不过,这是矩形形状连接点的定义方式(它看起来与锚点非常相似)。

getConnectionPoints() {
  var corners = this.getCorners();
  var middleTop = new Point((corners[0].x + corners[1].x) / 2, corners[0].y);
  var middleBottom = new Point((corners[0].x + corners[1].x) / 2, corners[1].y);
  var middleLeft = new Point(corners[0].x, (corners[0].y + corners[1].y) / 2);
  var middleRight = new Point(corners[1].x, (corners[0].y + corners[1].y) / 2);

  var connectionPoints = [
    { connectionPoint: middleTop },
    { connectionPoint: middleBottom },
    { connectionPoint: middleLeft },
    { connectionPoint: middleRight }
  ];

  return connectionPoints;
}

connectionPoints 数组是一个只包含一个键值对的字典——目前这有点过头了,但我怀疑,与锚点一样,将来可能需要一些额外的数据。

检测我们附近的形状

控制与形状连接/断开连接的代码的逻辑位置是 AnchorController,它在鼠标悬停在形状上时为每个锚点创建。此外,只有某些形状(如线条)可以连接到其他形状。因此,在 AnchorController 中,onDrag 函数还可以处理显示附近形状的连接点。

onDrag(dx, dy) {
  // Call into the shape controller to handle
  // the specific anchor drag.
  this.fncDragAnchor(dx, dy);
  this.showAnyConnectionPoints();
}

函数 showAnyConnectionPoints 同时管理当前显示连接点的形状列表,以及调用函数来显示或移除附近形状的连接点。

showAnyConnectionPoints() {
  if (this.shapeController.canConnectToShapes) {
    var changes = this.getNewNearbyShapes
                  (this.mouseController.x, this.mouseController.y);
    this.createConnectionPoints(changes.newShapes);

    // Other interesting approaches:
    // https://stackoverflow.com/questions/1885557/
    // simplest-code-for-array-intersection-in-javascript
    // [...new Set(a)].filter(x => new Set(b).has(x));
    var currentShapesId = 
        changes.newShapes.concat(changes.existingShapes).map(ns => ns.id);

    var noLongerNearShapes = this.shapeConnectionPoints.filter
                             (s => currentShapesId.indexOf(s.id) < 0);
    this.removeExpiredShapeConnectionPoints(noLongerNearShapes);

    // Remove any shapes from the shapeConnectionPoints that do not exist anymore.
    var existingShapesId = changes.existingShapes.map(ns => ns.id);
    this.shapeConnectionPoints = this.shapeConnectionPoints.filter
                                 (s => existingShapesId.indexOf(s.id) >= 0);

    // Add in the new shapes.
    this.shapeConnectionPoints = this.shapeConnectionPoints.concat(changes.newShapes);

    console.log("scp: " + this.shapeConnectionPoints.length + ", 
    new: " + changes.newShapes.length + ", existing: " + existingShapesId.length);
  }
}

这实际上只是一堆 map 和 filter 调用,用于将新形状添加到当前形状连接点并移除不再应该显示连接点的旧形状。

使用 getNewNearbyShapes ,返回我们靠近的新形状和我们仍然靠近的现有形状都很有用。

getNewNearbyShapes(x, y) {
  var newShapes = [];
  var existingShapes = [];
  var p = new Point(x, y);
  p = Helpers.translateToScreenCoordinate(p);
  var nearbyShapeEls = Helpers.getNearbyShapes(p);

  // logging:
  // nearbyShapesEls.map(s => console.log(s.outerHTML.split(" ")[0].substring(1)));

  nearbyShapeEls.map(el => {
    var controllers = this.mouseController.getControllersByElement(el);
    if (controllers) {
      controllers.map(ctrl => {
        if (ctrl.hasConnectionPoints) {
          var shapeId = ctrl.view.id;

          // If it already exists in the list, don't add it again.
          if (!this.shapeConnectionPoints.any(cp => cp.id == shapeId)) {
            var connectionPoints = ctrl.getConnectionPoints();
            newShapes.push({ id: shapeId, controller: ctrl, 
                             connectionPoints: connectionPoints });
          } else {
            existingShapes.push({ id: shapeId });
          }
        }
      });
    }
  });

  return { newShapes : newShapes, existingShapes: existingShapes };
}

其关键部分是新形状包含结构 {shape ID, controller, connection points},而现有形状只是结构 {shape ID}。在前面的函数中,这两个列表被连接起来,并且公共形状 ID 被映射到当前显示连接点的形状集合中。

var currentShapesId = changes.newShapes.concat(changes.existingShapes).map(ns => ns.id);

显示连接点

// "shapes" is a {id, controller, connectionPoints} structure
createConnectionPoints(shapes) {
  var cpGroup = Helpers.getElement(Constants.SVG_CONNECTION_POINTS_ID);

  shapes.map(shape => {
    shape.connectionPoints.map(cpStruct => {
      var cp = cpStruct.connectionPoint;
      var el = Helpers.createElement("g", { connectingToShapeId: shape.id });
      el.appendChild(Helpers.createElement("line", 
        { x1: cp.x - 5, y1: cp.y - 5, x2: cp.x + 5, y2: cp.y + 5, 
          fill: "#FFFFFF", stroke: "#000080", "stroke-width": 1 }));
      el.appendChild(Helpers.createElement("line", 
        { x1: cp.x + 5, y1: cp.y - 5, x2: cp.x - 5, y2: cp.y + 5, 
          fill: "#FFFFFF", stroke: "#000080", "stroke-width": 1 }));
      cpGroup.appendChild(el);
    });
  });
}

任何我们想要显示连接点的形状都会添加一个包含两条形成 X 的线的组到连接点组。请注意 connectingToShapeId 属性,它设置关联形状的形状 ID。我们接下来使用此信息来移除特定形状的连接点。

移除连接点

// "shapes" is a {id, controller, connectionPoints} structure
removeExpiredShapeConnectionPoints(shapes) {
  shapes.map(shape => {
    // <a href="https://stackoverflow.com/a/16775485/2276361">
    // https://stackoverflow.com/a/16775485/2276361</a>
    var nodes = document.querySelectorAll('[connectingtoshapeid="' + shape.id + '"]');
    // or: Array.from(nodes); 
    // <a href="https://stackoverflow.com/a/36249012/2276361">
    // https://stackoverflow.com/a/36249012/2276361</a>
    // <a href="https://stackoverflow.com/a/33822526/2276361">
    // https://stackoverflow.com/a/33822526/2276361</a>
    [...nodes].map(node => { node.parentNode.removeChild(node) });
  });
}

移除连接点涉及文档查询以获取所有与形状关联的连接点 SVG 组,并移除子节点。

连接到形状

连接到形状涉及找到鼠标最接近的连接点(假设只找到一个),并将线条的锚点吸附到形状的连接点。我们还将新的连接告诉图表模型。在这里,我们看到连接点索引如何用于跟踪形状上的实际连接点。

connectIfCloseToShapeConnectionPoint() {
  var p = new Point(this.mouseController.x, this.mouseController.y);
  p = Helpers.translateToScreenCoordinate(p);

  var nearbyConnectionPoints = [];

  this.shapeConnectionPoints.filter(scp => {
    for (var i = 0; i < scp.connectionPoints.length; i++) {
      var cpStruct = scp.connectionPoints[i];
      if (Helpers.isNear(cpStruct.connectionPoint, p, Constants.MAX_CP_NEAR)) {
        nearbyConnectionPoints.push({ shapeController: scp.controller, 
        shapeCPIdx : i, connectionPoint : cpStruct.connectionPoint});
      }
    }
  });

  if (nearbyConnectionPoints.length == 1) {
    var ncp = nearbyConnectionPoints[0];

    // The location of the connection point of the shape to which we're connecting.
    var p = ncp.connectionPoint;
    // Physical location of endpoint is without line and surface translations.
    p = p.translate(-this.shapeController.model.tx, -this.shapeController.model.ty);
    p = p.translate(-surfaceModel.tx, - surfaceModel.ty);
    // Move the endpoint of the shape from which 
    // we're connecting (the line) to this point.
    this.shapeController.connect(this.anchorIdx, p);
    diagramModel.connect(ncp.shapeController.view.id, 
                 this.shapeController.view.id, ncp.shapeCPIdx, this.anchorIdx);
  }
}

这种方法的一个缺点是它只在拖动端点锚点时有效。如果你正在拖动线条,我们没有检测端点是否接近另一个形状的连接点。

移动形状时更新连接

这是 Controller 类 onDrag 函数的一个简单添加。

 // Default behavior
onDrag(dx, dy)
{
  this.model.translate(dx, dy);
  this.adjustConnections(dx, dy);
}

// Adjust all connectors connecting to this shape.
adjustConnections(dx, dy) {
  var connections = diagramModel.connections.filter(c => c.shapeId == this.view.id);
  connections.map(c => {
    // TODO: Sort of nasty assumption here that 
    // the first controller is the line controller
    var lineController = this.mouseController.getControllersById(c.lineId)[0];
    lineController.translateEndpoint(c.lineAnchorIdx, dx, dy);
  });
}

注意 translateEndpoint 如何依赖于锚点索引——同样,不理想但对于当前实现足够了。

translateEndpoint(idx, dx, dy) {
  switch (idx) {
    case 0:
      var p = new Point(this.model.x1, this.model.y1);
      p = p.translate(dx, dy);
      this.model.x1 = p.x;
      this.model.y1 = p.y;
      break;
    case 1:
      var p = new Point(this.model.x2, this.model.y2);
      p = p.translate(dx, dy);
      this.model.x2 = p.x;
      this.model.y2 = p.y;
      break;
  }
}

移动线条端点只是根据形状的移动更新端点。

调整形状大小时更新连接

Controller 类实现了公共函数来平移附加到正在调整大小的形状的线条端点。

// Adjust the connectors connecting to this shape's connection point.
adjustConnectorsAttachedToConnectionPoint(dx, dy, cpIdx) {
  var connections = diagramModel.connections.filter
      (c => c.shapeId == this.view.id && c.shapeCPIdx == cpIdx);
  connections.map(c => {
    // TODO: Sort of nasty assumption here that the 
    // first controller is the line controller
    var lineController = this.mouseController.getControllersById(c.lineId)[0];
    lineController.translateEndpoint(c.lineAnchorIdx, dx, dy);
  });
}

当锚点(目前始终是关联的连接点)移动时,形状控制器本身负责调用方法来调整连接到该锚点/连接点的任何连接。以下是当矩形形状的顶部锚点移动时发生的示例:

topMove(anchors, anchor, dx, dy) {
  // Moving the top affects "y" and "height"
  var y = this.model.y + dy;
  var height = this.model.height - dy;
  this.model.y = y;
  this.model.height = height;
  this.moveAnchor(anchors[0], 0, dy);
  this.adjustAnchorY(anchors[2], dy / 2);
  this.adjustAnchorY(anchors[3], dy / 2);
  this.adjustConnectorsAttachedToConnectionPoint(0, dy, 0);
  this.adjustConnectorsAttachedToConnectionPoint(0, dy / 2, 2);
  this.adjustConnectorsAttachedToConnectionPoint(0, dy / 2, 3);
}

当然,代码可以改进,索引的使用很烦人,并且 anchor-dx-dy 和 dx-dy-anchorIndex 参数顺序的切换也很烦人。但这说明了每个锚点“drag”函数负责找出如何调整连接点(也恰好是锚点坐标,顺序相同)。

断开连接

当整个线条移动时,两个端点都将从任何潜在的连接中断开。

onDrag(dx, dy) {
  super.onDrag(dx, dy);
  // When the entire line is being dragged, we disconnect any connections.
  diagramModel.disconnect(this.view.id, 0);
  diagramModel.disconnect(this.view.id, 1);
}

这是图表控制器中的一个简单 filter 操作。

// Disconnect any connections associated with the line and anchor index.
disconnect(lineId, lineAnchorIdx) {
  this.connections = this.connections.filter
  (c => !(c.lineId == lineId && c.lineAnchorIdx == lineAnchorIdx));
}

同样,每当线条的任一端点移动时,它都会从其可能连接到的任何形状断开。注意连接点(即 anchor 索引)的使用。

// Move the (x1, y1) coordinate.
moveULCorner(anchors, anchor, dx, dy) {
  this.model.x1 = this.model.x1 + dx;
  this.model.y1 = this.model.y1 + dy;
  this.moveAnchor(anchor, dx, dy);
  diagramModel.disconnect(this.view.id, 0);
}

// Move the (x2, y2) coordinate.
moveLRCorner(anchors, anchor, dx, dy) {
  this.model.x2 = this.model.x2 + dx;
  this.model.y2 = this.model.y2 + dy;
  this.moveAnchor(anchor, dx, dy);
  diagramModel.disconnect(this.view.id, 1);
}

移除形状。

我差点忘了这个!移除形状是一个涉及以下过程的过程:

  • 将形状与鼠标控制器分离。
  • 解绑事件。
  • 移除锚点(因为形状当前正在被悬停)。
  • 将其从模型中移除。
  • 断开与形状的任何连接。
  • 将其从“objects”集合中移除,使其从图表中消失。

幸运的是,这些大部分都是对各种控制器和模型的单行调用。

...
case Constants.KEY_DELETE:
  // Mouse is "leaving" this control, this removes any anchors.
  this.currentHoverControllers.map(c => c.onMouseLeave());

  // Remove shape from diagram model, and all connections of this shape.
  diagramModel.removeShape(this.hoverShapeId);

  // Remove shape from mouse controller and detach events.
  this.destroyShapeById(this.hoverShapeId);

  // Remove from "objects" collection.
  var el = Helpers.getElement(this.hoverShapeId);
  el.parentNode.removeChild(el);

  // Cleanup.
  this.currentHoverControllers = [];
  this.hoverShapeId = null;
  handled = true;
  break;
...

箭头

线条箭头使用 marker-start 和 marker-end SVG 标签实现。

<g id="toolboxLineWithStart">
  <rect id="lineHiddenSelectionArea" x="65" y="70" width="40" 
   height="40" stroke-opacity="0" fill-opacity="0" />
  <line id="line" x1="65" y1="70" x2="105" y2="110" fill="#FFFFFF" 
   stroke="black" stroke-width="1" 
        marker-start="url(#trianglestart)" />
</g>
  <g id="toolboxLineWithStartEnd">
  <rect id="lineHiddenSelectionArea" x="120" y="70" width="40" 
   height="40" stroke-opacity="0" fill-opacity="0" />
  <line id="line" x1="120" y1="70" x2="160" y2="110" fill="#FFFFFF" 
   stroke="black" stroke-width="1" 
        marker-start="url(#trianglestart)" 
        marker-end="url(#triangleend)" />
</g>

这些标签引用 defs 部分中的定义。

<marker id="trianglestart" viewBox="0 0 10 10" refX="0" 
 refY="5" markerWidth="8" markerHeight="8" orient="auto">
  <!-- path looks like < but closed -->
  <path d="M 10 0 L 0 5 L 10 10 z" />
</marker>
<marker id="triangleend" viewBox="0 0 10 10" refX="10" 
 refY="5" markerWidth="8" markerHeight="8" orient="auto">
  <!-- path looks like > but closed -->
  <path d="M 0 0 L 10 5 L 0 10 z" />
</marker>

这里的“窍门”是将 refX 和 refY 坐标设置得使箭头尖端位于线条的端点。例如,我们可以这样断开箭头与线条的连接:

refX="30" refY="5" 

结果是

markerWidth 和 markerHeight 控制箭头的大小。例如:

<marker id="trianglestart" viewBox="0 0 10 10" refX="0" 
 refY="5" markerWidth="22" markerHeight="22" orient="auto">
  <path d="M 10 0 L 0 5 L 10 10 z" />
</marker>

产生:

由于方向是“auto”,箭头将根据线条的方向旋转——非常酷,因为你不需要做任何事情就能实现这一点。

viewBox 属性更改坐标系,以便路径中指定的坐标相对于 viewBox

更新

  • 2018 年 5 月 4 日 - 添加了线条箭头。

结论

还有更多工作要做!撤销/重做、缩放、旋转、用于设置颜色和描边宽度的“属性”窗口、字体和字号、箭头端点、智能线条连接器、分组、上下移动形状、形状模板等等。在很大程度上,这些都是锦上添花(带有一定的复杂性,特别是关于旋转和连接点),我将继续添加。本文提出的内容为 Web 版图表工具的核心功能提供了一个良好的基础。敬请关注第二部分!

最令人烦恼的问题之一是处理未被目标形状接收的鼠标事件。例如,用户快速移动鼠标会导致正在移动的形状滞后,并且底层的 SVG 元素开始接收鼠标移动事件。正好在连接点上的鼠标抬起事件会导致连接点接收该事件,这就是我将连接点移到锚点下方的原因。

我注意到的一件事是,一旦我实现了真正的 MVC 模式,管理鼠标状态的许多复杂性就消失了。事实上,有了 MVC 模式,添加线条连接和更新持久化以包含连接就变得轻而易举了。

最后,这次非常宝贵的练习让我学会了如何以编程方式控制SVG,也让我学到了一些关于JavaScript的新知识。虽然还有很多工作要做,但我认为我已经为继续改进这个应用程序打下了坚实的基础。

历史

  • 2018年4月2日:初始版本
© . All rights reserved.