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

使用 SVG 和 AngularJS 实现流程图

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

5.00/5 (102投票s)

2014 年 1 月 18 日

MIT

63分钟阅读

viewsIcon

415998

downloadIcon

5973

记录了我使用 Web 技术构建流程图的实验。

目录

概述

本文档记录了一个基于 SVGAngularJS 的小型探索性项目,用于流程图可视化和编辑。它很好地利用了 MVVM 模式,以便 UI 逻辑可以进行 单元测试

在撰写了这么多关于 WPF 的文章之后,我现在写一篇关于 Web UI 的文章可能会让您感到意外。在过去的几年里,我一直在提升我的 Web 开发技能。

在工作中,我以一些相当有趣的方式使用 Web UI 来连接游戏开发。例如,构建游戏开发工具和游戏内的 Web UI,但我今天不谈论这些。

将我之前的 NetworkView WPF 文章移植到 Web UI 似乎是自然而然的。我一直对可视化和编辑网络、图形和流程图感兴趣,并且这是我用来测试特定领域技能的方式之一。

在开发代码的过程中,我在许多领域都提升了自己的技能,包括 JavascriptTDD、SVG 和 AngularJS。特别是,我学会了如何将 MVVM 模式的优点应用于 Web UI 开发。在本文中,我将展示如何在 HTML5 和 Javascript 中部署 MVVM 概念。

大约一年前,我开始使用 TDD 进行开发,这是我一直想在 WPF 上做的事情,但一直没有去做(或者真正体会到它的强大之处)。TDD 真正帮助我实现了 MVVM 的全部潜力。

我第一次尝试 NetworkView(在 WPF 中)花费了很长时间。两年多(当然是利用我的业余时间!)我写了一系列 5 篇文章,都是围绕 NetworkView 构建的。完成那些文章付出了很多努力!这次的开发和撰写文章的速度快了很多——只需要几个月(从我忙碌的生活中零星抽出 30 分钟)。我将更快的开发时间归因于以下原因:

  • 我设定的目标要低得多。新代码不像原始 NetworkView 那样通用或功能丰富。
  • 我 already 知道如何构建这类东西,所以我能够快速开始。
  • 我对 MVVM 模式有丰富的经验,所以不需要花太多时间去思考!这一点不容低估,第一次接触 WPF 通用控件和 MVVM 模式时,真是太难了!
  • 使用 Web UI 和 Javascript(说起来很痛苦)比使用 WPF 要容易得多,复杂性也低得多(虽然我仍然喜欢 C#,但我真希望他们能重做 WPF)。
  • 最后,使用 TDD 使我能够快速轻松地克服 Javascript 开发的许多传统难题。重要的是,我能够积极地重构,而无需处理因行为更改而产生的通常缺陷。

那么,让我们重温一下我对 SVG + AngularJS 流程图的探索。

屏幕截图

这是流程图 Web 应用的带注释的屏幕截图。左侧是流程图数据模型的可编辑 JSON 表示。右侧是流程图视图的图形表示。编辑任何一侧(左侧为文本,右侧为视觉)都会更新另一侧,它们会自动保持同步。

目标读者

那么,谁应该阅读本文?

如果您对使用 SVG 和 AngularJS 开发图形 Web 应用程序感兴趣,本文应该会有所帮助。

您应该已经了解一些 HTML5/Javascript,或者愿意快速学习。基本的 SVG 和 AngularJS 知识会有帮助,尽管我预计您现在正在学习其中一些,我也会尽力帮助您入门。我预计您 already 知道一些 MVVM 知识,我在之前的文章中对此进行了广泛的讨论,如果不知道,请不要担心,我会概述它是什么以及为什么它有用。

我还会提到 TDD,以帮助您理解它如何能成为一名更好的开发人员。

是什么以及为什么?

本文是关于我的原始 NetworkView WPF 控件的 Web UI 重写。如前所述,新代码不像原始 WPF 控件那样功能丰富或通用。开发一个功能完全的东西并不是我的初衷,我真的只是想找一种方法来练习我在 Javascript、TDD、AngularJS 和 SVG 方面的技能,并巩固我的 Web 开发技能。

我真的很喜欢使用 Web UI。自从职业生涯早期开始接触 Web 技术到现在,我看到了技术领域发生的许多变化。Web 技术已经取得了长足的进步,社区充满活力,热情高涨。

我的 NetworkView 文章很受欢迎,用 Web UI 进行重写似乎是个好主意。我正在构建我 already 熟悉的东西,所以比从头开始新项目能更快地完成。然而,原始文章中有许多部分在新代码中没有对应项。没有缩放和平移,没有类似 Adorners 的东西提供反馈。没有用于不同类型节点的模板。

总之,如果您想这样做,这段代码会对您有所帮助:

  • 您想了解本文中我所谈论的技术。
  • 如果您需要一个流程图并想根据您的需求改编我的代码。
  • 您想了解如何在非平凡且略微超出常规用例的情况下部署 AngularJS。

无论您阅读本文的原因是什么,您都将面临一些工作,无论是理解还是修改我的代码。如果您想在 AngularJS + SVG 方面取得进展,或者仅仅是在 Web UI 图形方面取得进展,我相信这会有所帮助。

实时演示

首先,让我们看一下实时演示。这样您就可以看到您将获得什么,而无需将代码下载到本地并运行您自己的 Web 服务器(这 anyway 并不难)。

这是主演示:

https://dl.dropboxusercontent.com/u/16408368/WebUI_FlowChart/index.html

这是单元测试:

https://dl.dropboxusercontent.com/u/16408368/WebUI_FlowChart/jasmine/SpecRunner.html

运行代码

运行代码所需的一切都作为 zip 文件附在本文章中。但我建议您访问 GitHub 存储库以获取最新代码。我建议使用 SourceTree 作为 Git 的界面。

运行示例应用程序需要您通过本地 Web 服务器运行它并通过浏览器查看。您也可以直接禁用浏览器的 Web 安全性,然后通过文件系统使用 *file://* 在浏览器中直接加载应用程序。但我不能推荐这样做,因为您需要覆盖浏览器安全性,此外,运行本地 Web 服务器比以往任何时候都容易。我将向您展示如何操作。

我提供了一个简单的、简陋的 Web 服务器(我在 StackOverflow 上找到的),它基于 NodeJS 构建。当您安装了 NodeJS 后,打开一个命令行界面并切换到代码所在目录。运行以下命令:

node server.js

现在您有了一个本地 Web 服务器。将您的 Web 浏览器指向以下 URL 来查看 Web 应用程序:

https://:8888/

要运行单元测试:

https://:8888/jasmine/SpecRunner.html

更新:我发现了一种更简单的方法来运行 Web 服务器。使用以下命令安装  http-server

npm install http-server -g

导航到包含代码的目录并运行 Web 服务器:

http-server

Javascript

Javascript 是互联网的语言,最近我对其有了新的认识。当然,它有一些缺点,但如果您遵循 Crockford 的建议,您可以坚持其优点。

一等函数是一项重要且功能强大的特性。我很高兴我们(某种程度上)在 C# 中也拥有了它们。即使是最新的 C++ 标准也支持 lambda 表达式,似乎 函数式编程如今正在悄悄地渗透到各个角落。从一门经典语言转向 Javascript,您可能会发现 基于原型的继承相当不寻常,但它功能更强大,即使难以理解。

一旦您准备就绪并习惯了它,Javascript 的工作流程就很难被超越。安装 Google Chrome,安装一个好的文本编辑器,您就拥有了一个开发环境!包括性能分析和调试。结合 node-livereload一套单元测试,您就拥有了一个系统,在这个系统中,当您编写代码时,您的 Web 应用程序和单元测试会自动重新运行。我怎么强调这一点的重要性都不为过,它对生产力至关重要。极快的 反馈周期对于有效的敏捷开发至关重要。

测试驱动开发

测试驱动开发是我职业生涯中最重要的积极变化之一。在代码快速演进的过程中,保持设计简洁和最小化缺陷一直很困难。随着代码量的增长,它变得更难管理,更难控制,并且难以重构。当使用 Javascript 这样的语言时,这个问题会更加严重。

单元测试是困难的。它需要付出努力和纪律。您不能等到开发完代码后再去做——当一切似乎都在正常运行时,很容易偷懒而跳过测试。TDD 改变了这一点。您必须有纪律,必须放慢速度,您被迫编写单元测试(否则您就没有真正进行 TDD)。您必须提前考虑您的编码,别无他法。编码前的思考总是一件好事,而且通常太稀少了。

TDD 使您能够为代码的可测试性进行设计。有时这意味着稍微过度设计的代码,但 TDD 意味着您可以放心地进行重构,这可以保持设计简单和受控。重构的诀窍是让设计看起来完美地适应不断变化的需求,即使代码已经远远超出原始设计。当我说的稍微过度设计时,我就是指这个意思,只是稍微一点。我见过也参与过大量过度设计的编码。TDD 在大多数情况下对过度设计有负面影响。TDD 意味着您只编写您正在测试的代码。这确保您的代码总是需要的,总是切中要点。您的精力集中在最终需求上,而不会编写您不需要的东西(除非您为不需要的东西编写测试,而您为什么会这样做呢?)。这种“*只编写您需要的东西*”的态度解决了开发人员面临的最具潜伏性的问题之一:它有助于防止开发永远不会被使用的代码。“*消除浪费*”是精益软件开发中的第一原则。

为程序创建永久性的单元测试脚手架可以防止代码腐烂并实现重构。它还有其他好处:保持您的理智和提高您对代码的信心。

现在承认这个 Web 应用程序有点小,不算特别复杂,但我在工作中曾对更复杂的程序使用过 TDD。这个 Web 应用程序是我业余时间开发的,每次只花 30-60 分钟。偶尔我也会花一两周时间专注于其他事情。切换项目需要大量的精神努力,但 TDD 使重新回到项目变得更容易。当您回到项目时,您运行测试,然后选择一个简单的下一个测试来实现。没有比这更好的方式从零开始重新投入了。

TDD 帮助我在代码库不断变化、添加一个又一个功能、朝着我的最终目标前进的过程中保持代码库的稳定。在此过程中,我积极地进行重构而没有引入缺陷。这一点很重要。我经常经历重构的灾难性失败,我说的是那种会导致数周甚至数月缺陷的事件。TDD 最具吸引力的好处之一是减轻了持续代码演进带来的痛苦。

我曾经听人说过:“*TDD 就像是程序员的训练轮*”。当时我笑了,但经过一番思考,我决定这个评论虽然有趣,但并非事实。我已经在 TDD 团队工作了一年,我可以诚实地说,TDD 比通常的“*凭感觉编码*”要困难得多。它需要努力去学习,并使您变慢(我称之为*开发 True Cost*)。TDD 非常强大,并非适合所有项目(在原型或探索性项目中的价值不大),但对于长期项目来说,如果您愿意进行投资,其回报可能是巨大的。

最后一点。拥有单元测试对于使应用程序跨浏览器正常运行至关重要。我在开发过程中不必进行跨浏览器测试。在最后阶段,主要就是确保单元测试在每个浏览器下都能正常运行。

MVVM

四年前,当我 first 学习 WPF 时,我 never 曾想到自己会陷入如此之深。最初的学习曲线很陡峭,但经过两年和多篇文章的学习,我对 WPF 和 MVVM 有了很好的理解。

MVVM 是从 MVC 演变而来的设计模式,它 actually 并不难理解,尽管我认为 MVVM 和 WPF 的结合以及由此产生的复杂性在最初让很多人(包括我自己)头疼。

MVVM 的基本概念非常简单:*将您的 UI 逻辑与 UI 渲染分离,以便您可以对您的 UI 逻辑进行单元测试*。基本上就是这样!它回答了这个问题:*如何对我的 GUI 进行单元测试?*

将 MVVM 整合到 Javascript 中看起来有点不同,但与 WPF 下的 MVVM 相似且更简单。Javascript/HTML 可能不是构建应用程序的理想方式,但它比使用 C#/WPF 更有生产力。我希望表明 MVVM + Web UI 可以为您带来 MVVM 的好处,同时又能避免 WPF 的复杂性(尽管如果您没有使用过 WPF,您可能不会欣赏这一点)。

AngularJS

我很高兴在转向 Web UI 的时候发现了 AngularJS

什么是 AngularJS?
最好直接从源头学习。

为什么要使用 AngularJS?
在本文中,我主要对其数据绑定能力感兴趣。AngularJS 提供了将 HTML 视图粘合到视图模型所需的魔力,并且其数据绑定使用起来非常简单。

为什么还要使用 AngularJS?
搜索“使用 AngularJS 的理由”或“AngularJS 的好处”,您会找到很多。

AngularJS 如何与 MVVM 契合?您问得好。它看起来是这样的:

控制器设置一个作用域。作用域包含绑定到HTML 视图的变量。在流程图应用程序中,作用域包含对流程图视图模型的引用,而视图模型又封装了流程图数据模型

等等,有控制器吗?
这是否意味着它是 MVC 而不是 MVVM?好吧,可以肯定的是,AngularJS 与我们所知的 MVVM 有些不同。AngularJS 模式也不同于传统的 MVC。这种情况经常发生:新的模式被创建,旧的模式被演进或扩展,这是软件开发进步的一部分。这归结于专业的开发人员根据自己的需要创建自己的模式,他们这样做主要是凭直觉,而且经常如此。在某些情况下,它们基于 MVC 等成熟的模式,其他情况则完全是针对手头问题的独特解决方案。因此,这两种模式存在差异并非是神秘事件,即使它们在更深层次上是相似的。就像微软通过 WPF 催生了 MVVM 模式一样,Google 也通过 AngularJS 创建了自己的*类 MVC* 模式。

最终,这只是术语和语义问题,我认为 AngularJS 控制器只是视图模型的一部分。我的看法是,应用程序由数据模型、视图模型和视图组成。最终,这完全取决于您的思考方式,我来自 MVVM/WPF 背景,所以我以这种方式看待我的工作,您无疑会以不同的方式看待它。

AngularJS 使用起来非常简单,我遇到的问题很少。自从我使用它以来,已经发布了多个版本,它们实际上解决了我的问题。有时我甚至深入研究了源代码,以获得更深入的理解。虽然不简单,但代码确实非常易读且易于理解。

我将在文章的最后部分讨论更多关于 AngularJS 问题和解决方案。

想要更多关于 AngularJS 的信息?
他们有很棒的文档

SVG

我最近几年才开始关注SVG,但令人惊讶的是,它 actually 已经存在了很长时间(根据维基百科,自 1999 年起)。

在使用 XAML 后,我发现微软直接从 SVG 借鉴了多少功能,这让我感到很有趣。事物就是这样运作的,如果我们不在前人的发现和发明的基础上创新,我们就无法取得任何进展。

我怀疑 SVG 曾经有点被遗忘,现在正在经历某种复兴。如今,SVG 拥有良好的浏览器支持,尽管仍然有一些问题需要注意,也有一些功能需要避免。在某种程度上,您可以直接在 HTML 中嵌入 SVG 并将其视为 HTML!不幸的是,您会遇到糟糕的库支持(我指的是jQuery),但我很高兴地说,在开发流程图 Web 应用程序的过程中,AngularJS 在 SVG 支持方面取得了进展。

我将在文章的最后部分讨论 SVG 问题

开发环境

这是我的开发环境的快速概述。

核心工具

  • Sublime2
  • Google Chrome(带有 Firefox 和 IE 用于测试)
  • node-livereload 用于在代码/HTML 更改时自动刷新浏览器(GUI LiveReload,虽然可能很棒,但在 Windows 下非常糟糕,几乎无法工作)。
  • SourceTree 用于与 GitHub 交互
  • Workflowy 用于管理我的待办事项列表
  • Internet Explorer 和 Firefox 用于测试

核心库

其他工具

  • Conemu 用于命令行工作
  • NodeJS 用于运行简单的本地 Web 服务器

特别鸣谢

  • GruntJS 是一个很棒的工具,用于脚本化您的构建过程(在这个 Web 应用中我不需要它,尽管我通常用它来构建 C# 和 Javascript 应用程序)

应用演练

概述

在本节中,我们将 walkthrough 流程图应用程序的 HTML 和代码,并理解应用程序如何与流程图视图模型进行交互。我们主要将查看 *index.html* 和 *app.js*。

在此过程中,我们将初步了解 AngularJS 应用程序的工作原理。

下图显示了应用程序中的 AngularJS 模块及其之间的依赖关系:

下一张图概述了项目中的文件:

深入到 *flowchart* 目录:

应用程序设置

应用程序的入口点是 *index.html*。它包含定义应用程序 UI 并引用必要脚本的 HTML。

传统上,脚本包含在 *head* 元素中,尽管这里只在 *head* 中包含了一个脚本。这是启用 live reload 支持的脚本。

<script src="https://:35729/livereload.js?snipver=1"></script>

Live reload 可以在源文件发生变化时自动刷新浏览器中的页面。这就是 Javascript 开发如此高效的原因,您可以更改代码,应用程序会自动重新加载并重新启动,无需编译,无需手动步骤。反馈循环大大缩短了。

Live reload 也可以通过使用浏览器插件而不是添加脚本来实现。我通常选择脚本,以便开发可以在任何机器上进行,而无需浏览器插件。当然,您可能不希望在生产环境中进行 live reload,因此您的生产服务器应该删除此脚本。

要尝试在本地进行 live reload,请确保您已安装 NodeJS 插件,并在包含网页的目录中运行 *node-live-reload*。

所有其他脚本都从 *body* 元素的末尾包含。这允许脚本在 Web 页面的 body 加载时异步加载。脚本包含在 *head* 还是 *body* 中取决于您的应用程序需要如何工作。

前两个脚本是 jQueryAngularJS,这是此应用程序构建的核心库。

<script src="lib/jquery-2.0.2.js" type="text/javascript"></script>
<script src="lib/angular-1.2.3.js" type="text/javascript"></script>

接下来是包含可重用代码的脚本,包括 SVG、鼠标处理和流程图。

<script src="debug.js" type="text/javascript"></script>
<script src="flowchart/svg_class.js" type="text/javascript"></script>
<script src="flowchart/mouse_capture_directive.js" type="text/javascript"></script>
<script src="flowchart/dragging_directive.js" type="text/javascript"></script>
<script src="flowchart/flowchart_viewmodel.js" type="text/javascript"></script>
<script src="flowchart/flowchart_directive.js" type="text/javascript"></script>

应用程序代码最后包含。

<script src="app.js" type="text/javascript"></script>

现在回到 *index.html* 的顶部,body 元素包含一些重要属性:

<body
    ng-app="app" 
    ng-controller="AppCtrl"
    mouse-capture
    ng-keydown="keyDown($event)"
    ng-keyup="keyUp($event)"
    >

ng-app 指定了包含 *AngularJS 应用程序* 的根元素。此属性的值指定了包含应用程序代码的 AngularJS 模块。这是应用程序中最重要的属性,因为它*引导*了 AngularJS。没有 *ng-app* 就没有 AngularJS 应用程序。在这种情况下,我们指定了 *app*,它将 DOM 连接到我们在 *app.js* 中注册的 *app* 模块,我们稍后会看这一点。有了 *ng-app* 和包含在页面中的 AngularJS 源代码,AngularJS 应用就会自动引导。如果需要,例如控制初始化顺序,您也可以手动引导 AngularJS 应用。有趣的是,*ng-app* 应用于整个网页的 body。这适合我,因为我希望整个页面都是一个 AngularJS 应用程序,但也可以将 *ng-app* 应用于任何子元素,从而只允许页面的一部分由 AngularJS 控制。

ng-controller 将一个AngularJS 控制器分配给页面的 body。这里分配了 *AppCtrl*,它是整个应用程序的根控制器。

mouse-capture 是我创建的自定义属性,用于管理应用程序中的鼠标捕获。

ng-keydownng-keyup 将 DOM 事件链接到 Javascript 处理函数。

如果您 already 了解 HTML 但不了解 AngularJS,那么您现在可能已经猜到 AngularJS 提供了创建自定义 HTML 属性的功能,可以将行为和逻辑连接到您的声明式用户界面。如果您没有意识到这有多么神奇,我建议您先做一些传统的 Web 编程,然后再回来学习 AngularJS。AngularJS 允许使用AngularJS 指令来扩展 HTML,添加新的元素和属性。

flow-chart 元素是一个自定义元素,用于在页面中插入流程图。

<flow-chart
    style="margin: 5px; width: 100%; height: 100%;"
    chart="chartViewModel"
    >
</flow-chart>

flow-chart 元素由一个指令定义,该指令将在 DOM 中的该位置注入一个 HTML/SVG 模板。该指令协调构成流程图的各个组件。它将鼠标输入处理程序附加到 DOM,并将它们转换为针对视图模型的操作。

flow-chart 元素的 *chart* 属性将应用程序作用域中的视图模型数据绑定到流程图的作用域。作用域是一个 Javascript 对象,包含可以通过数据绑定从 HTML/SVG 访问的变量和函数。在本例中,我们将 *chartViewModel* 绑定到 *chart* 属性,如下面的图所示:

应用程序模块设置

让我们看看 *app.js* 来查看应用程序的数据模型设置。第一行注册了 *app* 模块。

angular.module('app', ['flowChart', ])

使用 module 函数,注册了 *app* 模块。这与 *index.html* 中 *ng-app="app"* 引用的 *app* 是同一个。第一个参数是模块的名称。第二个参数是此模块依赖的模块列表。在本例中,*app* 模块依赖于 *flowChart* 模块。*flowChart* 模块包含流程图指令及其相关代码,我们稍后将对此进行介绍。

在模块之后,会注册一个AngularJS 服务。这是服务最简单的示例,稍后您将看到它是如何使用的。此服务只是返回浏览器的prompt函数。

.factory('prompt', function () {
    return prompt;
}

接下来注册应用程序的控制器

.controller('AppCtrl', ['$scope', 'prompt', function AppCtrl ($scope, prompt) {
    // ... controller code ...
}])
;

*controller* 函数的第二个参数是一个数组,包含两个字符串和一个函数。函数的参数与数组中的字符串名称相同。

如果不是为了最小化,我们可以更简单地定义控制器,如下所示:

.controller('AppCtrl', function AppCtrl ($scope, prompt) {
    // ... controller code ...
})
;

在第二种情况下,数组仅被函数替换,这更简单,但仅在开发期间有效,在生产环境中无效。AngularJS 通过调用注册的 Javascript 构造函数来实例化控制器。AngularJS 知道要实例化这个特定的控制器,因为它是在 HTML 中通过 *ng-controller="AppCtrl"* 指定的。然后,控制器参数通过基于参数名称的依赖注入来满足。AngularJS 的依赖注入实现非常简单、无缝且可靠,这让我坚信一个好的依赖注入框架应该是我编程工具包中的永久组成部分。

当然,简单的情况在应用程序已被最小化(minified)的生产环境中无效。参数名称将被缩短或混淆,因此我们必须在构造函数之前提供显式依赖项名称列表。真的很可惜我们必须这样做,因为隐式指定依赖项的方法更优雅。

*$scope* 是 AngularJS 为控制器自动创建的作用域。在本例中,作用域与*body* 元素相关联。这里的美元前缀表示 *$\scope* 由 AngularJS 本身提供。Javascript 中的美元符号只是一个可以用作标识符的字符,它对解释器没有特殊含义。我建议您不要为自己的变量使用 $,因为这样您就无法轻松识别 AngularJS 提供的变量。

*prompt* 参数是我们刚才看到的 *prompt* 服务。AngularJS 自动从我们之前注册的工厂实例创建 *prompt* 服务。您现在可能要问的问题是,为什么要在应用程序控制器中分离 *prompt* 服务?通常是为了让我们能够对应用程序控制器进行单元测试,尽管我在这里没有测试应用程序代码(但我确实测试了流程图代码,您稍后会看到)。分离意味着 prompt 服务可以被模拟,从而隔离我们要测试的代码。在本例中,我分离 prompt* 服务的原因只是想借此机会在一个最简单的场景中演示如何以及为什么使用服务。

应用程序控制器设置

现在让我们分解一下 *app.js* 中的应用程序控制器。

.controller('AppCtrl', ['$scope', 'prompt', function AppCtrl ($scope, prompt) {
    // ... Various private variables used by the controller ...

    // ... Create example data-model of the chart ...

    // ... Define application level key event handlers ...

    // ... Functions for adding/removing nodes and connectors ...

    // ... Create of the view-model and assignment to the AngularJS scope ...
}])
;

目前我们暂时跳过图表数据模型的细节。我们将在下一节中回来讨论。

应用程序控制器中最重要的事情是在函数末尾实例化视图模型。

$scope.chartViewModel = new flowchart.ChartViewModel(chartDataModel);

*ChartViewModel* 封装了数据模型,并将其分配给作用域,使其可以通过 HTML 访问。这允许我们将 *index.html* 中看到的 *chart* 属性数据绑定到 *chartViewModel*。

<flow-chart
    style="margin: 5px; width: 100%; height: 100%;"
    chart="chartViewModel"
    >
</flow-chart>

应用程序控制器创建流程图视图模型,以便它可以直接访问其服务。这是一个重要的设计决策。最初,应用程序只创建数据模型,该数据模型直接传递给流程图指令,然后流程图指令在内部用视图模型封装数据模型。我发现这种策略使应用程序对 UI 的控制不足。例如,考虑*删除选定的*流程图项。*delete* 键被处理,应用程序必须调用视图模型来删除*当前选定的*流程图项。最初的策略是直接从数据模型中删除项,并让指令检测到这一点并相应地更新视图模型,但这失败了,因为无法从数据中得知哪些项被选中!此外,这使得流程图指令更加复杂,因为它现在必须*监视*数据模型的变化,通常它只监视视图模型,而这 anyway 总是自动发生的。一个天真的方法是向数据模型添加字段来指示哪些项被选中,但这将是糟糕的设计:用视图特定的概念污染数据模型!无论如何,更改数据模型以支持选择(或其他视图功能)意味着您将无法在完全不同的视图类型之间共享数据模型,因此您可以看到,即使在原则上,将视图模型和数据模型概念结合起来也是错误的。更好的解决方案是拥有一个与流程图指令不同的视图模型,并模仿数据模型的结构。然后,应用程序就可以直接控制该视图模型,从而可以对其进行直接操作。

下图显示了应用程序与流程图组件之间的依赖关系。

作为应用程序如何与视图模型交互的一个示例,我们将看前面提到的*删除选定*功能,该功能允许删除流程图项。*ng-keyup* 被处理于 *body* 元素。

ng-keyup="keyUp($event)"

浏览器的 onkeyup 事件绑定到应用程序作用域中的*keyUp*。*$event* 对象可供 AngularJS 使用,并作为参数传递给 *keyUp*。这应该与 jQuery 事件对象基本相同,尽管 AngularJS 文档对此的说明不多。

此图说明了绑定:

*keyUp* 函数定义在 *app.js* 中,并直接分配给应用程序作用域。

$scope.keyUp = function (evt) {

    if (evt.keyCode === deleteKeyCode) {
        //
        // Delete key.
        //
        $scope.chartViewModel.deleteSelected();
    }

    // ... handling for other keys ...
};

*keyUp* 函数只是调用视图模型上的*deleteSelected*。这是应用程序直接操作流程图视图模型的一个示例,稍后我们将仔细研究此函数。

流程图数据模型设置

让我们后退一步,看看流程图数据模型的设置。

示例数据模型在 *app.js* 中内联定义:

var chartDataModel = {
    nodes: [
        // Nodes defined here.
    ],
    connections: [
        // Connections defined here.
    ]
};

然后它被视图模型封装:

$scope.chartViewModel = new flowchart.ChartViewModel(chartDataModel);

我们也可以异步加载数据模型作为 JSON 文件。

在深入研究数据模型的结构之前,您可能需要更好地理解流程图的各个组成部分。我将引用我以前文章中的图表,而不是准备新的图表。请查看该文章中的概念概述,然后回来。 ...

好的,您已经阅读了概述,对吧?您知道节点、连接器和连接之间的区别。

这是 *app.js* 中定义的单个节点的定义:

这是单个连接的定义:

数据模型中的连接通过 ID 引用其连接的节点。连接器通过索引引用。另一种方法是删除节点引用,只通过流程图中每个连接器唯一的 ID 来引用连接器。

流程图演练

概述

本节检查流程图指令、控制器、视图模型和模板的实现。

一个AngularJS 指令以 *flow-chart* 的名称注册。当 AngularJS 引导并在 DOM 中遇到 *flow-chart* 元素时,它会自动实例化该指令。然后,指令指定一个模板,该模板替换 HTML 中的 *flow-chart* 标签。指令还指定控制器并确定其作用域的设置。

流程图指令按照下图所示协调其他组件:

流程图指令和控制器定义在 *flowchart* 目录下的 *flowchart_directive.js* 中。第一行定义了 AngularJS 模块。

angular.module('flowChart', ['dragging'] )

该模块依赖于dragging 模块,该模块提供鼠标处理服务。

该模块实际上包含两个 AngularJS 指令:

.directive('flowChart', function() {
    // ...
})
.directive('chartJsonEdit', function () {
    // ...
})

*flowChart* 指令指定 SVG 模板和流程图控制器。我们将在下一节中详细介绍。

*chartJsonEdit* 指令是一个助手,它允许我们同时查看和编辑流程图的 JSON 表示和视觉 SVG 表示。这主要是为了测试、调试和帮助理解流程图的工作原理,您可能不会在生产环境中使用它,但我将其保留下来,因为它提供了一个很好的例子,说明两个*视图*如何显示同一个*视图模型*,我们稍后将对此进行更详细的介绍。

在两个指令之后,流程图控制器占据了该文件的大部分内容。

.controller('FlowChartController', ['$scope', 'dragging', '$element', 
    function FlowChartController ($scope, dragging, $element) {
        // ...
    }
])
;

在接下来的几节中,我们将详细介绍流程图的每个组件。

组件概述

流程图指令

使用指令来实现流程图,本质上是将其变成一个可重用的控件。整个指令小巧且自成一体。

.directive('flowChart', function() {
    return {
        restrict: 'E',
        templateUrl: "flowchart/flowchart_template.html",
        replace: true,
        scope: {
            chart: "=chart",
        },

        controller: 'FlowChartController',
    };
})

指令被限制为用作 HTML 元素。

restrict: 'E'

这实际上创建了一个新的 HTML 元素,*这就是 AngularJS 的强大之处*,您可以扩展 HTML,添加自己的元素和属性。这里还可以应用其他代码,例如限制为用作 HTML 属性(有效地创建新的 HTML 属性)。

restrict: 'A'

接下来的两行指定了流程图的模板,并说明它将替换 *flow-chart* 元素。

templateUrl: "flowchart/flowchart_template.html",
replace: true,

这会导致模板在 DOM 中被注入到 *flowchart* 元素的位置。

接下来,设置了一个隔离作用域

scope: {
    chart: "=chart",
},

这会为指令创建一个新的子作用域,该作用域独立于应用程序的作用域。通常,新作用域的创建(例如通过子控制器)会产生一个嵌套在父作用域下的子作用域。子作用域通过原型继承链与父作用域链接,因此父作用域的字段和函数可通过子作用域访问,甚至可能被子作用域覆盖。隔离作用域打破了这种连接,这对像流程图这样的可重用控件很重要,因为我们不希望这两个作用域相互干扰。

注意这一行:

chart: "=chart",

这会导致 HTML 元素的 *chart* 属性与作用域中的 *chart* 变量数据绑定。通过这种方式,我们以声明式方式将图表的视图模型从应用程序作用域连接到流程图作用域。

指令的最后一部分将其链接到控制器。

controller: "FlowChartController",

AngularJS 在指令实例化时按名称创建控制器。

在实际中看到的大多数指令示例都有一个 *link* 函数。在本例中,我使用控制器而不是 *link* 函数来包含指令的 UI 逻辑,我很快就会解释原因。

JSON 编辑指令

同一文件中定义的另一个指令是 *chartJsonEdit*,它将流程图的数据模型显示为可编辑的 JSON 文本。这实际上只是一个辅助工具,而不是关键的流程图组件。我使用它来进行调试和测试,它也有助于普遍理解事物的工作原理。我主要将其包含在这里,因为它很有趣,可以看到两个独立的视图(如果我们考虑指令是视图)如何显示同一个视图模型并保持同步。

我将在此处包含完整的代码和注释。主要需要注意的是同步是如何实现的。*$watch* 监视数据模型的变化。检测到更改时,数据模型将被序列化为 JSON 并显示在文本框中。当用户更新文本框时,会调用一个事件,并从更新的数据中重建视图模型。手动调用$digest,以便 AngularJS 响应更新的视图模型。

.directive('chartJsonEdit', function () {
    return {
        restrict: 'A',

        scope: {
            viewModel: "="
        },

        link: function (scope, elem, attr) {
            //
            // Serialize the data model as json and update the textarea.
            //
            var updateJson = function () {

                if (scope.viewModel) {
                    var json = JSON.stringify(scope.viewModel.data, null, 4);
                    $(elem).val(json);
                }
            };

            //
            // First up, set the initial value of the textarea.
            //
            updateJson();

            //
            // Watch for changes in the data model and update the textarea whenever necessary.
            //
            scope.$watch("viewModel.data", updateJson, true);

            //
            // Handle the change event from the textarea and update the data model
            // from the modified json.            
            //
            $(elem).bind("input propertychange", function () {

                var json = $(elem).val();
                var dataModel = JSON.parse(json);
                scope.viewModel = new flowchart.ChartViewModel(dataModel);

                scope.$digest();
            });
        }
    };
})

流程图控制器

控制器的目的是提供由模板绑定到 DOM 的输入事件处理程序。事件处理通常会路由到视图模型。由于 UI 逻辑已委托给视图模型,因此控制器的任务只是将输入事件转换为视图模型操作。这个任务本可以很容易地由指令的 *link* 函数完成,但是将 UI 逻辑分离到控制器中使得单元测试更加容易,因为控制器可以在没有 DOM 的情况下实例化。

控制器在 *flowchart_directive.js* 中,在两个指令之后注册,并占据了该文件的大部分内容。控制器本身是一个Javascript 构造函数,通过流程图模块的controller 函数注册。

.controller('FlowChartController', ['$scope', 'dragging', '$element', 
    function FlowChartController ($scope, dragging, $element) {
        // ...
    }
])
;

控制器以 *FlowChartController* 的名称注册,该名称用于从指令中引用控制器。

控制器参数由 AngularJS 在实例化控制器时自动创建并依赖注入。正如我们在应用程序控制器中所见,参数的名称被指定了两次。如果我们不需要最小化,我们可以只指定一次名称,就像参数本身的名称一样。

*$scope* 是指令的隔离作用域,其中包含一个 *chart* 字段,该字段是从应用程序作用域传递过来的视图模型。

*dragging* 是一个自定义服务,有助于鼠标处理,它太有趣了,以至于它获得了自己的章节

*$element* 是控制器附加到的 HTML 元素。此参数很容易模拟以进行单元测试,这允许在不实际实例化 DOM 的情况下进行控制器测试。

在控制器第一行,我们将this 变量缓存到一个名为 *controller* 的局部变量中。

var controller = this;

这与 Javascript 通常的var that = this 习惯用法相同,并且是必需的,以便从匿名回调函数中访问 *this* 变量,即流程图控制器。

接下来,我们缓存对document jQuery 的引用。

this.document = document;

this.jQuery = function (element) {
    return $(element);
}

这使得单元测试成为可能,因为 *document* 和 *jQuery* 可以轻松地被模拟对象替换。

接下来,我们设置作用域变量,然后是控制器的几个函数。然后,像 *mouseDown* 这样的事件处理程序被分配到作用域,以便从模板中引用。

以上是关于控制器的所有详细信息,这里还有很多内容需要涵盖,我们将在接下来的部分中逐一处理。

流程图模板

模板定义了构成流程图视觉效果的 SVG。它完全是自包含的,没有子模板。 AngularJS 当然可以(通常也需要)使用子模板,但它们可能会导致SVG 问题。模板从视图模型生成 UI,并决定 DOM 事件如何绑定到作用域中的函数。

模板可以在 *flowchart_template.html* 中找到。在理解了流程图指令之后,我们就知道模板的内容将完全替换 *index.html* 中的 *flow-chart* 元素。

整个模板被包装在一个根 SVG 元素中。

<svg
    class="draggable-container"
    xmlns="http://www.w3.org/2000/svg"
    ng-mousedown="mouseDown($event)"
    ng-mousemove="mouseMove($event)"  
    >
    <defs>
        <!-- ... -->
    </defs>

    <!-- ... content ... -->
</svg>

鼠标处理在 DOM 的多个级别进行。*鼠标按下*和*鼠标移动*在 SVG 元素上处理,以实现拖动选择鼠标悬停。模板中还有其他鼠标处理的示例,因为它们支撑着多个功能,例如:*选择节点和连接器*、*拖动节点*和*拖动连接器*。

*defs* 元素定义了一个可重用的 线性渐变 SVG,用于填充节点的背景。模板的其余部分是显示节点、连接器和连接的内容。在模板的末尾附近,定义了用于*拖动连接*(用户正在拖出的连接)和*拖动选择矩形*的图形。

流程图视图模型

视图模型紧密封装数据模型并将其呈现给视图。它提供 UI 逻辑并协调对数据的操作。视图模型可以在 *flowchart_viewmodel.js* 中找到。

那么,为什么还要有一个视图模型呢?

确实,所有流程图代码都可以驻留在流程图控制器中,甚至可以驻留在流程图指令中。我们 already 知道流程图控制器是分开的,以便于单元测试。分离*视图模型*也有助于单元测试,同时提高了模块化程度并简化了代码。然而,视图模型分离的主要原因是它允许应用程序代码直接与视图模型交互,这比与指令或控制器交互更方便。简单地说,应用程序拥有视图模型,并将其传递给指令/控制器。然后,应用程序可以自由地直接操作视图模型,而应用程序代码根本不与指令或控制器进行接口。

*flowchart_viewmodel.js* 包含构成视图模型的多个Javascript 类:*ConnectorViewModel*、*NodeViewModel*、*ConnectionViewModel* 和 *ChartViewModel*。说实话,这个文件几乎太大了!如果添加更多代码,我会重构并将其拆分为每个组件的独立文件。所有视图模型的构造函数都包含在 *flowchart* 对象中,该对象为视图模型代码创建了一个命名空间

所有构造函数都至少接受一个参数,即要封装的数据模型。在最简单的情况下,数据模型可以是一个空对象。

var chartDataModel = {};
var chartViewModel = new flowchart.ChartViewModel(chartDataModel);

当数据模型为空时,视图模型将根据需要填充它。视图模型也可以从完整或部分完成的数据模型创建,例如通过 AJAX 加载的 JSON。

var chartDataModel = {
    nodes: [
        // ...
    ],
    connections: [
        // ...
    ]
};

var chartViewModel = new flowchart.ChartViewModel(chartDataModel);

每个节点的视图模型以类似的方式创建:

var nodeViewModel = new flowchart.NodeViewModel(nodeDataModel);

连接器有点不同,连接器的 x、y 坐标被计算并传入,同时传入父节点视图模型的引用。

var connectorViewModel = new flowchart.ConnectorViewModel(connectorDataModel, computedX, computedY, parentNodeViewModel);

连接器又有所不同,并给定它们所连接的源连接器和目标连接器视图模型的引用。

var connectionViewModel = new flowchart.ConnectionViewModel(connectionDataModel, sourceConnectorViewModel, destConnectorViewModel);

下图说明了视图模型如何封装数据模型。

总之,流程图视图模型封装了许多用于操作和呈现流程图的函数。包括选择、拖动选择、删除节点和连接器以及创建新连接。

单元测试

TDD 和单元测试从一开始就让这个项目保持活力。当需要让我的代码在多个浏览器上运行时,单元测试真正发挥了作用并挽救了局面(可以说我应该从一开始就这样做,但我对跨浏览器方面 pretty 是新手)。

作为标准,单元测试文件的名称与其要测试的源文件相同,但末尾带有 *.spec*。例如,*flowchart_viewmodel.js* 的单元测试位于 *flowchart_viewmodel.spec.js* 中。

Jasmine 是一个非常棒的测试框架。我包含了 Jasmine *spec runner*,即运行测试的 HTML 页面。它在 *jasmine* 目录中。当您运行 Web 服务器时,您可以将浏览器指向 https://:8888/jasmine/SpecRunner.html 来运行单元测试。

图概念

在本节中,我将讨论流程图的每个元素以及在 UI 中表示它们所需的内容。

表示节点

要渲染一组事物,例如流程图节点,我们使用 AngularJS 的ng-repeat。这里它用于渲染视图模型中的所有节点。

<g
    ng-repeat="node in chart.nodes"
    ng-mousedown="nodeMouseDown($event, node)"
    ng-attr-transform="translate({{node.x()}}, {{node.y()}})"
    >

    <!-- ... node content ... -->
</g>

ng-repeat 会导致 SVG g 元素被展开并为每个节点重复一次。重复是由视图模型提供的节点数组:*chart.nodes* 驱动的。每次重复时,都会定义一个名为 *node* 的变量,该变量引用节点的视图模型。

ng-mousedown 将节点的鼠标按下事件绑定到控制器的 *nodeMouseDown*,后者包含在按下节点时要调用的逻辑,节点本身作为参数传递。

ng-attr-transformSVG transform 属性设置为一个转换,该转换根据视图模型中的 x、y 坐标定位节点。

ng-attr-<attribute-name> 是一个 AngularJS 新特性,它在*评估 AngularJS 表达式*之后设置给定的 HTML 或 SVG 属性。这个特性非常新,似乎还没有文档,尽管您会在指令文档中找到它的提及(特别是与 SVG 相关)。我将在 SVG 问题部分更详细地讨论*ng-attr-* 的必要性,同时我们将在模板中处处看到它的使用。

每个节点的背景都是一个 SVG rect

<rect
    ng-attr-class="{{node.selected() && 'selected-node-rect' || (node == mouseOverNode && 'mouseover-node-rect' || 'node-rect')}}"
    ry="10"
    rx="10"
    x="0"
    y="0"
    ng-attr-width="{{node.width()}}"
    ng-attr-height="{{node.height()}}"
    fill="url(#nodeBackgroundGradient)"
    >
</rect>

ng-attr-class 根据节点是已选中、未选中还是鼠标悬停在节点上,有条件地设置 SVG class。我将在*稍后*描述其他设置 SVG class 的方法(通过 jQuery/AngularJS),这些方法通常适用于HTML class,效果不佳。

ng-attr-width 和 *-height* 设置 rect 的宽度高度

fill 将 rect 的填充设置为 *nodeBackgroundGradient*,该填充在 SVG 的 defs 部分的早期定义。

接下来,一个 SVG text 显示节点的名称。

<text
    ng-attr-x="{{node.width()/2}}"
    y="25"
    text-anchor="middle"
    alignment-baseline="middle"
    >
    {{node.name()}}
</text>

文本通过将其锚定在节点的中间来水平居中。这里的 *ng-attr-x* 示例真正展示了AngularJS 表达式的强大功能。在这里,我们正在表达式中进行计算以确定节点的水平中心点,表达式的结果将设置文本的 x 坐标。

文本之后,我们看到两个独立的区域显示节点的输入和输出连接器。在深入研究连接器的视觉效果之前,让我们先概述一下渲染的节点与其 SVG 模板的关系。

ng-repeat

节点背景和名称

表示连接器

输入和输出连接器大致相同,因此我将只讨论输入连接器并指出差异。

这里再次使用 *ng-repeat* 来生成多个 SVG 元素:

<g
    ng-repeat="connector in node.inputConnectors"
    ng-mousedown="connectorMouseDown($event, node, connector, $index, true)"
    class="connector input-connector"
    >

    <!-- ... connector content ... -->
</g>

这看起来与节点的 SVG 非常相似,具有 *ng-repeat* 和 *mouse down* 的处理程序。这次,一个静态类被应用于定义为*connector*和*input-connector*的 SVG g 元素。如果它是输出连接器,则会应用*output-connector*类。

每个连接器由两个元素组成。第一个是用于显示名称的文本元素:

<text
    ng-attr-x="{{connector.x() + 20}}"
    ng-attr-y="{{connector.y()}}"
    text-anchor="left"
    alignment-baseline="middle"
    >
    {{connector.name()}}
</text>

输入和输出连接器之间的唯一区别是赋给*x 坐标*的表达式。输入连接器位于节点的左侧,因此它向*右*偏移一点。输出连接器位于相反的一侧,因此它向*左*偏移。

第二个元素是一个*圆形*形状,代表*连接锚点*,这是一个 SVG 圆形,位于连接器的坐标处。

<circle
    ng-attr-class="{{connector == mouseOverConnector && 'mouseover-connector-circle' || 'connector-circle'}}"
    ng-attr-r="{{connectorSize}}"	
    ng-attr-cx="{{connector.x()}}"
    ng-attr-cy="{{connector.y()}}"
    />

ng-attr-class 用于根据鼠标是否悬停在其上方来有条件地设置连接器的类。其他属性设置圆形的大小和位置。

下图显示了渲染的连接器与其 SVG 模板的关系。首先是*ng-repeat*:

每个连接器的内容

代表连接

连接由一个弯曲的 SVG 路径 组成,两端附加了 SVG 圆形。多个连接使用熟悉的 ng-repeat: 进行显示:

<g
    ng-repeat="connection in chart.connections"
    class="connection"
    ng-mousedown="connectionMouseDown($event, connection)"
    >

    <!-- ... connection content ... -->
</g>

弯曲路径的坐标由视图模型计算

<path
    ng-attr-class="{{connection.selected() && 'selected-connection-line' || (connection == mouseOverConnection && 'mouseover-connection-line' || 'connection-line')}}"
    ng-attr-d="M {{connection.sourceCoordX()}}, {{connection.sourceCoordY()}}
    	       C {{connection.sourceTangentX()}}, {{connection.sourceTangentY()}}
           		 {{connection.destTangentX()}}, {{connection.destTangentY()}}
                 {{connection.destCoordX()}}, {{connection.destCoordY()}}"
    >
</path>

连接的每一端都用一个小实心圆封顶。源端和目标端看起来很相似,所以我们只看源端

<circle
    ng-attr-class="{{connection.selected() && 'selected-connection-endpoint' || (connection == mouseOverConnection && 'mouseover-connection-endpoint' || 'connection-endpoint')}}"
    r="5"
    ng-attr-cx="{{connection.sourceCoordX()}}"
    ng-attr-cy="{{connection.sourceCoordY()}}"
    >
</circle>

现在,通过一些图示来理解渲染出的连接与模板之间的关系。

ng-repeat

连接的内容

UI 功能

在本节中,我将介绍一些 UI 功能的实现。讨论将贯穿应用程序、指令、控制器、视图模型和模板,以检查每个功能的运作。

选择

节点和连接可以处于选中未选中状态。单击鼠标左键选择节点或连接。点击背景取消选择所有。按住 Ctrl 键单击可实现多选

支持选择是单独将节点和连接的数据模型包装在视图模型中的主要原因。这些视图模型最简单的形式是包含一个 _selected 布尔字段,用于存储当前的选中状态。此值必须存储在视图模型中,而不是数据模型中,否则会不必要地污染数据模型,使其难以与不同类型的视图重用。

节点和连接的视图模型,NodeViewModelConnectionViewModel,都具有一个简单的 API 来管理选择,该 API 包括:

  • select() 用于选择节点或连接;
  • deselect() 用于取消选择它;
  • toggleSelected() 根据当前状态更改选择;以及
  • selected() 返回 true 当当前被选中时。

ChartViewModel 具有用于管理整个图表选择的选择 API:

  • selectAll() 选择图表中的所有节点和连接;
  • deselectAll() 取消选择所有内容;
  • updateSelectedNodesLocation(...) 按指定的增量偏移选中的节点;
  • deleteSelected() 删除所有选中的内容;
  • applySelectionRect(...)选择包含在指定矩形内的所有内容;以及
  • getSelectedNodes() 检索选中的节点列表。

节点和连接的视觉效果会根据其选择状态动态修改。ng-attr-class 根据对 selected() 的调用结果完全切换类,例如,设置节点的类

<rect
    ng-attr-class="{{node.selected() && 'selected-node-rect' || (node == mouseOverNode && 'mouseover-node-rect' || 'node-rect')}}"
    ...
    >
</rect>

当然,表达式会更复杂,因为我们还根据鼠标悬停状态设置类。如果您不熟悉 JavaScript,我应该指出 这种类型的表达式 就像三元运算符

node.selected() 返回 true 时,SVG rect 的类设置为 selected-node-rect,这是一个在 app.css 中定义的类,并修改节点的视觉效果以指示它已被选中。

同样的技巧也用于有条件地设置连接的类。

拖动选择

节点和连接也可以通过拖出一个选择矩形来选择,以包含要选择的项目。

拖动选择在多个级别上处理

  • 模板将鼠标事件处理程序绑定到 DOM;
  • 控制器提供事件处理程序并协调拖动;以及
  • 视图模型确定要选择哪些节点,然后选择它们。

最终,拖动选择期间的最终操作是选择包含在拖动选择矩形内的节点和连接。矩形的坐标和大小被传递给 applySelectionRect。此函数按以下步骤应用选择:

  • 所有最初被选中的内容都会被取消选中。
  • 节点与选择矩形进行测试,并选中包含在其中的节点。
  • 当连接附着到在上一步中选中的节点时,这些连接会被选中。

流程图控制器接收鼠标事件并协调拖动操作。鼠标按下是我们在这里感兴趣的事件,它由控制器中的 mouseDown 处理。

<svg
    class="draggable-container"
    xmlns="http://www.w3.org/2000/svg"
    ng-mousedown="mouseDown($event)"
    ng-mousemove="mouseMove($event)" 
    >

    <!-- ... -->
</svg>

查看 mouseDown,我们看到了拖动服务的第一次使用。这是一个我创建的自定义服务,用于帮助管理 AngularJS 中的拖动操作。在接下来的几节中,我们将看到它的多个示例,稍后我们将查看其实现。拖动服务作为 dragging 参数依赖注入到控制器中,这使得我们可以在控制器中的任何地方使用该服务。

mouseDown 的第一个要点是它附加到 $scope,这使其可以在 HTML 中绑定。

$scope.mouseDown = function (evt) {
    // ...
};

mouseDown 的第一个任务是确保没有任何内容被选中。这意味着流程图中的任何鼠标按下都会取消选择所有内容。这正是我们在点击流程图背景时想要的行为。

$scope.mouseDown = function (evt) {
    $scope.chart.deselectAll();

    // ...
};

取消选择所有内容后,在 dragging 服务上调用 startDrag 以开始拖动操作。

$scope.mouseDown = function (evt) {

    // ... deselect all ...

    dragging.startDrag(evt, {
        // ...
    });
};

拖动操作将一直持续到检测到鼠标抬起,在这种情况下,鼠标抬起发生在根 SVG 元素上。请注意,我们没有显式处理鼠标抬起,它由拖动服务自动处理,并且 SVG 元素上的draggable-container 类将其标识为拖动将在其中包含的元素。

多个事件处理程序(或回调)作为参数传递,并在拖动操作的关键点调用:

dragging.startDrag(evt, {

    dragStarted: function (x, y) {
        // ...
    },

    dragging: function (x, y) {
        // ...
    },

    dragEnded: function () {
        // ...
    },
});
  • dragStarted 在拖动开始时调用;
  • dragging 在拖动过程中反复调用;最后
  • dragEnded 在用户结束拖动时调用。

dragStarted 设置用于跟踪拖动操作状态的范围变量。

dragging.startDrag(evt, {
    dragStarted: function (x, y) {
        $scope.dragSelecting = true;
        var startPoint = controller.translateCoordinates(x, y);
        $scope.dragSelectionStartPoint = startPoint;
        $scope.dragSelectionRect = {
            x: startPoint.x,
            y: startPoint.y,
            width: 0,
            height: 0,
        };
    },

    dragging: // ...

    dragEnded: // ...
});

dragSelectionRect 跟踪选择矩形的坐标和大小,并且需要用于可视化显示选择矩形。

dragging 在拖动操作期间的每次鼠标移动时调用。它会随着用户拖动矩形而不断更新 dragSelectionRect

dragging.startDrag(evt, {
    dragStarted: // ...

    dragging: function (deltaX, deltaY, x, y) {
        var startPoint = $scope.dragSelectionStartPoint;
        var curPoint = controller.translateCoordinates(x, y);
        $scope.dragSelectionRect = {
            x: curPoint.x > startPoint.x ? startPoint.x : curPoint.x,
            y: curPoint.y > startPoint.y ? startPoint.y : curPoint.y,
            width: curPoint.x > startPoint.x ? x - startPoint.x : startPoint.x - x,
            height: curPoint.y > startPoint.y ? y - startPoint.y : startPoint.y - y,
        };
    },

    dragEnded: // ...
});

最终,拖动操作完成,调用 dragEnded。这会调用视图模型来应用选择矩形,然后删除用于跟踪选择矩形的范围变量。

dragging.startDrag(evt, {
    dragStarted: // ...

    dragging: // ...

    dragEnded: function () {
        $scope.dragSelecting = false;
        $scope.chart.applySelectionRect($scope.dragSelectionRect);
        delete $scope.dragSelectionStartPoint;
        delete $scope.dragSelectionRect;
    },
});

选择矩形本身显示为一个简单的 SVG rect

<rect
    ng-if="dragSelecting"
    class="drag-selection-rect"
    ng-attr-x="{{dragSelectionRect.x}}"
    ng-attr-y="{{dragSelectionRect.y}}"
    ng-attr-width="{{dragSelectionRect.width}}"
    ng-attr-height="{{dragSelectionRect.height}}"
    >
</rect>

该矩形仅在用户实际拖动时显示,因此使用绑定到 dragSelecting 变量的ng-if 条件性启用。如果您回顾 dragStarteddragEnded,您会发现该变量在拖动操作期间设置为 true

矩形通过设置其坐标和大小的 ng-attr- 属性进行定位。

节点拖动

可以通过点击节点上的任意位置然后拖动来拖动节点。可以同时拖动多个选中的节点。

节点的鼠标按下nodeMouseDown 处理。

<g
    ng-repeat="node in chart.nodes"
    ng-mousedown="nodeMouseDown($event, node)"
    ng-attr-transform="translate({{node.x()}}, {{node.y()}})"
    >

    <! -- ... -->
</g>

nodeMouseDown 使用拖动服务来协调节点的拖动。

$scope.nodeMouseDown = function (evt, node) {
    // ...

    dragging.startDrag(evt, {
        dragStarted: // ...

        dragging: // ...

        clicked: // ...
    });
};

正如我们已经看到的,有许多事件处理程序(或回调)传递给 startDrag,它们在拖动操作期间被调用。

dragStarted 在拖动开始时调用。

dragStarted: function (x, y) {

    lastMouseCoords = controller.translateCoordinates(x, y);

    if (!node.selected()) {
        chart.deselectAll();
        node.select();
    }
},

拖动选中的节点时,所有选中的节点也会被拖动,并且选择不会改变。但是,当拖动一个尚未被选中的节点时,只有该节点会被选中并拖动。

dragging 在拖动操作期间被反复调用。它计算鼠标坐标差,并调用视图模型来更新选中节点的位置。

dragging: function (x, y) {

    var curCoords = controller.translateCoordinates(x, y);
    var deltaX = curCoords.x - lastMouseCoords.x;
    var deltaY = curCoords.y - lastMouseCoords.y;

    chart.updateSelectedNodesLocation(deltaX, deltaY);

    lastMouseCoords = curCoords;
},

updateSelectedNodesLocation 是更新正在拖动的节点位置的视图模型函数。它很简单,只需枚举选中的节点并直接更新它们的坐标。

this.updateSelectedNodesLocation = function (deltaX, deltaY) {

    var selectedNodes = this.getSelectedNodes();
    for (var i = 0; i < selectedNodes.length; ++i) {
        var node = selectedNodes[i];
        node.data.x += deltaX;
        node.data.y += deltaY;
    }
};

在这种情况下,无需处理 dragEnded,因此它被 dragging service 忽略。

clicked 回调是新的,当鼠标按下导致点击而不是拖动操作时调用它。在这种情况下,我们委托给视图模型。

clicked: function () {
    chart.handleNodeClicked(node, evt.ctrlKey);
},

handleNodeClicked 会根据是否按下了 Control 键来切换选择(如果按下了 Control 键),或者取消选择所有内容,然后仅选择被点击的节点。

this.handleNodeClicked = function (node, ctrlKey) {

    if (ctrlKey) {
        node.toggleSelected();
    }
    else {
        this.deselectAll();
        node.select();
    }

    var nodeIndex = this.nodes.indexOf(node);
    if (nodeIndex == -1) {
        throw new Error("Failed to find node in view model!");
    }
    this.nodes.splice(nodeIndex, 1);
    this.nodes.push(node);          
};

请注意代码的结尾,它在每次点击后更改节点的顺序。被点击的节点被移到列表的末尾。由于节点列表驱动着ng-repeat,如前所述,它实际上控制了节点的渲染顺序。这通常被称为 Z 顺序。这意味着被点击的节点总是被带到前面

添加节点和连接器

用于向流程图添加节点的 UI 足够简单,我没有花太多时间。它只是 index.html 中的一个按钮。

<button
    ng-click="addNewNode()"
    title="Add a new node to the chart"
    >
    Add Node
</button>

ng-click点击事件绑定到 addNewNode 函数。点击按钮会调用此函数,该函数定义在 app.js 中。

$scope.addNewNode = function () {
    var nodeName = prompt("Enter a node name:", "New node");

    if (!nodeName) {
        return;
    }

    var newNodeDataModel = {
        // ... define node data-model ...
    };

    $scope.chartViewModel.addNode(newNodeDataModel);
};

该函数首先提示用户输入新节点的名称。这利用了prompt 服务,该服务定义在同一个文件中,是对浏览器prompt函数的封装。接下来设置新节点的数据模型,这与图表初始数据模型基本相同。最后调用 addNode 将新节点注入图表的视图模型。

添加连接器与添加节点非常相似。有用于添加输入或输出连接器的按钮。单击按钮时会调用一个函数,用户输入一个名称,创建数据模型,然后将连接器添加到每个选中的节点。

删除节点和连接

节点和连接通过相同的机制删除。您选择或多选要删除的内容,然后按delete 键或点击Delete Selected 按钮。点击按钮会调用 deleteSelected 函数,该函数进而调用视图模型。

$scope.deleteSelected = function () {
    $scope.chartViewModel.deleteSelected();
};

删除键使用 ng-keyup 处理页面的body

<body 
    ...
    ng-keyup="keyUp($event)"
    >

    <!-- ... -->
</body>

每次按下按键时都会调用keyUp,它会检查删除键的键码并调用视图模型。

$scope.keyUp = function (evt) {
    if (evt.keyCode === deleteKeyCode) {
        //
        // Delete key.
        //
        $scope.chartViewModel.deleteSelected();
    }

    // ....
};

我觉得这种按键事件处理方式有点丑陋。我知道存在 AngularJS 插件可以将热键直接绑定到范围函数,但我不想在此项目中包含任何额外的依赖项。如果有人知道在 AngularJS 中设置此项的更简洁的方法,请告诉我,我会更新文章!

当调用视图模型的 deleteSelected 时,它遵循几个简单的规则来确定要删除哪些节点和连接器,以及保留哪些,如下图所示。

deleteSelected 有三个主要部分:

  1. 枚举节点并删除任何选中的节点。
  2. 枚举连接器,删除任何选中的连接器,或删除任何已连接节点已被删除的连接器。
  3. 更新视图模型和数据模型,使其仅包含要保留的节点和连接器。

第一部分

this.deleteSelected = function () {
    var newNodeViewModels = [];
    var newNodeDataModels = [];
    var deletedNodeIds = [];

    for (var nodeIndex = 0; nodeIndex < this.nodes.length; ++nodeIndex) {
        var node = this.nodes[nodeIndex];

        if (!node.selected()) {
            // Only retain non-selected nodes.
            newNodeViewModels.push(node);
            newNodeDataModels.push(node.data);
        }
        else {
            // Keep track of nodes that were deleted, so their connections can also
            // be deleted.
            deletedNodeIds.push(node.data.id);
        }
    }

    // ...
};

这段代码构建了一个新的列表,其中包含要保留的节点。未选中的节点会添加到此列表中。然后构建一个单独的列表,其中包含要删除的节点的 ID。我们保留被删除节点的 ID,以便检查哪些连接现在已失效,因为已删除的节点已连接。

第二部分

this.deleteSelected = function () {
    var newNodeViewModels = [];
    var newNodeDataModels = [];
    var deletedNodeIds = [];

    // ... delete nodes ...

    var newConnectionViewModels = [];
    var newConnectionDataModels = [];

    for (var connectionIndex = 0; connectionIndex < this.connections.length; ++connectionIndex) {
        var connection = this.connections[connectionIndex];				

        if (!connection.selected() &&
            deletedNodeIds.indexOf(connection.data.source.nodeID) === -1 &&
            deletedNodeIds.indexOf(connection.data.dest.nodeID) === -1)	{	
            //
            // The nodes this connection is attached to, where not deleted,
            // so keep the connection.	
            //
            newConnectionViewModels.push(connection);
            newConnectionDataModels.push(connection.data);
        }
    }

    // ...
};

删除连接器的代码与删除节点的代码类似。同样,我们构建一个要保留的连接器列表。在这种情况下,我们不仅在连接器被选中时删除它,还在其连接的节点刚刚被删除时也删除它。

第三部分最简单,它从刚刚构建的列表中更新视图模型和数据模型。

this.deleteSelected = function () {

    // ... delete nodes ...

    // ... delete connections ...

    this.nodes = newNodeViewModels;
    this.data.nodes = newNodeDataModels;
    this.connections = newConnectionViewModels;
    this.data.connections = newConnectionDataModels;
};

鼠标悬停和 SVG 命中测试

我实现了鼠标悬停支持,以便在鼠标悬停在流程图中的项上时可以突出显示它们。详细研究这一点很有趣,因为我无法使用 AngularJS 的事件处理(例如 ng-mouseenterng-mouseleave)来实现。取而代之的是,我必须手动实现 SVG 命中测试,以确定鼠标光标下的元素。

鼠标悬停功能不仅仅是美观上的,它对于连接拖动至关重要,以便知道新的连接被放置在哪个连接器上。

根 SVG 元素将 ng-mousemove 绑定到 mouseMove 函数。

<svg
    ...
    ng-mousemove="mouseMove($event)" 
    >

    <!-- ... -->
</svg> 

这使整个 SVG 画布能够跟踪鼠标移动。

mouseMove 首先清除在上一次调用中可能缓存的鼠标悬停元素。

$scope.mouseMove = function (evt) {

    $scope.mouseOverConnection = null;
    $scope.mouseOverConnector = null;
    $scope.mouseOverNode = null;

    // ...
};

接下来是实际的命中测试。

$scope.mouseMove = function (evt) {

    // ... clear cached elements ...

    var mouseOverElement = controller.hitTest(evt.clientX, evt.clientY);
    if (mouseOverElement == null) {
        // Mouse isn't over anything, just clear all.
        return;
    }

    // ...
};

每次鼠标移动后都会调用命中测试,以确定当前鼠标光标下的 SVG 元素。当鼠标下没有 SVG 元素时(因为没有命中任何东西),mouseMove 会立即返回,因为它没有更多事情可做。发生这种情况时,已缓存的元素已被清除,因此控制器的当前状态记录为未命中任何内容

接下来,进行各种检查以确定单击的是哪种类型的元素,以便可以将该元素(如果它是一个连接、连接器或节点)缓存到相应的变量中。检查连接鼠标悬停仅在连接拖动当前未进行时是必需的。因此,连接命中测试必须有条件地启用。

$scope.mouseMove = function (evt) {

    // ...

    if (!$scope.draggingConnection) { // Only allow 'connection mouse over' when not dragging out a connection.

        // Figure out if the mouse is over a connection.
        var scope = controller.checkForHit(mouseOverElement, controller.connectionClass);
        $scope.mouseOverConnection = (scope && scope.connection) ? scope.connection : null;

        if ($scope.mouseOverConnection) {
            // Don't attempt to mouse over anything else.
            return;
        }
    }

    // ...
};

在连接命中测试之后是连接器命中测试,然后是节点命中测试。

$scope.mouseMove = function (evt) {

    // ...

    // Figure out if the mouse is over a connector.
    var scope = controller.checkForHit(mouseOverElement, controller.connectorClass);
    $scope.mouseOverConnector = (scope && scope.connector) ? scope.connector : null;
    if ($scope.mouseOverConnector) {
        // Don't attempt to mouse over anything else.
        return;   
    }

    // Figure out if the mouse is over a node.
    var scope = controller.checkForHit(mouseOverElement, controller.nodeClass);    
    $scope.mouseOverNode = (scope && scope.node) ? scope.node : null;		
};

鼠标悬停元素被缓存到三个变量之一:mouseOverConnectionmouseOverConnectormouseOverNode。其中每个都是范围变量,并从 SVG 引用以有条件地在鼠标悬停时启用特殊类,使连接、连接器或节点在鼠标悬停时看起来不同。

ng-attr-class 根据鼠标悬停状态(以及选择状态)有条件地设置 SVG 元素的类。

ng-attr-class="{{connection.selected() && 'selected-connection-line' || (connection == mouseOverConnection && 'mouseover-connection-line' || 'connection-line')}}"

这个复杂的表达式将类设置为选中的连接的selected-connection-line,鼠标悬停在其上的mouseover-connection-line,或者在以上两种条件都不为true时设置为connection-line

mouseMove 依赖于 hitTestcheckForHit 函数来完成繁重的工作。hitTest 仅调用 elementFromPoint 来确定指定坐标下的元素。

this.hitTest = function (clientX, clientY) {
    return this.document.elementFromPoint(clientX, clientY);
};

checkForHit 调用 searchUp,它递归地向上搜索 DOM,查找具有以下类之一的元素:connectionconnectornode。这样,我们就可以找到与我们要进行命中测试的流程图组件最直接相关的 SVG 元素。

this.searchUp = function (element, parentClass) {
    //
    // Reached the root.
    //
    if (element == null || element.length == 0) {
        return null;
    }

    // 
    // Check if the element has the class that identifies it as a connector.
    //
    if (hasClassSVG(element, parentClass)) {
        //	
        // Found the connector element.
        //
        return element;
    }

    //
    // Recursively search parent elements.
    //
    return this.searchUp(element.parent(), parentClass);
};

searchUp 依赖于自定义函数 hasClassSVG 来检查元素的类。jQuery 通常用于检查 HTML 元素的类,但不幸的是,它对 SVG 元素不起作用。我将在 SVG 问题中更详细地讨论这一点。

hitTestcheckForHit 都实现为单独的函数,因此可以轻松地在单元测试中用模拟对象替换它们。

连接拖动

连接是通过拖出一个连接器创建的,创建的连接可以由用户拖动。当新连接的端点被拖到一个另一个连接器上并提交到视图模型时,新连接的创建就完成了。当连接被拖动时,它由一个 SVG 视觉效果表示,该视觉效果独立于流程图中的其他连接。

ng-ifdraggingConnection 设置为 true 时有条件地显示视觉效果。

<g
    ng-if="draggingConnection"
    >
    <path
        class="dragging-connection dragging-connection-line"
        ng-attr-d="M {{dragPoint1.x}}, {{dragPoint1.y}}
                   C {{dragTangent1.x}}, {{dragTangent1.y}}	
                     {{dragTangent2.x}}, {{dragTangent2.y}}
                     {{dragPoint2.x}}, {{dragPoint2.y}}"
        >
    </path>

    <circle
        class="dragging-connection dragging-connection-endpoint"
        r="4"
        ng-attr-cx="{{dragPoint1.x}}" 
        ng-attr-cy="{{dragPoint1.y}}" 
        >
    </circle>

    <circle
        class="dragging-connection dragging-connection-endpoint"
        r="4" 
        ng-attr-cx="{{dragPoint2.x}}" 
        ng-attr-cy="{{dragPoint2.y}}" 	
        >
    </circle>
</g>

连接的端点和曲线由以下变量定义:dragPoint1, dragPoint2, dragTangent1dragTangent2

连接拖动由在连接器鼠标按下启动。鼠标按下事件绑定到 connectorMouseDown

<g
    ng-repeat="connector in node.outputConnectors"
    ng-mousedown="connectorMouseDown($event, node, connector, $index, false)"
    class="connector output-connector"
    >

    <!-- ... connector ... -->
</g>

connectorMouseDown 使用拖动服务来管理拖动操作,这一点我们已经见过多次。

$scope.connectorMouseDown = function (evt, node, connector, connectorIndex, isInputConnector) {

    dragging.startDrag(evt, {
        // ... handle dragging events ...
    });
};

端点和切线在拖动开始时计算。

dragStarted: function (x, y) {
    var curCoords = controller.translateCoordinates(x, y);
    $scope.draggingConnection = true;
    $scope.dragPoint1 = flowchart.computeConnectorPos(node, connectorIndex, isInputConnector);
    $scope.dragPoint2 = {
        x: curCoords.x,
        y: curCoords.y
    };
    $scope.dragTangent1 = flowchart.computeConnectionSourceTangent($scope.dragPoint1, $scope.dragPoint2);
    $scope.dragTangent2 = flowchart.computeConnectionDestTangent($scope.dragPoint1, $scope.dragPoint2);
},

draggingConnection 已设置为 true,从而启用 SVG 视觉效果的显示。

第一个端点固定在被拖出的连接器上。第二个端点固定在鼠标光标的当前位置。

连接的端点和切线在拖动过程中反复更新。

dragging: function (x, y, evt) {
    var startCoords = controller.translateCoordinates(x, y);
    $scope.dragPoint1 = flowchart.computeConnectorPos(node, connectorIndex, isInputConnector);
    $scope.dragPoint2 = {
        x: startCoords.x,
        y: startCoords.y
    };
    $scope.dragTangent1 = flowchart.computeConnectionSourceTangent($scope.dragPoint1, $scope.dragPoint2);
    $scope.dragTangent2 = flowchart.computeConnectionDestTangent($scope.dragPoint1, $scope.dragPoint2);
},

拖动操作完成后,新连接被提交到流程图。

dragEnded: function () {
    if ($scope.mouseOverConnector && 
        $scope.mouseOverConnector !== connector) {
        $scope.chart.createNewConnection(connector, $scope.mouseOverConnector);
    }

    $scope.draggingConnection = false;
    delete $scope.dragPoint1;
    delete $scope.dragTangent1;
    delete $scope.dragPoint2;
    delete $scope.dragTangent2;
},

不再需要的范围变量被删除。然后将 draggingConnection 设置为 false 以禁用拖动连接视觉效果的渲染。

注意唯一的验证规则:无法创建会循环回同一连接器的连接。如果这是生产代码,它可能会有更多的验证规则或某种添加用户定义规则的方法。

如果您对 translateCoordinates 的调用感兴趣,我将在 SVG 问题中进行解释。

拖动服务

流程图依赖于良好的鼠标输入处理,因此使其正确至关重要。只有流程图指令与拖动服务通信,视图模型对此一无所知。拖动服务反过来又依赖于鼠标捕获服务

拖动在许多不同的应用程序中都是必需的,并且要正确处理它出乎意料地棘手。将拖动代码直接嵌入 UI 代码会使事情复杂化,因为您通常必须将拖动操作作为某种状态机来管理。随着不同类型的拖动操作的需求增加和复杂性的增长,这会变得更加痛苦。已经有一些 Javascript 库和插件可以做到这一点,但是我想做一个能与 HTML、SVG 和 AngularJS 很好地配合的东西。

流程图指令以以下方式使用拖动指令,我们已经研究了它们的工作原理:

  • 拖动选择;
  • 节点拖动;以及
  • 连接拖动。

如果您想象一下,如果拖动不是在一个单独的可重用库中,流程图指令(尽管相对简单)可能会变得非常复杂,所有三种拖动操作都直接处理。事件驱动编程来拯救我们,Javascript 对此有很好的支持,因为我们使用匿名函数来为事件定义内联回调。

必须调用 startDrag 来启动拖动操作。这旨在响应鼠标按下事件。将匿名函数作为参数传递以处理拖动事件。

dragging.startDrag(evt, {
    dragStarted: function (x, y) {
        // ... event handler ...
    },

    dragging: function (x, y, evt) {
        // ... event handler ...
    },

    dragEnded: function () {
        // ... event handler ...
    },

    clicked: function () {
        // ... event handler ...
    },
});

dragStarteddraggingdragEnded 在拖动操作的关键事件中被调用。clicked鼠标按下后跟鼠标抬起但没有发生拖动(或至少鼠标没有移动超过一个小的阈值)时被调用。这被认为是鼠标点击而不是鼠标拖动

该服务在 dragging_service.js 中实现。文件开头定义了一个AngularJS 模块

angular.module('dragging', ['mouseCapture', ] )

dragging 模块依赖于 mouseCapture 模块。文件的其余部分包含服务的定义。

.factory('dragging', function ($rootScope, mouseCapture) {

    // ...

    return {
        // ... functions exported by the service ...
    };
})
;

工厂函数返回的对象是实际的服务。该服务在 dragging 名称下注册,以便 AngularJS 在需要将其作为 dragging 参数依赖注入到 FlowChartController 时可以实例化该服务。

该服务导出了我们已经多次使用的唯一函数 startDrag

return {
    startDrag: function (evt, config) {
        // ...
    },
};

startDrag 的参数是鼠标按下事件的事件对象和一个包含事件处理程序的配置对象。startDrag 在拖动操作期间捕获鼠标。嵌套函数在捕获期间处理拖动事件,以便它可以监视鼠标状态。

startDrag: function (evt, config) {
    var dragging = false;
    var x = evt.pageX;
    var y = evt.pageY;

    var mouseMove = function (evt) {
        // ... handle mouse move events during dragging ...
    };

    var released = function() {
        // ... handle release of mouse capture and end dragging ...
    };

    var mouseUp = function (evt) {
        // ... handle mouse up and release the mouse capture ...
    };

    mouseCapture.acquire(evt, {
        mouseMove: mouseMove,
        mouseUp: mouseUp,
        released: released,\
    });

    evt.stopPropagation();
    evt.preventDefault();
},

调用 mouseCapture.acquire 会捕获鼠标,然后服务会处理鼠标输入事件。这允许拖动操作通过在页面子元素上(通过在该元素上鼠标按下)启动,然后由父元素(在本例中为body 元素)上的事件处理拖动。在 Windows 编程中,鼠标捕获由操作系统支持。然而,在浏览器中工作时,这必须手动实现,所以我创建了一个自定义鼠标捕获服务,在下一节中进行了讨论。

请注意,startDrag 会停止 DOM 事件的传播并阻止默认操作,拖动服务提供自定义输入处理,因此我们阻止了浏览器的默认操作。

让我们看看拖动期间活动的鼠标事件处理程序。mouse move 处理程序有两种身份。在拖动开始之前,它会不断检查鼠标坐标,以查看它们是否超过了一个小的阈值。当发生这种情况时,拖动操作开始,并调用 dragStarted

从那时起,拖动正在进行中,mouseMove 会持续跟踪鼠标坐标并反复调用 dragging 函数。

var mouseMove = function (evt) {
    if (!dragging) {
        if (evt.pageX - x > threshold ||
            evt.pageY - y > threshold) {
            dragging = true;

            if (config.dragStarted) {
                config.dragStarted(x, y, evt);
            }

            if (config.dragging) {
                // First 'dragging' call to take into account that we have 
                // already moved the mouse by a 'threshold' amount.
                config.dragging(evt.pageX, evt.pageY, evt);
            }
        }
    }
    else {	
        if (config.dragging) {
            config.dragging(evt.pageX, evt.pageY, evt);
        }

        x = evt.pageX;
        y = evt.pageY;
    }
};

当鼠标捕获被释放时,会调用release 处理程序。这可以通过两种方式之一发生。mouse up 处理程序已停止拖动操作并请求释放鼠标。或者,如果其他代码获得了鼠标捕获,强制释放。release 也有两种身份,如果拖动正在进行,它会调用 dragEnded。如果拖动从未开始,因为鼠标从未超过阈值,则改为调用 clicked 来表示拖动从未开始,用户只是鼠标点击

var released = function() {
    if (dragging) {
        if (config.dragEnded) {
            config.dragEnded();
        }
    }
    else {
        if (config.clicked) {	
            config.clicked();
        }
    }
};

mouse up 处理程序很简单,它只是释放鼠标捕获(这会调用release处理程序)并停止事件传播。

var mouseUp = function (evt) {
    mouseCapture.release();
    evt.stopPropagation();
    evt.preventDefault();
};

鼠标捕获服务

在开发 Windows 应用程序时,鼠标捕获被理所当然地使用。当鼠标捕获获取时,我们可以专门处理元素的鼠标事件,直到捕获被释放。在浏览器中工作时,似乎没有内置方法可以实现这一点。通过使用AngularJS 指令服务,我能够创建自己的自定义属性,将此行为附加到 DOM。

mouse-capture 属性标识可以捕获鼠标的元素。在流程图应用程序中,mouse-capture 应用于 HTML 页面的 body。

<body
    ng-app="app"
    ng-controller="AppCtrl"
    mouse-capture
    ng-keydown="keyDown($event)"
    ng-keyup="keyUp($event)"
    >

    <!-- ... -->
</body>

实现此属性的小指令位于 mouse_capture_directive.js 的末尾。文件的其余部分实现了用于获取鼠标捕获的服务。

文件以注册模块开始。

angular.module('mouseCapture', [])

此模块没有依赖项,因此是空数组。

接下来注册服务。

.factory('mouseCapture', function ($rootScope) {

    // ... setup and event handlers ...

    return {
        // ... functions exported by the service ...
    };
})

这是一个相当大的文件,我们稍后会回来讨论它。文件末尾有一个与服务同名的指令。

.directive('mouseCapture', function () {
    return {
        restrict: 'A',
        controller: function($scope, $element, $attrs, mouseCapture) {
            mouseCapture.registerElement($element);
        },
    };
})
;

服务和指令可以具有相同的名称,因为它们在不同的上下文中被使用。服务被依赖注入到 Javascript 函数中,而指令用作 HTML 属性(因此是restrict: 'A'),因此它们的用法不重叠。

该指令定义了一个控制器,它在 DOM 加载时进行初始化。mouseCapture 服务本身被注入到控制器以及 DOM 元素中。该指令使用服务来注册要捕获鼠标的元素,这是在捕获期间将处理鼠标移动鼠标抬起的元素。

回到服务。工厂函数在返回服务之前定义了几个鼠标事件处理程序。

.factory('mouseCapture', function ($rootScope) {

    // ... state variables ...

    var mouseMove = function (evt) {   		
        // ... handle mouse movement while the mouse is captured ...
    };

    var mouseUp = function (evt) {
        // ... handle mouse up while the mouse is capture ...
    };

    return {
        // ... functions exported by the service ...
    };
})

当获取鼠标捕获时,处理程序会动态附加到 DOM,并在释放鼠标捕获时分离。

服务本身导出三个函数:

return {
    registerElement: function(element) {
        // ... register the DOM element whose mouse events will be hooked ...
    },

    acquire: function (evt, config) {
        // ... acquires the mouse capture ...
    },

    release: function () {  
        // ... releases the mouse capture ...
    },
};

registerElement 很简单,它缓存可以捕获鼠标事件的单个元素(在本例中是body 元素)。

registerElement: function(element) {
    $element = element;
},

acquire 会释放任何先前的鼠标捕获,缓存配置对象并绑定事件处理程序。

acquire: function (evt, config) {
    this.release();
    mouseCaptureConfig = config;
    $element.mousemove(mouseMove);
    $element.mouseup(mouseUp);
},

release 调用released事件处理程序并解除绑定事件处理程序。

release: function () {
    if (mouseCaptureConfig) {
        if (mouseCaptureConfig.released) {
            mouseCaptureConfig.released();
        }

        mouseCaptureConfig = null;
    }

    $element.unbind("mousemove", mouseMove);
    $element.unbind("mouseup", mouseUp);
},

当鼠标被捕获时,会调用 mouseMovemouseUp 来处理鼠标事件,这些事件会被转发给更高级别的代码(例如拖动服务)。

mouseMovemouseUp 非常相似,所以我们只看mouseMove

var mouseMove = function (evt) {
    if (mouseCaptureConfig && mouseCaptureConfig.mouseMove) {
        mouseCaptureConfig.mouseMove(evt);
        $rootScope.$digest();
    }
};

必须调用$digest 函数,使 AngularJS 能够识别由鼠标捕获服务客户端进行的模型更改。AngularJS 需要知道模型何时发生更改,以便它可以根据需要重新渲染 DOM。在编写 AngularJS 应用程序时,大多数时候您不需要了解$digest,它只在您在指令或服务中低级别工作,并且通常直接操作 DOM 时才会发挥作用。

问题

Web UI 问题

客户端 Web 开发充满问题,任何从事过这项工作的人都很清楚。使用 jQuery 等库和 AngularJS 等框架可以在很大程度上避免问题。适当使用 Javascript(感谢 Crockford 先生!)并使用单元测试来构建代码,可以进一步避免传统问题。良好的软件开发技能和对适当模式的理解,极大地有助于避免过去 Javascript 的维护和调试噩梦。

即使面临客户端 Web 开发的所有问题,我认为我实际上更喜欢它而不是常规应用程序开发。作为一名专业软件开发人员,我两者都做一些,但如果将来可能,我可能会考虑将桌面应用程序开发为独立的 Web 应用程序。无需使用编译器(除非您愿意)带来的生产力提升,以及可皮肤化应用程序的可能性不容忽视,尽管我确实怀念 Visual Studio 的重构支持。

我从 Windows 桌面编程中非常怀念的一件事是能够捕获鼠标,为了实现这一点,我必须创建自己的 DIY 鼠标捕获系统。

AngularJS 问题

尽管我曾遇到过 AngularJS 的一些问题,但我必须明确一点:AngularJS 非常棒。它使客户端 Web 开发变得更加容易,以至于它几乎让我相信这比 WPF 更好地构建 UI。

自项目开始以来,AngularJS 已经发展。最近添加了对ng-attr-的支持,这似乎是为了解决数据绑定 SVG 元素属性的问题,这正是我遇到的问题!这项功能如此新颖且如此必要,以至于最初我不得不直接从 AngularJS 仓库克隆以尽早访问它。它仍然非常新,以至于他们唯一的文档似乎是指令帮助的一部分。

ng-if 是在此项目期间出现的另一项功能,能够有条件地显示 HTML/SVG 元素被证明非常有用。

学习曲线很陡峭。这不仅仅是 AngularJS,而是我提高 Web 开发技能付出了巨大的努力。总而言之,AngularJS 的问题非常少,它解决的问题数量远远超过了它的学习曲线或我使用它时遇到的任何问题。

SVG 问题

当我开始集成 AngularJS/jQuery 和 SVG 时,我遇到了许多小问题。为了帮助弄清楚我能做什么和不能做什么,我创建了一个大型测试平台,测试了集成的许多不同方面。这使我能够弄清楚我想要避免的问题区域,并找到我无法避免的区域的解决方案。

创建测试平台使我能够解决这些问题并提高我对 SVG 及其与 AngularJS 功能(如ng-repeat)交互的理解。我发现很难创建在根 SVG 元素下注入 SVG 元素的指令。这似乎是因为 jQuery 在 HTML 命名空间而不是 SVG 命名空间中创建元素。AngularJS 在底层使用 jQuery,因此实例化 SVG 模板的部分会导致元素根本不是 SVG 元素,这显然没有帮助。这是使用 jQuery 创建 SVG 元素时的一个众所周知的问题(如果您在听,请修复它!),并且有很多信息可以为您提供解决此问题的迂回方法。但在流程图应用程序中,我通过将所有 SVG 包含在一个单独的模板中,并在 SVG 元素中显式指定命名空间,完全避免了命名空间问题。

不幸的是,SVG DOM 与 HTML DOM 不同,因此许多您可能期望工作的 jQuery 函数不起作用(尽管有些可以正常工作)。一个显著的例子是设置元素的类。由于使用 jQuery 时这对于 SVG 不起作用,因此对于 AngularJS 也不起作用,因为它建立在 jQuery 之上。因此,ng-class 不能使用。这就是为什么我不得不在 SVG 中多次使用 ng-attr-class 来有条件地设置类。这也不是一个坏选择,因为我认为 ng-attr-class 比其他选项更容易理解,尽管它确实只能一次将一个类应用于元素。在其他情况下(例如,鼠标悬停代码),我通过避免 jQuery 并使用自定义函数来检查 SVG 类来解决类问题。感谢 Justin McCandless 分享他解决此问题的解决方案

有一些现有的库可以帮助处理 jQuery 对 SVG 的糟糕支持。jQuery SVG 插件看起来不错,但前提是您想以编程方式创建和操作 SVG。我热衷于使用 AngularJS 模板以声明方式定义 SVG。

通过实现我自己的命中测试和鼠标悬停代码,我避免了 jQuery 的 mouseenter/mouseleave 事件与 SVG 相关的潜在问题。为此,使用极其简单的函数elementFromPoint 似乎是最方便的选择。

我遇到的另一个 jQuery 问题是 offset 函数。最初我使用它将页面坐标转换为 SVG 坐标。出于某种原因,这在Firefox 下不起作用。在在线研究后,我创建了 translateCoordinates 函数,它使用 SVG API 来实现转换。

Firefox 下的另一个问题是,SVG rect 的fill 属性不能使用 CSS 设置。这在其他浏览器中有效,但在 Firefox 下,我不得不将其更改为将 fill 设置为 rect 的属性,而不是通过 CSS。

我还有另一个值得一提的问题。它很奇怪,我从未完全弄清楚。我嵌套了 SVG g 元素来表示一个连接,并对其应用了 ng-repeat 来渲染多个连接(即一个嵌套在另一个 g 中的 g)。当没有连接时(导致 ng-repeat 显示为空),所有在连接之后的 SVG 元素都被清除了。消失了!嵌套的 g 元素实际上是多余的,所以我能够将其简化为一个包含连接视觉效果的单个 g。这解决了这个非常奇怪的问题。我在 HTML 而不是 SVG 下测试了这个问题,没有出现问题,所以我假设它只在使用 AngularJS 下的 SVG 时出现(或者可能与 jQuery 有关)。

结论

文章到此结束。 感谢您的阅读。 

您提供的任何反馈或错误报告都将不胜感激,我将努力酌情更新文章和代码。 我将为您提供一些未来的想法和有用的资源链接。

未来改进

可以应用于此代码的未来改进仅仅是来自原始文章中 NetworkView 的功能。

  • 模板支持不同类型的节点
  • 用于反馈的装饰器
  • 缩放和平移

资源

AngularJS

http://angularjs.org/

http://docs.angularjs.org/api

jQuery

https://jqueryjs.cn/

SVG

https://w3schools.org.cn/svg/

Jasmine

http://pivotal.github.io/jasmine/

测试驱动开发

http://net.tutsplus.com/tutorials/php/the-newbies-guide-to-test-driven-development/

© . All rights reserved.