精通-Angular-组件第二版-三-

45 阅读54分钟

精通 Angular 组件第二版(三)

原文:zh.annas-archive.org/md5/74e15f35f78fc549e292088a2a9a4e5f

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:保持活动更新

在本章中,我们将使用 可伸缩矢量图形SVG)和 Angular 构建图形组件来构建我们的任务管理系统中的活动日志。SVG 是处理复杂图形内容的完美选择,通过使用 Angular 组件,我们可以轻松构建封装和可重用的内容。

由于我们希望记录我们应用程序中的所有活动,例如添加评论或重命名任务,我们将创建一个活动中央存储库。然后我们可以使用 SVG 显示这些活动并将它们渲染为活动时间线。

为了添加所有活动的概述并提供用户输入以缩小显示活动的范围,我们将创建一个交互式滑块组件。此组件将使用投影在滑块的背景上渲染时间戳,以形式为刻度和活动。我们还将使用 SVG 在组件内渲染元素。

本章将涵盖以下主题:

  • SVG 的基本介绍

  • 使 SVG 可与 Angular 组件组合

  • 在组件模板中使用命名空间

  • 创建一个简单的管道,使用 Moment.js 格式化日历时间

  • 使用 @HostListener 装饰器处理用户输入事件,以创建交互式滑块元素

  • 使用 ViewEncapsulation.Native 来利用 Shadow DOM,以创建原生样式封装

创建用于记录活动的服务

本章的目标是提供一个方法来跟踪任务管理应用程序中的所有用户活动。为此,我们需要一个系统,允许我们在组件内记录活动并访问先前记录的活动。

在本章中,我们只跟踪项目上的活动。然而,活动跟踪器可以用于我们应用程序中的任何功能。我们将使用 TypeScript 区分联合来描述我们的活动。让我们直接进入正题,首先创建我们新活动功能中使用的模型。

打开位于 src/app/model.ts 的模型文件,并添加以下更改:

export type ActivityAlignment = 'left' | 'right';

export interface ActivitySliderSelection {
  start: number;
  end: number;
}

export interface ActivityBase {
  kind: string;
  id?: number;
  user: User;
  time: number;
  category: string;
  title: string;
  message: string;
}

export interface ProjectActivity extends ActivityBase {
  kind: 'project';
  projectId: number;
}

export type Activity = ProjectActivity;

作为实体,活动应该是相当通用的,并且应该具有以下字段及其相应用途:

  • user:负责此活动的用户对象。

  • time:活动的时间戳。我们将对此时间戳进行格式化以供可读的显示格式,但当我们绘制活动滑块时,我们还将使用它进行投影数学。

  • category:此字段提供了一种额外的标记活动的方式。对于项目,我们目前将使用两个类别;评论任务

  • title:这指的是活动的标题,它将提供关于活动内容的非常简短的摘要。这可能像 新任务已添加评论已添加 这样的内容。

  • message:这是活动真正内容所在字段。它应该包含足够的信息,以便在活动期间提供良好的可追溯性。

此外,我们还在创建一个接口ActivitySliderSelection,我们将在与我们的自定义滑块 UI 组件通信选择变化时使用它。

自定义类型ActivityAlignment将被用来存储关于活动在时间线上的位置信息。

让我们也更新我们的内存数据库,以便在创建活动视图的 UI 组件时有东西可以工作。打开位于src/app/database.ts的文件,并应用以下更改:

import {InMemoryDbService} from 'angular-in-memory-web-api';
import {Activity, Project, Task, User} from './model';

export class Database implements InMemoryDbService {
  createDb() {
    …

    const now = +new Date();

 const activities: Activity[] = [{
 id: 1,
 kind: 'project',
 user: users[0],
 time: now - 1000 * 60 * 60 * 8,
 projectId: 1,
 category: 'tasks',
 title: 'A task was updated',
 message: 'The task \'Task 1\' was updated on #project-1.'
 }, {
 id: 2,
 kind: 'project',
 user: users[0],
 time: now - 1000 * 60 * 60 * 5,
 projectId: 2,
 category: 'tasks',
 title: 'A task was updated',
 message: 'The task \'Task 1\' was updated on #project-2.'
 }, {
 id: 3,
 kind: 'project',
 user: users[0],
 time: now - 1000 * 60 * 60 * 2,
 projectId: 2,
 category: 'tasks',
 title: 'A task was updated',
 message: 'The task \'Task 2\' was updated on #project-2.'
 }];

    return {users, projects, tasks, activities};
  }
}

现在,我们可以继续创建一个服务来加载活动和记录新活动。让我们使用 Angular CLI 来创建我们服务的占位符:

ng generate service --spec false activities/activities

这将在路径src/app/activities/activities.service.ts上生成一个新的服务类。让我们打开那个文件,并添加必要的代码来实现我们的服务:

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject} from 'rxjs';
import {Activity, ProjectActivity, User} from '../model';
import {UserService} from '../user/user.service';
import {map, mergeMap, take} from 'rxjs/operators';

@Injectable()
export class ActivitiesService {
  private activities = new BehaviorSubject<Activity[]>([]);

  constructor(private http: HttpClient, private userService: UserService) {
    this.loadActivities();
  }

  private loadActivities() {
    this.http.get<Activity[]>('/api/activities')
      .subscribe((activities) => this.activities.next(activities));
  }

  getActivities() {
    return this.activities
      .asObservable().pipe(
        map(activities => activities.sort((a, b) => b.time - a.time))
      );
  }

  logProjectActivity(projectId: number, category: string, title: string, message: string) {
    this.userService.getCurrentUser()
      .pipe(
        take(1),
        mergeMap((user: User) => this.http
          .post('/api/activities', <ProjectActivity>{
            kind: 'project',
            time: +new Date(),
            projectId,
            user,
            category,
            title,
            message
          })
        )
      ).subscribe(() => this.loadActivities());
  }
}

这里没有太多需要讨论的。我们的服务与我们已经创建的任务列表或项目服务非常相似。另外,当我们获取我们的活动行为主题的可观察对象时,我们正在对发出的活动列表进行排序。我们总是希望按活动时间发出排序后的活动列表。

由于活动不能被编辑或删除,我们只需要关注新添加的活动。

logProjectActivity方法中,我们只是使用 Angular HTTP 客户端将一个新的活动发布到我们的内存中 Web 数据库。用户服务将为我们提供当前登录用户的信息。

数据方面就到这里。我们创建了一个简单的平台,将帮助我们跟踪应用程序中的活动。在本书的后面部分,我们可以使用活动服务来跟踪所有类型的活动。现在,我们只关心与项目相关的活动。

记录活动

我们已经创建了一个很好的系统来记录活动。现在,让我们继续在我们的组件中使用它,以保持对项目上下文中发生的所有活动的审计。

首先,让我们使用我们的活动服务来记录当项目任务被更新和创建时的活动。记录活动可以被视为应用程序的副作用,我们不希望在纯 UI 组件中引起副作用。相反,容器组件是执行这些操作的理想场所。

让我们打开位于src/app/container/task-list-container/task-list-container.component.ts的任务列表容器组件,并应用以下更改:

import {ActivitiesService} from '../../activities/activities.service';
import {limitWithEllipsis} from '../../utilities/string-utilities';

@Component({
  selector: 'mac-task-list-container',
  templateUrl: './task-list-container.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListContainerComponent {
  …

  constructor(private taskService: TaskService,
              private projectService: ProjectService,
              private route: ActivatedRoute,
 private activitiesService: ActivitiesService) {
    …
  }

  activateFilterType(type: TaskListFilterType) {
    this.activeTaskFilterType.next(type);
  }

  addTask(title: string) {
    this.selectedProject
      .pipe(
        take(1)
      )
      .subscribe((project) => {
        const task: Task = {
          projectId: project.id, title, done: false
        };
        this.taskService.addTask(task);
 this.activitiesService.logProjectActivity(
 project.id,
 'tasks',
 'A task was added',
 `A new task "${limitWithEllipsis(title, 30)}" was added to #project-${project.id}.`
 );
      });
  }

  updateTask(task: Task) {
    this.taskService.updateTask(task);
 this.activitiesService.logProjectActivity(
 task.projectId,
 'tasks',
 'A task was updated',
 `The task "${limitWithEllipsis(task.title, 30)}" was updated on #project-${task.projectId}.`
 );
  }
}

使用我们活动服务的logProjectActivity方法,我们可以轻松地记录创建和更新任务的活动。

在我们的活动消息体中,我们使用了一个新的实用函数limitWithEllipsis,我们从这个名为string-utilities的新模块中导入它。这个函数截断输入字符串,并在第二个参数指定的位置截断。此外,它还在截断字符串的末尾添加一个省略号字符()。这是一个当我们需要创建可能很长的文本预览时的有用工具。

让我们快速构建这个小小的辅助函数,并在路径src/app/utilities/string-utilities.ts上创建一个新文件。打开文件,并添加以下代码:

export function limitWithEllipsis(str: string, limit: number): string {
  if (str.length > limit) {
    return str.slice(0, limit - 1) + '…';
  } else {
    return str;
  }
}

到此为止。我们已经成功地在任务创建和更新时记录活动。我们还将使用项目评论容器组件中的活动服务来创建添加和编辑评论的日志。由于涉及的步骤与我们刚刚为任务列表容器组件所做的工作非常相似,我们将跳过这一部分。您始终可以查看本章的最终代码库,为项目评论容器组件添加活动日志。

利用 SVG 的力量

SVG 自 1999 年以来一直是开放网络平台标准的一部分,并于 2001 年首次推荐,当时是 SVG 1.0 标准。SVG 是两个基于 XML 的矢量图像格式的独立提案的整合。精确图形标记语言PGML),主要由 Adobe 和 Netscape 开发,以及矢量标记语言VML),主要由 Microsoft 和 Macromedia 代表,都是不同的 XML 格式,服务于相同的目的。W3C 联盟拒绝了这两个提案,转而支持新开发的 SVG 标准,将两者的优点统一到一个标准中:

展示 SVG 标准发展的时间线

所有这三个标准都有一个共同的目标,那就是为网络提供一个格式,以便在浏览器中显示矢量图形。SVG 是一种声明性语言,它使用 XML 元素和属性来指定图形对象。

让我们看看如何使用 SVG 创建一个黑色圆形的简单示例:

<?xml version="1.0" encoding="utf-8"?>   
<svg version="1.1"   
     width="20px" height="20px"> 
  <circle cx="10" cy="10" r="10" fill="black" /> 
</svg> 

这个相当简单的例子代表了一个 SVG 图像,其中有一个黑色的圆形,其中心位于cx="10" px 和cy="10" px。圆的半径是10 px,这使得这个圆的宽度和高度都是20 px。

SVG 坐标系的原点位于左上角,其中y轴朝南方向,x轴向东:

SVG 内部的坐标系

不仅可以使用原始形状,如圆形、线条和矩形,还可以使用复杂的多边形,创建图形内容的可能性几乎是无限的。

SVG 不仅用于网络,而且已经成为不同应用程序之间交换矢量图形的重要中间格式。几乎任何支持矢量图形的应用程序都支持导入和导出 SVG 文件。

当我们不将 SVG 文件作为 HTML 图像元素包含在内,而是直接在我们的 DOM 中包含 SVG 内容时,SVG 的真正力量才显现出来。由于 HTML5 直接支持 HTML 文档中的 SVG 命名空间,并将渲染我们在 HTML 中定义的图形,因此出现了一系列新的可能性。现在,我们可以使用 CSS 来样式化 SVG,使用 JavaScript 来操作 DOM,并轻松地使 SVG 交互式。

将我们之前的圆形图像示例提升到下一个层次,我们可以通过改变圆的颜色(通过点击它)使其交互式。首先,让我们创建一个最小的 HTML 文档,并将我们的 SVG 元素直接包含在 DOM 中:

<!doctype html> 
<title>Minimalistic Circle</title> 
<svg width="20px" height="20px"> 
  <circle id="circle" cx="10" cy="10" r="10" fill="black"> 
</svg> 
<script> 
  document 
    .getElementById('circle') 
    .addEventListener('click', function(event) { 
      event.target.setAttribute('fill', 'red'); 
    }); 
</script> 

正如你所见,当我们直接在我们的 HTML 文档的 DOM 中使用 SVG 时,我们可以去掉版本和 XML 命名空间声明。这里有趣的是,我们可以将 SVG 视为非常普通的 HTML。我们可以为 SVG 元素分配一个 ID,甚至类,并从 JavaScript 中访问它们。

在我们的 HTML 文档的script标签中,我们可以直接使用之前分配给它的 ID 访问我们的circle元素。我们可以像处理常规 HTML 元素一样为 SVG 元素添加事件监听器。在这个例子中,我们添加了一个click事件监听器,并将我们的圆的颜色改为红色。

为了简化,我们在本例中使用了内联script标签。当然,使用一个单独的 JavaScript 文件来进行脚本编写会更为整洁。

SVG 样式

在网络中关注点的分离方面,我是一个纯粹主义者。我仍然坚信结构(HTML)、外观(CSS)和行为(JavaScript)的分离,以及遵循这一实践时产生最易于维护的应用程序。

首先,在 HTML 中包含 SVG 看起来有些奇怪,你可能会认为这打破了清晰的分离合同。为什么只有外观相关的数据组成的图形内容,会坐在我的 HTML 中,而 HTML 本应只包含原始信息?在处理了大量 DOM 中的 SVG 之后,我得出结论,我们可以通过将外观责任分为以下两个子组来使用 SVG 建立清晰的分离:

  • 图形结构:这个子组处理定义你的图形内容基本结构的过程。这是关于形状和布局。

  • 视觉外观:这个子组处理定义我们图形结构的外观和感觉的过程,例如颜色、线宽、线型和文本对齐。

如果我们将 SVG 的关注点分为这些组,我们实际上可以获得很好的可维护性。图形结构由 SVG 形状本身定义。它们直接写入我们的 HTML 中,但没有特定的外观和感觉。我们只在 HTML 中存储基本的结构信息。

幸运的是,我们不需要在 SVG 元素上使用属性,所有与视觉外观相关的属性,例如颜色,也可以使用相应的 CSS 属性来指定。这允许我们将结构与外观相关的所有方面都卸载到 CSS 中。

让我们回到我们画黑色圆圈的例子;我们将稍作调整,以满足我们对关注点分离的需求,以便我们可以区分图形结构和图形外观:

<!doctype html> 
<title>Minimalistic Circle</title> 
<svg width="20px" height="20px"> 
  <circle class="circle" cx="10" cy="10" r="10"> 
</svg> 

现在我们可以通过使用 CSS 来样式化我们的图形结构,包括以下内容的样式表:

.circle { 
  fill: black; 
} 

这真是太棒了,因为我们不仅可以重用一些图形结构,还可以使用 CSS 应用不同的视觉外观参数,类似于我们通过仅更改一些 CSS 就成功重用了一些语义 HTML 时的启发时刻。

让我们看看我们可以用来样式化 SVG 形状的最重要 CSS 属性:

  • fill: 当与实心 SVG 形状一起工作时,始终有形状填充和轮廓选项可用;fill属性指定了形状填充的颜色。

  • stroke: 这个属性指定了 SVG 形状轮廓的颜色。

  • stroke-width: 这个属性指定了 SVG 形状轮廓的宽度,对于实心形状而言。对于非实心形状,例如线条,这可以被视为线条宽度。

  • stroke-dasharray: 这指定了线条的虚线模式。虚线模式是由空格分隔的值,定义了一个模式。

  • stroke-dashoffset: 这指定了虚线模式的偏移量,该偏移量由stroke-dasharray属性指定。

  • stroke-linecap: 这个属性定义了线条端点应该如何渲染。它们可以渲染为方形、平头或圆角端点。

  • stroke-linejoin: 这个属性指定了路径内线条的连接方式。

  • shape-rendering: 使用这个属性,你可以覆盖用于渲染形状的形状渲染算法,正如其名所示。如果你需要在形状上获得清晰的边缘,这特别有用。

要获取有关可用外观相关 SVG 属性的完整参考,请访问 Mozilla 开发者网站developer.mozilla.org/en-US/docs/Web/SVG/Attribute

我希望这个简短的介绍让你对 SVG 及其带来的强大功能有了更好的感觉。在本章中,我们将使用其中的一些功能来创建漂亮的、交互式的图形组件。如果你想了解更多关于 SVG 的信息,我强烈建议你阅读 Sara Soueidan 的精彩文章。

构建 SVG 组件

当使用 SVG 模板构建 Angular 组件时,有一些事情你需要注意。首先也是最明显的一点是 XML 命名空间。现代浏览器在解析 HTML 时非常智能。除了可能是计算机科学历史上最容错的解析器之外,DOM 解析器在识别标记并决定如何处理它方面也非常聪明。它们会根据元素名称自动为我们决定正确的命名空间,因此我们编写 HTML 时不需要处理它们。

如果你稍微玩过 DOM API,你可能已经注意到有两种方法可以创建新元素。例如,在文档对象中,有一个 createElement 函数,但还有一个 createElementNS,它接受一个额外的命名空间 URI 参数。此外,每个创建的元素都有一个 namespaceURI 属性,它告诉你特定元素的命名空间。这很重要,因为 HTML5 是一个至少包含三个命名空间的标准:

  • HTML:这是标准的 HTML 命名空间,具有 www.w3.org/1999/xhtml URI。

  • SVG:它包含所有 SVG 元素和属性,并使用 www.w3.org/2000/svg URI。有时你可以在 svg 元素的 xmlns 属性中看到这个命名空间 URI。实际上,这并不是必需的,因为浏览器足够智能,可以自己决定正确的命名空间。

  • MathML:这是一个基于 XML 的格式,用于描述数学公式,并且大多数现代浏览器都支持它。它使用 www.w3.org/1998/Math/MathML 命名空间 URI。

我们可以在单个文档中混合来自不同标准和命名空间的所有这些元素,并且当浏览器在 DOM 中创建元素时,它会自己确定正确的命名空间。

如果你想要更多关于命名空间的信息,我建议你阅读 Mozilla 开发者网络上的 命名空间快速入门 文章,网址为 developer.mozilla.org/en/docs/Web/SVG/Namespaces_Crash_Course

由于 Angular 会为我们编译模板,并使用 DOM API 将元素渲染到 DOM 中,因此它需要在这个过程中了解命名空间。类似于浏览器,Angular 在创建元素时提供了一些智能来决定正确的命名空间。然而,在某些情况下,你可能需要帮助 Angular 识别正确的命名空间。

为了说明一些这种行为,让我们将我们一直在工作的圆形示例转换成一个 Angular 组件:

@Component({ 
  selector: 'awesome-circle', 
  template: ` 
    <svg [attr.width]="size" [attr.height]="size"> 
      <circle [attr.cx]="size/2" [attr.cy]="size/2" 
              [attr.r]="size/2" fill="black" /> 
    </svg> 
  ` 
}) 
export class AwesomeCircle { 
  @Input() size; 
} 

我们已经将圆形 SVG 图形封装成了一个简单的 Angular 组件。size 输入参数通过控制 SVG 的 widthheight 属性以及圆形的 cxcyr 属性来确定圆形的实际宽度和高度。

要使用我们的圆形组件,只需在另一个组件中使用以下模板:

<awesome-circle [size]="20"></awesome-circle>

需要注意的是,我们需要在 SVG 元素上使用属性绑定,而不能直接设置 DOM 元素的属性。这是由于 SVG 元素具有特殊的属性类型(例如,SVGAnimatedLength),可以使用同步多媒体集成****语言SMIL)进行动画。我们不需要干扰这些相对复杂的元素属性,而可以简单地使用属性绑定来设置 DOM 元素的属性值。

让我们回到我们的命名空间讨论。Angular 将知道它需要使用 SVG 命名空间来创建这个模板内的元素。它将以这种方式工作,仅仅因为我们正在使用svg元素作为组件内的根元素,并且它可以在模板解析器中自动切换任何子元素的命名空间。

然而,在某些情况下,我们需要帮助 Angular 确定我们想要创建的元素的正确命名空间。当我们创建不包含根svg元素的嵌套 SVG 组件时,我们会遇到这种情况:

@Component({ 
  selector: '[awesomeCircle]', 
  template: ` 
      <svg:circle [attr.cx]="size/2" [attr.cy]="size/2" 
                  [attr.r]="size/2" fill="black" /> 
  ' 
}) 
export class AwesomeCircle { 
  @Input('awesomeCircle') size; 
} 

@Component({ 
  selector: 'app' 
  template: ` 
   <svg width="20" height="20"> 
    <g [awesomeCircle]="20"></g> 
   </svg> 
  `, 
  directives: [AwesomeCircle] 
}) 
export class App {} 

在这个例子中,我们嵌套 SVG 组件,我们出色的圆形组件没有svg根元素来告诉 Angular 切换命名空间。这就是为什么我们在我们的应用程序组件中创建了svg元素,然后在一个 SVG 组中包含了出色的圆形组件。

我们需要明确告诉 Angular 在我们的圆形组件中切换到 SVG 命名空间,我们可以通过在前面代码摘录的高亮部分中看到的方式,将命名空间名称作为冒号分隔的前缀来做到这一点。

如果你需要在 SVG 命名空间中显式创建多个元素,你可以依赖 Angular 也会为子元素应用命名空间的事实,并将所有元素与一个 SVG 组元素组合在一起。因此,你只需要在组元素前加上<svg:g> ... </svg:g>前缀,而不是包含的任何 SVG 元素。

当处理 SVG 时,这就是了解 Angular 内部结构的足够信息。让我们继续前进,创建一些真正的组件!

构建一个交互式活动滑块组件

在前面的主题中,我们介绍了与 SVG 一起工作的基础知识以及处理 Angular 组件中的 SVG。现在,是时候将我们的知识应用到任务管理应用程序中,并使用 SVG 创建一些出色的组件了。

在这个背景下,我们将创建的第一个组件是一个交互式滑块,允许用户选择他或她感兴趣检查的活动时间范围。显示一个简单的 HTML5 范围输入可能是一个解决方案,但既然我们已经获得了一些 SVG 超级能力,我们可以做得更好!我们将使用 SVG 来渲染我们自己的滑块,它将在滑块上显示现有活动作为刻度。让我们看看我们将要创建的滑块组件的模拟图:

图片

活动滑块组件的模拟图

我们的滑块组件实际上将有两个用途。它应该是一个用户控件,并提供一种选择时间范围以过滤活动的方法。然而,它还应该提供所有活动的概述,以便用户可以更直观地过滤范围。通过绘制代表活动的垂直条,我们已经在用户心中建立了他或她感兴趣的范围感。

让我们使用 Angular CLI 工具创建我们的新活动滑块组件:

ng generate component --spec false -ve none -cd onpush activities/activity-slider

打开路径src/app/activities/activity-slider/activity-slider.component.ts上生成的组件类,并添加以下代码:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener,
  Input, OnChanges, Output, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  constructor(private elementRef: ElementRef) {}
}

我们应该首先提到的,与迄今为止我们所写的所有其他组件不同,我们为这个组件使用了ViewEncapsulation.Native。正如我们在第二章的“创建我们的应用程序组件”部分所学的,在*准备,设置,出发!*当我们为组件封装使用ViewEncapsulation.Native时,Angular 实际上使用阴影 DOM 来创建组件。我们也在第一章的“基于组件的用户界面”部分的“阴影 DOM”中简要介绍了这一点。

使用阴影 DOM(Shadow DOM)为我们组件带来的优势是:我们的组件将完全封装,从 CSS 方面来说。这不仅意味着全局 CSS 不会泄漏到我们的组件中;这也意味着我们需要创建局部样式,以便为我们的组件进行样式设计。

到目前为止,我们使用了来自全局样式表的样式,该样式表是为本书准备的。我们在该文件中使用了组件 CSS 命名约定,以避免与 CSS 类名冲突。然而,当使用阴影 DOM 时,我们可以省略前缀和其他命名约定来避免冲突,因为我们只在我们组件的局部应用样式。

Chrome 从版本 35 开始原生支持阴影 DOM。在 Firefox 中,可以通过访问about:config页面并打开dom.webcomponents.enabled标志来启用阴影 DOM。IE、Edge 和 Safari 完全不支持这个标准;然而,我们可以通过包含一个名为webcomponents.js的 polyfill 来设置它们以处理阴影 DOM。你可以在github.com/webcomponents/webcomponentsjs上找到有关此 polyfill 的更多信息。

现在,让我们添加我们将在活动滑块组件中使用的局部 CSS 样式。打开文件src/app/activities/activity-slider/activity-slider.component.css,并添加以下代码:

:host {
  display: block;
}

.slide {
  fill:#f9f9f9;
}

.activity {
  fill:#3699cb;
}

.time {
  fill:#bbb;
  font-size:14px;
}

.tick {
  stroke:#bbb;
  stroke-width:2px;
  stroke-dasharray:3px;
}

.selection-overlay {
  fill:#d9d9d9;
}

通常,这样的短类名可能会在我们的项目中导致名称冲突,但由于样式将局限于我们组件的阴影 DOM 中,我们不再需要担心名称冲突。

你可以看到我们在我们的样式中使用了特殊的伪选择器:host。这个选择器是 CSS 的 Shadow DOM 规范的一部分,它允许我们样式化 shadow root 的主机元素。这变得非常方便,因为我们可以在样式化时将主机元素视为组件内部的一部分。

让我们回到活动滑块组件内部的其余代码。作为一个输入参数,我们定义了将用于的活动的列表,这不仅用于确定滑块中的可用范围,还用于在滑块的背景上渲染活动。

一旦用户做出选择,我们的组件将使用outSelectionChange输出通知外界关于变化。

在构造函数中,我们正在注入主机元素以供以后使用。我们需要它来访问滑块的本地 DOM 元素,以便进行一些宽度计算。

时间投影

我们的滑块组件需要能够将时间戳投影到 SVG 的坐标系中。此外,当用户点击时间轴选择范围时,我们需要能够将坐标投影回时间戳。为此,我们需要在我们的组件内创建两个投影函数,这些函数将使用一些辅助函数和状态来计算值,从坐标到时间,反之亦然:

图片

计算中重要变量和函数的可视化

虽然我们将使用百分比来定位滑块组件上的 SVG 元素,但两侧的内边距需要以像素为单位指定。totalWidth函数将返回该区域的像素总宽度;这是我们将在其中绘制活动指示器的地方。timeFirsttimeLasttimeSpan变量也将被计算使用,并以毫秒为单位指定。

让我们在滑块中添加一些代码来处理我们的活动在滑块上的投影:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
 timeFirst: number;
 timeLast: number;
 timeSpan: number;

  constructor(private elementRef: ElementRef) {}

 totalWidth() {
 return this.elementRef.nativeElement.clientWidth - this.padding * 2;
 }

 projectTime(time: number) {
 const position = this.padding +
 (time - this.timeFirst) / this.timeSpan * this.totalWidth();
 return position / this.elementRef.nativeElement.clientWidth * 100;
 }

 projectLength(length: number) {
 return this.timeFirst + (length - this.padding) / this.totalWidth() * this.timeSpan;
 }
}

由于我们已经忽略了主机元素的引用,我们可以使用其clientWidth属性来获取组件的全宽并减去内边距。这将给我们想要的绘制活动指示器的区域的全宽,以像素为单位。

projectTime函数中,我们首先将时间戳通过简单的三段式规则转换为位置。因为我们可以访问第一个活动的时间戳以及总时间跨度,这将是一个相当简单的任务。一旦我们这样做,我们可以通过将其除以总组件宽度然后乘以 100 来将我们的位置值(以像素为单位)转换为百分比。

要将像素值投影回时间戳,我们可以基本上执行projectTime的逆操作,只不过我们这里不处理百分比,而是假设projectLength函数的长度参数是以像素单位。

我们在我们的预测代码中使用了某些成员变量(timeFirsttimeLasttimeSpan),但如何设置这些成员变量呢?由于我们有一个activities组件输入,预期它将是一个相关活动的列表,我们可以观察输入的变化并根据输入设置值。为了观察该组件输入的变化,我们可以使用OnChanges生命周期钩子:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener,
  Input, Output, OnChanges, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent implements OnChanges {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
  timeFirst: number;
  timeLast: number;
  timeSpan: number;

  constructor(private elementRef: ElementRef) {}

  ngOnChanges(changes: SimpleChanges) {
 if (changes.activities && this.activities) {
 if (this.activities.length === 1) {
 this.timeFirst = this.timeLast = this.activities[0].time;
 } else if (this.activities.length > 1) {
 this.timeFirst = this.activities[this.activities.length - 1].time;
 this.timeLast = this.activities[0].time;
 } else {
 this.timeFirst = this.timeLast = new Date().getTime();
 }

 this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
 }
 }

  …
}

首先,我们需要检查更改是否包括对activities输入的更改,以及当前输入值是否有效。在检查输入值之后,我们可以确定我们的成员变量,即timeFirsttimeLasttimeSpan。我们将timeSpan变量限制至少为1,否则我们的预测计算将会出错。

上述代码将确保当activities输入更改时,我们将始终重新计算我们的成员变量,并且我们将使用最新的数据渲染活动。

渲染活动指示器

我们已经实现了组件的基本功能,并为将时间信息绘制到组件的坐标系中奠定了基础。现在是时候使用我们的投影函数,并使用 SVG 在滑块上绘制活动指示器了。

让我们在src/app/activities/activity-slider/activity-slider.component.html中打开活动滑块的模板,并添加以下代码:

<svg width="100%" height="70px">
  <rect x="0" y="30" width="100%" height="40" class="slide"></rect>
  <rect *ngFor="let activity of activities"
        [attr.x]="projectTime(activity.time) + '%'"
        height="40" width="2px" y="30" class="activity"></rect>
</svg>

由于我们需要为活动列表中的每个活动创建一个指示器,我们可以简单地使用ngFor指令重复表示活动指示器的矩形。

正如我们在前一个主题中构建活动服务类时所知,创建用于记录活动的服务,活动总是包含一个带有活动时间戳的time字段。在我们的组件中,我们已创建了一个将时间转换为相对于组件宽度的百分比的投影函数。我们可以在rect元素的x属性绑定中简单地使用projectTime函数,以将活动指示器定位在正确的位置。

通过仅使用 SVG 模板和我们的背景函数来预测时间,我们已经创建了一个小巧的图表,该图表显示活动指示器在时间轴上的位置。

你可以想象,如果我们有很多活动,我们的滑块实际上看起来会很拥挤,很难感觉到这些活动可能发生的时间。我们需要有一种类型的网格,帮助我们把图表与时间轴关联起来。

如我们的滑块组件的模拟所示,现在,我们将在滑块背景上引入一些刻度,将滑块分成几个部分。我们还将为每个刻度标注日历时间。这将使用户在查看滑块上的活动指示器时对时间有一个大致的感觉。

让我们看看我们活动滑块类中的代码更改,这将启用我们的刻度渲染:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter,
  Input, OnChanges, Output, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent implements OnChanges {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
  timeFirst: number;
  timeLast: number;
  timeSpan: number;
 ticks: number[];

  constructor(private elementRef: ElementRef) {}

  ngOnChanges(changes: SimpleChanges) {
    if (changes.activities && this.activities) {
      if (this.activities.length === 1) {
        this.timeFirst = this.timeLast = this.activities[0].time;
      } else if (this.activities.length > 1) {
        this.timeFirst = this.activities[this.activities.length - 1].time;
        this.timeLast = this.activities[0].time;
      } else {
        this.timeFirst = this.timeLast = new Date().getTime();
      }

      this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
      this.computeTicks();
    }
  }

  computeTicks() {
 const count = 5;
 const timeSpanTick = this.timeSpan / count;
 this.ticks = Array.from({length: count}).map((element, index) => {
 return this.timeFirst + timeSpanTick * index;
 });
 }

  …
}

首先,我们需要创建一个函数来计算一些刻度,我们可以将它们放置在时间轴上。为此,我们需要创建computeTicks方法,它将整个时间轴分成五个相等的部分,并生成表示各个刻度时间位置的戳记。我们将这些刻度存储在一个新的ticks成员变量中。有了这些时间戳的帮助,我们可以在视图中轻松渲染刻度。

我们使用Array.from ES6 函数来创建一个具有所需长度的新数组,并使用功能数组额外函数map从该数组生成刻度模型对象。使用Array.from是一个很好的技巧,可以创建一个给定长度的初始数组,这可以用来建立一种功能风格。

让我们看看我们的活动滑块组件的模板,以及我们如何使用我们的时间戳数组在滑块组件上渲染刻度:

<svg width="100%" height="70px">
  <rect x="0" y="30" width="100%" height="40" class="slide"></rect>
 <g *ngFor="let tick of ticks">
 <text [attr.x]="projectTime(tick) + '%'" y="14" class="time">
 {{tick | calendarTime}}
 </text>
 <line [attr.x1]="projectTime(tick) + '%'" [attr.x2]="projectTime(tick) + '%'"
 y1="30" y2="70" class="tick"></line>
 </g>
  <rect *ngFor="let activity of activities"
        [attr.x]="projectTime(activity.time) + '%'"
        height="40" width="2px" y="30" class="activity"></rect>
</svg>

为了渲染我们的刻度,我们使用了一个 SVG 组元素来放置我们的ngFor指令,该指令重复我们在ticks成员变量中存储的刻度戳记。

对于每个刻度,我们需要放置一个标签,以及跨越滑块背景的线。我们可以使用 SVG 文本元素来渲染带有时间戳的标签,并将其放置在滑块上方。在我们的text元素的x属性绑定中,我们使用了我们的projectTime投影函数来接收从时间戳中得到的投影百分比值。我们的text元素的y坐标固定在一个位置,标签将正好位于滑块上方。

SVG 线由四个坐标组成:x1x2y1y2。共同定义了两个坐标点,一条线将从一点画到另一点。

现在,我们越来越接近我们在本主题开头所指定的最终滑块。最后缺失的拼图碎片是使我们的滑块交互式,以便用户可以选择一系列活动。

使其生动起来

到目前为止,我们已经涵盖了滑块背景的渲染,以及活动指示器的渲染。我们还生成了刻度并使用网格线和标签显示它们,以显示每个刻度的日历时间。

嗯,这并不真正是一个滑块,对吧?当然,我们还需要处理用户输入,并使滑块交互式,以便用户可以选择他们想要显示活动的时间范围。

要做到这一点,请向组件类添加以下更改:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener,
  Input, OnChanges, Output, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent implements OnChanges {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
  timeFirst: number;
  timeLast: number;
  timeSpan: number;
  ticks: number[];
 selection: ActivitySliderSelection;

  constructor(private elementRef: ElementRef) {}

  ngOnChanges(changes: SimpleChanges) {
    if (changes.activities && this.activities) {
      if (this.activities.length === 1) {
        this.timeFirst = this.timeLast = this.activities[0].time;
      } else if (this.activities.length > 1) {
        this.timeFirst = this.activities[this.activities.length - 1].time;
        this.timeLast = this.activities[0].time;
      } else {
        this.timeFirst = this.timeLast = new Date().getTime();
      }

      this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
      this.computeTicks();

 this.selection = {
 start: this.timeFirst,
 end: this.timeLast
 };
 this.outSelectionChange.next(this.selection);
    }
  }

  …
}

当我们在OnChanges生命周期钩子中检测到activities输入属性的变化时,我们为我们的滑块组件中的用户选择初始化一个ActivitySliderSelection对象。它包含一个startend属性,两者都包含表示我们在活动滑块上所选范围的戳记。

一旦我们设置了初始选区,我们需要使用outSelectionChange输出属性来发出事件。这样,我们可以让父组件知道滑动条内的选区已更改。

为了显示选定的范围,我们在模板中使用一个覆盖矩形,它将放置在滑动背景之上。如果你再次查看滑动条的模拟图像,你会注意到这个覆盖层被涂成灰色:

<svg width="100%" height="70px">
  <rect x="0" y="30" width="100%" height="40" class="slide"></rect>
  <rect *ngIf="selection"
 [attr.x]="projectTime(selection.start) + '%'"
 [attr.width]="projectTime(selection.end) - projectTime(selection.start) + '%'"
 y="30" height="40" class="selection-overlay"></rect>
  <g *ngFor="let tick of ticks">
    <text [attr.x]="projectTime(tick) + '%'" y="14" class="time">
      {{tick | calendarTime}}
    </text>
    <line [attr.x1]="projectTime(tick) + '%'" [attr.x2]="projectTime(tick) + '%'"
          y1="30" y2="70" class="tick"></line>
  </g>
  <rect *ngFor="let activity of activities"
        [attr.x]="projectTime(activity.time) + '%'"
        height="40" width="2px" y="30" class="activity"></rect>
</svg>

这个矩形将放置在我们的滑动背景之上,并使用我们的投影函数来计算xwidth属性。由于我们需要等待变化检测在OnChanges生命周期钩子中初始化我们的选区,我们将通过使用ngIf指令来检查有效的选区对象。

现在,我们需要开始处理我们的活动滑动组件的用户输入。存储状态和渲染选区的机制已经就位,因此我们可以实现所需的主监听器来处理用户输入。由于我们已经逐步应用了许多更改,让我们看看组件类的最终完整版本。需要添加用户交互的缺失更改以粗体显示:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener,
  Input, OnChanges, Output, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent implements OnChanges {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
  timeFirst: number;
  timeLast: number;
  timeSpan: number;
  ticks: number[];
  selection: ActivitySliderSelection;
  modifySelection: boolean;

  constructor(private elementRef: ElementRef) {}

  ngOnChanges(changes: SimpleChanges) {
    if (changes.activities && this.activities) {
      if (this.activities.length === 1) {
        this.timeFirst = this.timeLast = this.activities[0].time;
      } else if (this.activities.length > 1) {
        this.timeFirst = this.activities[this.activities.length - 1].time;
        this.timeLast = this.activities[0].time;
      } else {
        this.timeFirst = this.timeLast = new Date().getTime();
      }

      this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
      this.computeTicks();

      this.selection = {
        start: this.timeFirst,
        end: this.timeLast
      };
      this.outSelectionChange.next(this.selection);
    }
  }

  computeTicks() {
    const count = 5;
    const timeSpanTick = this.timeSpan / count;
    this.ticks = Array.from({length: count}).map((element, index) => {
      return this.timeFirst + timeSpanTick * index;
    });
  }

  totalWidth() {
    return this.elementRef.nativeElement.clientWidth - this.padding * 2;
  }

  projectTime(time: number) {
    const position = this.padding +
      (time - this.timeFirst) / this.timeSpan * this.totalWidth();
    return position / this.elementRef.nativeElement.clientWidth * 100;
  }

  projectLength(length: number) {
    return this.timeFirst + (length - this.padding) / this.totalWidth() * this.timeSpan;
  }

 @HostListener('mousedown', ['$event'])
 onMouseDown(event) {
 this.selection.start = this.selection.end = this.projectLength(event.offsetX);
 this.outSelectionChange.next(this.selection);
 this.modifySelection = true;
 event.stopPropagation();
 event.preventDefault();
 }

 @HostListener('mousemove', ['$event'])
 onMouseMove(event) {
 if (this.modifySelection) {
 this.selection.end = Math.max(this.selection.start, this.projectLength(event.offsetX));
 this.outSelectionChange.next(this.selection);
 event.stopPropagation();
 event.preventDefault();
 }
 }

 @HostListener('mouseup')
 onMouseUp() {
 this.modifySelection = false;
 }

 @HostListener('mouseleave')
 onMouseLeave() {
 this.modifySelection = false;
 }
}

在前面的代码片段中,我们在滑动组件的主元素上处理了总共四个事件:

  • onMouseDown: 我们将选择模型startend属性设置为相同的值。由于我们使用时间戳来设置这些属性,我们首先将鼠标位置投影到时间空间中。鼠标位置以像素为单位,相对于滑动组件的起点。由于我们知道滑动条的宽度和显示的总时间长度,我们可以轻松地将它转换为时间戳。我们使用projectLength方法来完成这个目的。通过传递第二个参数到@HostListener装饰器,我们指定了我们将把 DOM 事件传递给我们的onMouseDown方法。我们还在我们的组件中设置了一个状态标志modifySelection,以指示正在进行的选区。

  • onMouseMove: 如果组件处于选区模式(modifySelection标志为true),你可以调整selection对象的结束属性。在这里,我们还确保通过使用Math.max和限制选区的结束不小于开始,排除了创建负选区的可能性。

  • onMouseUp: 当用户释放鼠标按钮时,组件退出选区模式。这可以通过将modifySelection标志设置为false来完成。

  • onMouseLeave: 这与onMouseUp事件相同;区别在于这里,组件将仅退出选区模式。

使用@HostListener装饰器,我们能够处理所有必要的用户输入,以完成我们组件中仍缺少的交互元素。

概述

在这个主题中,我们学习了如何使用 SVG 来创建具有 Angular 的图形和交互式组件。通过在我们的 SVG 元素上创建属性绑定,并使用 ngForngIf 指令控制图形元素的实例化,我们构建了一个自定义滑动组件,为我们提供了活动的好概述。同时,我们还学习了如何使用 @HostListener 装饰器来处理用户输入,以便使我们的组件具有交互性:

图片

完成后的活动滑动组件的截图

总结一下,我们学习了以下概念:

  • 使用 ViewEncapsulation.Native 封装组件视图并导入本地样式

  • 覆盖一些基本的时间戳到屏幕坐标的投影,用于与 SVG 元素一起使用

  • 处理用户输入并使用 @HostListener 装饰器创建自定义选择机制

构建活动时间线

到目前为止,我们已经构建了一个用于记录活动的服务和一个滑动组件来选择时间范围,并使用活动指示器提供概述。由于我们需要在滑动组件内执行许多绘图任务,SVG 对于这个用例来说是一个完美的选择。为了完成我们的活动组件树,我们仍然需要渲染使用活动滑动组件选择的活动。

让我们继续完善我们的活动组件树。我们将创建一个新的组件,该组件负责在活动时间线中渲染单个活动。让我们使用 Angular CLI 来创建我们的活动组件:

ng generate component --spec false -ve none -cd onpush activities/activity

现在,让我们从组件模板开始。打开文件 src/app/activities/activity/activity.component.html,并添加以下代码:

<img [attr.src]="activity.user.pictureUrl"
     [attr.alt]="activity.user.name"
     class="user-image">
<div class="info" [class.info-align-right]="isAlignedRight()">
  <h3 class="title">{{activity.title}}</h3>
  <p class="author">by {{activity.user.name}} {{activity.time | fromNow}}</p>
  <p>{{activity.message}}</p>
</div>

每个活动将包括一个用户图像,以及一个包含活动标题、消息和作者详情的信息框。

我们的活动将使用一个输入来确定其对齐方式。这允许我们从组件外部对齐活动。isAlignedRight 方法帮助我们设置一个额外的 CSS 类,info-align-right,在活动信息框上。

让我们在文件 src/app/activities/activity/activity.component.ts 中创建我们的组件类:

import {Component, Input, HostBinding, ChangeDetectionStrategy} from '@angular/core';
import {Activity, ActivityAlignment} from '../../model';

@Component({
  selector: 'mac-activity',
  templateUrl: './activity.component.html',
  styleUrls: ['./activity.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ActivityComponent {
  @Input() activity: Activity;
  @Input() alignment: ActivityAlignment;
  @Input() @HostBinding('class.start-mark') startMark;
  @Input() @HostBinding('class.end-mark') endMark;

  isAlignedRight() {
    return this.alignment === 'right';
  }
}

我们的活动组件期望有四个输入:

  • activity:这个属性接受需要用组件渲染的活动数据模型。这是我们使用活动服务创建的活动。

  • alignment:这个输入属性应该设置为包含单词leftright的字符串。我们使用这个属性来确定是否需要在模板中添加一个额外的 CSS 类,以便将活动信息框对齐到右侧。

  • startMark:这个输入属性同时充当输入和宿主绑定。如果这个输入设置为true,活动将获得一个额外的 CSS 类,start-mark,这将在时间线上方产生一个小标记,以指示时间线的终止。

  • endMark:与 startMark 相同,这个输入使用主机绑定来设置一个额外的 CSS 类,end-mark,这将导致时间线底部出现一个小标记,以指示时间线的终止。

在模板中使用了 isAlignedRight 方法,用于确定我们是否需要为信息框添加一个额外的 CSS 类,以便将其对齐到右侧。

我们使用在 第四章 “在项目中思考” 中创建的 FromNow 管道格式化了活动的日期时间戳。

我们现在几乎有了显示活动所需的所有组件。然而,还缺少一些东西,那就是将活动滑块与我们的活动组件结合在一起的东西。为此,我们将创建一个新的组件,称为 activities

ng generate component --spec false -ve none -cd onpush activities/activities

在 Angular CLI 生成组件文件后,让我们打开位于 src/app/activities/activities/activities.component.ts 的组件类,并添加以下代码:

import {Component, Input, ChangeDetectionStrategy, EventEmitter, Output} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../model';

@Component({
  selector: 'mac-activities',
  templateUrl: './activities.component.html',
  styleUrls: ['./activities.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ActivitiesComponent {
  @Input() activities: Activity[];
  @Input() selectedActivities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();

  selectionChange(selection: ActivitySliderSelection) {
    this.outSelectionChange.emit(selection);
  }
}

由于这个组件将仅作为组合组件,用于排列滑块并渲染所有活动,所以我们里面没有太多逻辑。这是一个纯组件,它依赖于父容器组件来确定哪些活动应该显示/选择。我们还重新发出在活动滑块中产生的 outSelectionChange 事件。

让我们也看看位于 src/app/activities/activities/activities.component.ts 的模板:

<mac-activity-slider [activities]="activities"
                     (outSelectionChange)="selectionChange($event)">
</mac-activity-slider>
<div class="l-container">
  <mac-activity *ngFor="let activity of selectedActivities, let odd = odd; let first = first; let last =  
                        last"
                [activity]="activity"
                [alignment]="odd ? 'left' : 'right'"
                [startMark]="first"
                [endMark]="last">
  </mac-activity>
</div>

再次强调,这只是一个简单的组合。我们正在渲染活动滑块,并使用 ngFor 指令来渲染我们的活动时间线。借助局部视图变量 oddfirstlast,我们可以设置活动组件上所需的所有必要格式化输入。

好的!我们几乎完成了。我们已经准备好了所有的活动 UI 组件。然而,我们仍然需要为我们的活动创建一个容器组件,并添加必要的路由配置,以便用户可以导航到项目活动标签页。

让我们再次使用 Angular CLI 工具,为我们的活动容器组件创建文件:

ng generate component --spec false -ve none -cd onpush container/project-activities-container

打开位于 src/app/container/project-activities-container/project-activities-container.component.ts 的组件类文件,并应用以下代码:

import {Component, ViewEncapsulation, ChangeDetectionStrategy} from '@angular/core';
import {ProjectService} from '../../project/project.service';
import {Observable, combineLatest, BehaviorSubject} from 'rxjs';
import {Activity, ActivitySliderSelection} from '../../model';
import {map} from 'rxjs/operators';
import {ActivatedRoute} from '@angular/router';
import {ActivitiesService} from '../../activities/activities.service';

@Component({
  selector: 'mac-project-activities-container',
  templateUrl: './project-activities-container.component.html',
  styleUrls: ['./project-activities-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectActivitiesContainerComponent {
  activities: Observable<Activity[]>;
  selection = new BehaviorSubject<ActivitySliderSelection | null>(null);
  selectedActivities: Observable<Activity[]>;

  constructor(private projectService: ProjectService,
              private activitiesService: ActivitiesService,
              private route: ActivatedRoute) {
    this.activities = combineLatest(
      this.activitiesService.getActivities(),
      route.parent.params
    ).pipe(
      map(([activities, routeParams]) =>
        activities
          .filter(activity => activity.kind === 'project' &&
            activity.projectId === +routeParams.projectId)
      )
    );

    this.selectedActivities = combineLatest(
      this.activities,
      this.selection
    ).pipe(
      map(([activities, selection]) => {
        if (selection) {
          return activities.filter(
            (activity) => activity.time >= selection.start && activity.time <= selection.end
          );
        } else {
          return activities;
        }
      })
    );
  }

  selectionChange(selection: ActivitySliderSelection) {
    this.selection.next(selection);
  }
}

虽然这看起来像很多代码,但实际上应该看起来非常熟悉。我们的其他容器组件几乎与这个相同。我们正在从活动服务访问活动可观察对象,并将可观察对象与父路由参数组合,以获取所选的项目 ID。

这个容器组件的特殊之处在于我们存储了一个行为主题,selection,它用于发出我们从活动滑块组件接收到的最新选择。在 selectedActivities 可观察对象中,我们使用这个选择与映射函数一起,以过滤出我们选择范围内的活动。

正如容器组件通常所做的那样,这个组件的模板非常简单。我们只是渲染我们的活动组件,并在我们的容器可观察对象上使用异步管道创建绑定。打开文件 src/app/container/project-activities-container/project-activities-container.component.html,并应用以下更改:

<mac-activities [activities]="activities | async"
                [selectedActivities]="selectedActivities | async"
                (outSelectionChange)="selectionChange($event)">
</mac-activities>

好的;我们的容器部分就到这里。现在,我们只需要将新创建的活动容器组件添加到路由配置中。让我们打开我们的路由配置文件 src/app/routes.ts,并应用以下更改:

import {Route} from '@angular/router';
import {ProjectContainerComponent} from './container/project-container/project-container.component';
import {TaskListContainerComponent} from './container/task-list-container/task-list-container.component';
import {ProjectCommentsContainerComponent} from './container/project-comments-container/project-comments-container.component';
import {ProjectContainerGuard} from './guards/project-container.guard';
import {ProjectActivitiesContainerComponent} from './container/project-activities-container/project-activities-container.component';

export const routes: Route[] = [{
  path: 'projects/:projectId',
  component: ProjectContainerComponent,
  canActivate: [ProjectContainerGuard],
  children: [{
    path: 'tasks',
    component: TaskListContainerComponent
  }, {
    path: 'comments',
    component: ProjectCommentsContainerComponent
  }, {
 path: 'activities',
 component: ProjectActivitiesContainerComponent
 }, {
    path: '**',
    redirectTo: 'tasks'
  }]
}, {
  path: '',
  pathMatch: 'full',
  redirectTo: '/projects/1'
}];

我们的“活动”页面就到这里!我们已经创建了三个组件,它们组合在一起并显示活动流,提供了一个滑块来筛选日期相关的活动。在浏览器中预览您的更改,现在您应该能够导航到项目中的“活动”标签页。此外,尝试通过添加新任务或更新它们来记录一些活动。点击并拖动活动滑块以更改您的选择:

完成后的活动视图截图

摘要

在本章中,我们使用 SVG 创建了一个交互式滑块组件。在这个过程中,我们学习了 SVG 的基础知识以及 SVG 在 DOM 中的强大功能。利用 Angular,我们能够使 SVG 具有可组合性,这是它本身所不具备的。我们学习了命名空间、Angular 如何处理它们,以及如何告诉 Angular 我们希望显式地使用命名空间。

除了为我们的滑块组件使用 SVG 之外,我们还学习了如何使用 Shadow DOM 来创建原生视图封装。因此,我们能够为我们的组件使用本地样式。当我们使用本地样式时,我们不需要担心 CSS 命名冲突、特异性和全局 CSS 的副作用。

在下一章中,我们将增强到目前为止所构建的内容。我们将创建一些组件来丰富我们应用程序中的用户体验。

第七章:用户体验组件

对于今天构建应用程序的开发者来说,用户体验应该是一个核心关注点。我们不再生活在一个用户对仅仅能工作的应用程序就感到满意的世界里。现在的期望要高得多。一个应用程序需要高度可用,并且应该提供高效的流程;用户甚至期望它在执行任务时能给他们带来愉悦。

在本章中,我们将探讨构建一些组件,这些组件将提高我们任务管理系统的整体可用性。这些特性将丰富当前的功能并提供更高效的流程。

我们将开发以下两个技术特性,并将它们嵌入到我们当前的应用程序中, wherever applicable:

  • 标签管理:我们将启用在生成内容(如评论、活动和其他可能有用的地方)中使用标签的功能。标签将帮助用户在内容和导航快捷方式之间建立链接。

  • 拖放:我们将构建通用的组件,使使用拖放功能变得轻而易举。通过启用拖放功能,我们将允许用户以更高的效率完成某些任务。

在本章中,我们将涵盖以下主题:

  • 创建一个标签管理系统以输入和显示标签

  • 使用服务渲染标签创建一个有状态的管道

  • 创建一个组件,在用户输入时自动完成标签

  • 使用ViewChild装饰器上的read属性来查询指令实例

  • 掌握 HTML5 拖放 API 的基础

  • 为可拖动元素和拖放目标创建指令

  • 使用dataTransfer对象和自定义属性来启用选择性拖放目标

标签管理

经典的标签形式允许你在系统中建立分类法。它帮助你组织内容。它允许你有一个可以快速管理的多对多关联,你可以在以后用它来过滤相关信息。

在我们的任务管理系统里,我们将使用一个略有不同的标签版本。我们的目标是提供一个在应用程序内允许语义快捷方式的方法。借助标签,用户应该能够在不同部分的数据之间交叉引用信息,提供所引用实体的摘要以及导航快捷方式。

例如,我们可以在用户评论中包含一个项目标签。用户可以通过简单地输入项目 ID 来输入标签。当评论显示时,我们将看到项目的标题,当我们点击标签时,我们可以直接导航到任务所在的项目详情页面。

在本节中,我们将开发所需的组件系统,以提供一种使用项目标签在评论中交叉引用其他项目的方法。我们还将使用标签管理,这是我们在上一章“跟上活动”中创建的。

我们标签的模型

让我们从表示我们系统中标签的标签模型开始。打开位于src/app/model.ts的模型模块文件,并添加以下接口:

export interface Tag {
  type: string;
  hashTag: string;
  title: string;
  link: string;
}

此接口表示标签;每次我们存储标签信息时,我们都会使用此接口。让我们看看各个字段并详细说明它们的使用:

  • hashTag:这是标签的文本表示。我们需要使用此文本表示来唯一标识所有标签。我们可以将标签的文本表示定义为如下:

    • 标签符号始终以井号符号(#)开头。

    • 标签符号只包含字母字符或减号符号(-)。

    • 标签的所有其他细节,由titlelinktype属性定义,都可以从hashTag属性中推导出来。因此,标签符号可以被认为是一个唯一的标识符。

  • title:这是标签的比较长的文本表示。它应该包含尽可能多的关于主题的细节。在项目标签的情况下,这可能意味着项目标题、开放标签计数、分配者和其他重要信息。由于这是如果标签呈现给用户时将被渲染的字段,因此保持内容相对紧凑将是有益的。

  • link:一个有效的 URL,当标签被渲染时将使用它。此 URL 将使链接可点击并启用快捷导航。在我们将要创建的项目标签的情况下,这将是一个链接到给定项目视图的 URL。

  • type:用于区分不同的标签,并为我们提供一种在更高粒度级别组织标签的方法。

到目前为止,一切顺利。我们现在有一个数据模型,我们可以用它来传输关于标签的信息。

创建标签服务

实现我们的标签系统的下一步是编写标签服务。该服务将负责收集我们应用程序中所有可能存在的标签。然后,可以在我们的编辑器组件中向用户展示可用的标签列表。这样,用户就可以在我们的应用程序中添加标签到评论和其他可编辑字段。标签服务还应用于将包含简单标签符号的文本转换为 HTML。这样,标签可以渲染为链接,允许我们在应用程序中导航到详细视图。我们的标签服务的责任可以分为两大主要领域。让我们详细看看这些责任:

  • 提供标签列表:目前,我们只想在我们的标签系统中启用项目。因此,我们的标签服务需要为我们的项目服务中的每个项目创建一个项目标签。这个系统将是可扩展的,其他标签来源可以轻松实现。

  • 解析和渲染标签:标签服务的解析功能负责在输入字符串中查找哈希标签。在解析输入字符串时,服务将检查匹配的标签,然后使用标签对象的 titlelink 字段来渲染它们的 HTML 表示。

让我们使用 Angular CLI 工具来创建我们新服务的占位符:

ng generate service --spec false tags/tags

现在,让我们添加以下代码作为我们服务的起点:

import {Injectable} from '@angular/core';
import {ProjectService} from '../project/project.service';
import {Project, Tag} from '../model';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {limitWithEllipsis} from '../utilities/string-utilities';

@Injectable()
export class TagsService {
  tags: Observable<Tag[]>;

  constructor(private projectService: ProjectService) {
    this.tags = this.projectService.getProjects().pipe(
      map((projects: Project[]) => projects.map(project => ({
        type: 'project',
        hashTag: `#project-${project.id}`,
        title: limitWithEllipsis(project.title, 20),
        link: `/projects/${project.id}/tasks`
      })))
    );
  }
}

我们标签服务类的 tags 成员是一个可观察对象,具有泛型类型 Tag[]。这个可观察对象将始终发出我们应用程序中可用的最新标签列表。在我们的构造函数中,我们使用项目服务中的项目列表可观察对象作为基础,将所有项目转换为项目标签。

在项目的情况下,我们将我们的标签对象的类型设置为 'project'。在我们项目的后期阶段,我们也可以使用除项目之外的其他来源来生成标签,但就目前而言,我们只关注项目。

对于 hashTag 属性,我们使用前缀 '#project-' 并附加项目的 ID。这样,我们的标签可以被识别为项目标签,并且通过附加的 ID,我们还可以确定引用的是哪个具体项目。对于 title 字段,我们使用了一个辅助函数 limitWithEllipsis,该函数会截断超过 20 个字符的项目标题。对于标签对象的 link 字段,我们指定了将导航到项目详情视图的 URL。

渲染标签

现在我们有一个使用响应式方法从可用项目中生成标签的服务。这已经解决了我们服务的第一个问题。让我们看看它的其他责任,即解析文本内容以查找标签并渲染 HTML。

在我们开始在标签服务中编写 parse 方法之前,我们需要创建一个用于字符串替换的小型实用函数。打开文件 src/app/utilities/string-utilities.ts,其中我们已经创建了 limitWithEllipsis 函数,并添加以下代码:

export function replaceAll(
  target: string,
  search: string,
  replacement: string): string {
  return target.split(search).join(replacement);
}

前面的方法使用了一个小的 JavaScript 技巧来替换字符串中所有出现的字符串。不幸的是,这不可能使用字符串的默认 replace 函数。

让我们继续我们的标签服务。渲染标签并不是什么大问题,因为我们已经以一种干净的方式抽象了标签的数据模型。由于标签有指向位置的 URL,我们将使用锚点 HTML 元素来表示我们的标签。这些元素还具有帮助我们以不同于常规内容的样式来样式化标签的类。让我们在标签服务中创建另一个方法,该方法可以用于解析文本,识别文本内容中的标签,并将它们渲染成 HTML。打开位于 src/app/tags/tags.service.ts 的标签服务文件,并应用以下更改:

import {Injectable} from '@angular/core';
import {ProjectService} from '../project/project.service';
import {Project, Tag} from '../model';
import {Observable, of} from 'rxjs';
import {map} from 'rxjs/operators';
import {limitWithEllipsis, replaceAll} from '../utilities/string-utilities';

@Injectable()
export class TagsService {
  tags: Observable<Tag[]>;

  constructor(private projectService: ProjectService) {
    this.tags = this.projectService.getProjects().pipe(
      map((projects: Project[]) => projects.map(project => ({
        type: 'project',
        hashTag: `#project-${project.id}`,
        title: limitWithEllipsis(project.title, 20),
        link: `/projects/${project.id}/tasks`
      })))
    );
  }

  parse(textContent: string): Observable<string> {
 const hashTags: string[] = textContent.match(/#[\w\/-]+/g);
 if (!hashTags) {
 return of(textContent);
 }

 return this.tags.pipe(
 map((tags: Tag[]) => {
 hashTags.forEach(hashTag => {
 const tag = tags.find(t => t.hashTag === hashTag);
 if (tag) {
 textContent = replaceAll(
 textContent,
 hashTag,
 `<a class="tag tag-${tag.type}" 
              href="${tag.link}">${tag.title}</a>`
 );
 }
 });
 return textContent;
 })
 );
 }
}

让我们快速回顾前面的更改,并逐步查看 parse 方法:

  1. 首先,我们在传递给 parse 方法的文本内容中搜索标签,并将发现的标签列表存储在一个名为 hashTags 的变量中。

  2. 如果没有发现任何标签,我们立即返回一个包含传递给方法的原文本内容的新可观察流。我们使用 RxJS 的 of 辅助函数来完成这个操作。

  3. 下一步是在我们的服务中渲染所有发现的标签与相应的标签对象。我们不会直接在我们的服务中存储标签,而是使用一个可观察的流将不同的来源转换为标签。我们使用 map 操作符来获取标签列表,然后将所有发现的标签渲染到 HTML 中。

  4. 我们使用 Array.prototype.forEach 来遍历初始文本内容中的所有发现的标签。然后我们尝试在可用项目标签列表中找到一个匹配的标签对象。我们通过简单地比较文本中找到的标签与我们的标签对象上的 hashTag 属性来完成这个操作。

  5. 如果找到了匹配的标签,我们使用我们新创建的 replaceAll 辅助函数来替换所有给定标签的所有出现,并用该标签的渲染 HTML 版本来替换。我们使用标签对象的 typelinktitle 字段来渲染一个锚点 HTML 元素。

  6. 在所有标签都被替换为这些标签的 HTML 版本之后,我们从可观察的映射函数中返回渲染的 HTML 内容。

这就是我们的标签服务的全部内容。作为下一步,我们将创建一个管道,该管道将使用我们的服务直接在组件视图中渲染标签。

使用管道集成标签

我们的任务服务的所有问题现在都已经得到解决,并且它已经开始为可用的项目存储标签。现在我们可以继续将我们的服务集成到应用程序中。

由于我们的标签服务将带有简单标签的文本转换为带有链接的 HTML,因此管道将是一个完美的辅助工具,用于在组件中集成该功能。

让我们使用 Angular CLI 工具创建一个新的管道:

ng generate pipe --spec false pipes/tags

打开位于 src/app/pipes/tags.pipe.ts 的生成文件,并添加以下代码:

import {Pipe, PipeTransform} from '@angular/core';
import {TagsService} from '../tags/tags.service';
import {DomSanitizer} from '@angular/platform-browser';
import {map} from 'rxjs/operators';

@Pipe({
  name: 'tags',
  pure: false
})
export class TagsPipe implements PipeTransform {
  constructor(private tagsService: TagsService,
              private sanitizer: DomSanitizer) {}

  transform(value) {
    if (typeof value !== 'string') {
      return value;
    }
    return this.tagsService.parse(value).pipe(
      map(parsed => this.sanitizer.bypassSecurityTrustHtml(parsed))
    );
  }
}

我们已经创建了一些管道。然而,这个管道有点不同,因为它不是一个纯管道。如果管道的 transform 函数总是对给定的输入返回相同的输出,则认为管道是纯的。这意味着 transform 函数不应依赖于任何其他可能影响转换结果的外部来源,唯一的依赖是输入值。但我们的标签管道并不符合这一点。它依赖于标签服务来转换输入,并且可以在任何时间将新标签存储在标签服务中。连续的转换可以成功渲染刚刚还不存在的标签。

通过告诉 Angular 我们的管道不是纯的,我们可以禁用它在纯管道上执行的性能优化。这也意味着 Angular 需要在每次变更检测时重新验证管道的输出。这可能导致性能问题;因此,纯标志应该谨慎使用。

在我们的管道中,我们注入了标签服务,这有助于我们将简单文本转换为渲染的 HTML。然而,Angular 有一些安全机制阻止我们直接在模板中使用这个 HTML 字符串。为了确保 Angular 我们知道我们在做什么,我们可以使用 DOM 清理器实例来创建可信的 HTML,然后我们可以在innerHTML绑定中渲染它。通过在清理器上调用bypassSecurityTrustHtml,传递我们的生成的 HTML 字符串,我们可以告诉 Angular 对这个实例忽略任何安全担忧,然后我们可以在视图中渲染 HTML。

好的;就标签的渲染而言,我们已经准备好了。让我们将我们的标签功能集成到我们的编辑组件中,这样我们就可以在注释系统中使用它们。

我们真正需要做的只是在我们编辑组件模板中包含标签管道。让我们打开位于 src/app/ui/editor/editor.component.html 的编辑器模板,并应用以下更改:

<div #editableContentElement
     class="editable-content"
     contenteditable="true"></div>
<div class="output" [innerHTML]="content ? (content | tags | async) : '-'"></div>
<div *ngIf="showControls && !editMode"
     class="controls">
  <button (click)="beginEdit()" class="icon-edit"></button>
</div>
<div *ngIf="showControls && editMode"
     class="controls">
  <button (click)="saveEdit()" class="icon-save"></button>
  <button (click)="cancelEdit()" class="icon-cancel"></button>
</div>

我们在模板中做的唯一更改是我们显示编辑器内容的地方。我们正在使用属性绑定到我们的编辑器输出 HTML 元素的innerHTML属性。这允许我们渲染由我们的标签服务生成的 HTML 内容。由于标签管道返回一个可观察对象,我们需要链式连接一个异步管道。

恭喜!您的标签系统已经完成了一半!我们已经创建了一个标签服务,它收集应用程序中的可用标签,并且与我们的新创建的管道一起,在编辑组件中渲染标签。在浏览器中预览您的更改,并尝试在项目的注释标签页上添加一些注释的标签。目前,在我们的应用程序中只有两个项目。尝试将以下标签添加到注释中——#project-2——并在编辑器中保存更改。现在您应该能够在注释中看到渲染的标签。如果您再次编辑注释,您将看到标签的文本表示。

让我们暂时偏离一下主题。我们已经创建了一个标签系统,并且我们刚刚通过使用标签管道将其集成到我们的编辑组件中。如果用户在任何注释中写入项目标签,它们现在将由标签服务渲染。这太棒了!用户现在可以在注释中建立与其他项目的交叉链接,这些链接将自动渲染为链接并显示截断的项目标题。用户需要做的只是将项目标签的文本表示添加到注释中。

以下两个截图展示了注释系统的示例。第一个截图是编辑模式下编辑器的示例,在注释系统中输入了一个文本标签:

输入文本标签的一个示例

第二个截图是使用我们的编辑器集成在评论系统中启用渲染标签的一个示例:

通过编辑器集成渲染标签的一个示例

在本节中,我们探讨了以下概念:

  • 我们构建了一个标签服务,它可以生成、缓存和渲染标签

  • 我们使用pure标志构建了一个有状态的管道

  • 我们使用了[innerHTML]属性绑定来将 HTML 内容渲染到元素中

  • 我们使用了 DOM 清理器来绕过使用innerHTML绑定时的安全检查

我们还没有完成标签输入的工作。我们不能期望我们的用户知道系统中所有可用的标签,然后手动在评论中输入它们。让我们看看在下一节中我们如何改进这一点。

支持标签输入

在这里,我们将构建一个组件(及其支持结构),以使用户输入标签的过程变得顺畅。到目前为止,他们可以编写项目标签,但这需要他们知道项目 ID,这使得我们的标签管理变得毫无用处。我们希望提供一些选择,当用户准备编写标签时。理想情况下,我们将显示可用的标签,一旦他们开始通过输入哈希符号(#)编写标签。

起初听起来很简单的事情实际上是一个相当棘手的问题。我们的标签输入需要处理以下挑战:

  • 处理输入事件以监控标签创建。我们需要知道用户何时开始编写标签,以及当使用无效的标签字符时,输入的标签名称何时被更新或取消。

  • 计算用户输入光标的位置。是的,我知道这听起来很简单,但实际上并不是。计算用户输入光标的视口偏移位置需要使用浏览器的 Selection API,这是一个相当底层的 API,需要一些抽象。

为了应对这些挑战,我们将引入一个实用指令,我们可以使用它来处理那些相当复杂的底层用户输入事件。

创建一个标签输入指令

由于在用户输入中识别标签并不是一个简单的任务,我们将创建一个指令来帮助我们完成这个任务。这实际上是我们共同创建的第一个指令!如果你还记得第一章中的组件化用户界面,指令是用来创建自定义行为而不需要自己视图的。我们的标签输入指令将收集并识别用户输入中的标签,但它实际上并不渲染自己的视图。

让我们在src/app/model.ts中的模型文件中添加两个更多接口,以帮助我们与标签用户输入进行通信:

export interface InputPosition {
  top: number;
  left: number;
  caretOffset: number;
}

export interface HashTagInput {
  hashTag: string;
  position: InputPosition;
}

对于我们指令识别为 hashtag 输入的每个用户输入,我们将使用 hashtag 输入对象进行通信。除了 hashtag 的实际文本内容外,我们还发送一个由以下属性组成的输入位置:

  • topleft: 表示光标位置的实际输入发生的屏幕顶部和左侧,以像素为单位。

  • caretOffset: 描述了在可编辑元素的文本内容中,hashtag 的字符偏移量。当我们要在可编辑元素中替换 hashtag 并实现自动完成的感觉时,这将非常有用。

InputPosition接口的topleft属性中,你可以看到我们想要计算实际用户输入发生的坐标。这听起来非常简单,但实际上并非如此。为了帮助我们进行这个计算,我们将引入一个新的辅助函数,我们将在一个新文件中创建这个函数,路径为src/app/utilities/dom-utilities.ts。创建这个新文件并添加以下内容:

import {InputPosition} from '../model';

export function getRangeBoundingClientRect(): InputPosition | null {
  if (window.getSelection) {
    const selection = window.getSelection();
    if (!selection.rangeCount) {
      return null;
    }

    const range = selection.getRangeAt(0);
    const rect = range.getBoundingClientRect();

    if (!range.collapsed) {
      return {
        top: rect.top,
        left: rect.left,
        caretOffset: range.startOffset
      };
    }

    const dummy = document.createElement('span');
    range.insertNode(dummy);
    const pos: InputPosition = {
      top: rect.top,
      left: rect.left,
      caretOffset: range.startOffset
    };
    dummy.parentNode.removeChild(dummy);
    return pos;
  }

  if (document['selection']) {
    return document['selection']
      .createRange()
      .getBoundingClientRect();
  }
}

在这里我们就不深入细节了。这段代码基本上是尝试找到描述光标位置相对于视口的toprightbottomleft偏移量的DOMRect对象,即边界框。问题是,Selection API 不允许我们直接获取光标的位置;它只允许我们获取当前选择的位置。如果光标放置不正确,我们需要在光标位置插入一个虚拟元素,并返回虚拟元素的边界框DOMRect对象。当然,在返回DOMRect对象之前,我们需要再次移除虚拟元素。

因此,这就是我们需要编写的标签输入指令的所有内容。让我们使用 Angular CLI 创建我们的第一个指令。创建指令的命令与创建组件的命令非常相似:

ng generate directive --spec false tags/tags-input

这生成了我们新指令的存根。让我们打开文件src/app/tags/tags-input.directive.ts,并添加以下代码:

import {Directive, HostListener} from '@angular/core';
import {getRangeBoundingClientRect} from '../utilities/dom-utilities';
import {HashTagInput} from '../model';
import {BehaviorSubject} from 'rxjs';

@Directive({
  selector: '[macTagsInput]'
})
export class TagsInputDirective {
  private hashTagInput: HashTagInput | null = null;
  private hashTagSubject = new BehaviorSubject<HashTagInput>(this.hashTagInput);
  hashTagChange = this.hashTagSubject.asObservable();
}

私有hashTagInput属性是一个内部状态,用于存储当前的 hashtag 输入信息。hashTagSubject成员是一个行为主题,我们内部使用它来发布 hashtag 输入更改。我们使用主题上的asObservable方法来公开一个可观察的流,该流在每次更改时都会发出 hashtag 输入对象。我们将这个派生可观察流存储在具有公共可见性的hashTagChange成员中。其他组件可以访问这个属性并订阅,以便在 hashtag 输入事件发生时得到通知。

现在,让我们逐步添加更多到我们的指令中。首先,让我们添加一个重置方法,当 hashtag 输入应该被重置时我们可以调用这个方法。这个方法将在内部使用,当输入被取消时,也可以从外部,从另一个组件中调用,以取消标签输入:

reset() {
  this.hashTagInput = null;
  this.hashTagSubject.next(this.hashTagInput);
}

下一个方法用于根据用户输入更新内部 hashtag 输入对象:

  private updateHashTag(hashTag, position = this.hashTagInput.position) {
    this.hashTagInput = {hashTag, position};
    this.hashTagSubject.next(this.hashTagInput);
  }

现在,让我们向我们的标签输入指令添加两个主要方法,以收集用户输入。我们使用 HostListener 装饰器在宿主元素上创建 keydownkeypress 事件的绑定:

updateTextTag(textTag, position = this.position) { 
  this.textTag = textTag; 
  this.position = position; 
} 

keyDown 方法将由宿主事件绑定调用 keydown 事件。我们关注的是退格键,它应该也会移除当前输入的标签的最后字符。如果我们能检测到退格键(字符代码 8),我们将调用我们的 updateHashTag 方法,并使用 Array.prototype.slice 函数更新当前哈希标签,移除最后一个字符:

@HostListener('keydown', ['$event'])
keyDown(event: KeyboardEvent) {
  if (this.hashTagInput && event.which === 8) {
    this.updateHashTag(this.hashTagInput.hashTag.slice(0, -1));
  }
}

keyPress 方法是在 keypress 事件上从宿主元素的事件绑定中调用的。这就是这个支持指令的主要逻辑所在。在这里,我们处理两种不同的情况:

  • 如果按下的键是哈希符号,我们将从头开始一个新的标签

  • 如果按下的键不是有效的单词字符或哈希符号,我们将将其重置为其初始状态,这将取消标签输入

  • 任何其他有效字符,我们将添加到当前的文本标签字符串中

将以下代码添加到标签输入指令中:

@HostListener('keypress', ['$event'])
keyPress(event: KeyboardEvent) {
  const char = String.fromCharCode(event.which);
  if (char === '#') {
    this.updateHashTag('#', getRangeBoundingClientRect());
  } else if (!/[\w-]/i.test(char)) {
    this.reset();
  } else if (this.hashTagInput) {
    this.updateHashTag(this.hashTagInput.hashTag + char);
  }
}

当输入一个新的哈希标签(如果用户插入哈希符号)时,我们将更新内部哈希标签输入对象,并使用我们的实用函数 getRangeBoundingClientRect 将输入对象的位置设置为当前光标位置。

好的;现在我们有了处理标签输入所需的所有支持。然而,我们仍然需要一个方法来向用户展示标签服务中的可用标签。为此,我们将创建一个新的标签选择组件。它将向用户展示可用标签的列表,并利用我们支持指令发出的标签输入变化来过滤和定位列表。

创建一个标签选择组件

为了帮助用户找到正确的标签,我们将提供一个包含可用标签的下拉菜单。为此,我们需要使用我们标签输入指令发出的标签输入对象。让我们简要地看看这个组件的要求:

  • 它应该在工具提示/呼出框中显示从我们的标签服务中收集到的可用标签

  • 它应该支持显示标签的限制

  • 它应该接收一个带标签输入对象,用于过滤可用标签并使用标签输入对象上的位置数据定位自身

  • 它应该在用户点击列表中的标签时发出一个事件

  • 如果过滤器无效,或者没有元素匹配过滤器,组件应该隐藏:

图片

完成标签选择组件,通过用户输入过滤

让我们先更新我们的应用程序模型,位于 src/app/model.ts,以包括一个用于通信标签选择的新接口。将以下代码添加到文件中:

export interface TagSelection {
  tag: Tag;
  hashTagInput: HashTagInput;
}

如果一个标签被选中,我们想知道被选中的是哪个标签对象,同时,相应的标签输入对象也应该传递。这些数据是必要的,以便我们能够正确地响应选中的标签并正确更新可编辑元素。

让我们继续我们的组件,并创建组件类。首先,让我们使用 Angular CLI 构建一个新的组件:

ng generate component --spec false -cd onpush tags/tags-select

这将生成一个新的组件,我们将打开位于src/app/tags/tags-select/tags-select.component.ts的组件类文件,并添加以下代码:

import {ChangeDetectionStrategy, Component, EventEmitter, HostBinding, Input, OnChanges, Output} from '@angular/core';
import {HashTagInput, Tag, TagSelection} from '../../model';

const tagListLimit = 4;

@Component({
  selector: 'mac-tags-select',
  templateUrl: './tags-select.component.html',
  styleUrls: ['./tags-select.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TagsSelectComponent implements OnChanges {
  @Input() tags: Tag[];
  @Input() hashTagInput: HashTagInput | null;
  @Output() outSelectTag = new EventEmitter<TagSelection>();

  filteredTags: Tag[];

  filterTags() {
    const filter = this.hashTagInput.hashTag.slice(1).toLowerCase();
    this.filteredTags = this.tags
      .filter(tag =>
        tag.hashTag.toLowerCase().includes(filter) ||
        tag.title.toLowerCase().includes(filter)
      )
      .slice(0, tagListLimit);
  }

  selectTag(tag: Tag) {
    this.outSelectTag.next({
      tag,
      hashTagInput: this.hashTagInput
    });
  }

  ngOnChanges(changes) {
    if ((changes.hashTagInput || changes.tags) && this.hashTagInput) {
      this.filterTags();
    }
  }
}

我们的组件有两个输入元素。tags输入用于将所有可用的标签传递到标签选择组件中。这是当用户选择可用标签时将展示给用户的标签列表。hashTagInput输入是我们从之前创建的标签输入指令中获取的标签输入对象。我们将从该对象中提取当前用户输入以过滤显示的标签。我们还将使用该对象的位置数据将组件定位到用户开始编写标签输入的插入点屏幕坐标。

输出outSelectTag用于在用户从标签列表中选择标签时发出事件。filteredTags属性用于计算过滤后的标签列表。当hashTagInputtags输入对象发生变化时,我们调用filterTags方法。在这里,我们使用当前标签输入数据过滤所有标签列表。由于这只是一个计算状态,我们的组件仍然是一个纯组件,我们仍然可以使用OnPush变更检测策略。

当用户从过滤后的标签列表中选择标签时,会从视图中调用selectTag方法。在那里,我们发出一个新的标签选择对象,该对象包含选中的标签以及标签输入对象。

让我们继续并添加一些访问器属性到我们的组件中,我们使用这些属性来创建宿主元素样式绑定。以下访问器属性hasFilteredTags绑定到宿主元素的显示样式属性。它将控制组件是显示还是隐藏。只有当过滤器有效且过滤后的标签至少包含一个标签时,我们才会显示组件:

  @HostBinding('style.display')
  get hasFilteredTags() {
    return this.filteredTags && this.filteredTags.length > 0 ? 'block' : 'none';
  }

以下两个访问器属性使用宿主绑定来设置宿主元素的topleft样式,基于组件的hashTagInput输入:

  @HostBinding('style.top')
  get topPosition() {
    return this.hashTagInput && this.hashTagInput.position ?
      `${this.hashTagInput.position.top}px` : 0;
  }

  @HostBinding('style.left')
  get leftPosition() {
    return this.hashTagInput && this.hashTagInput.position ?
      `${this.hashTagInput.position.left}px` : 0;
  }

我们的组件模板相当简单。让我们打开存储在src/app/tags/tags-select/tags-select.component.html中的视图模板,并应用以下更改:

<ul class="list">
  <li *ngFor="let tag of filteredTags"
      (click)="selectTag(tag)"
      class="item">{{tag.title}}</li>
</ul>

我们使用了NgFor指令来遍历filteredTags成员中的所有标签。如果点击了一个标签,我们需要执行selectTag方法并传递当前迭代的标签。在列表中,我们只显示标签标题,这有助于用户识别他们想要使用的标签。

现在,我们已经构建了我们需要的所有组件,以实现用户平滑地输入标签。然而,我们仍然需要将所有这些组件连接起来。下一步是在我们的项目评论中启用标签选择。

在编辑器组件中集成标签选择

作为第一步,我们应该修改我们的编辑器组件,以便利用我们刚刚创建的标签选择组件与标签输入指令一起使用。

在我们开始更改编辑器之前,让我们看看一个新的字符串辅助函数splice,它允许我们传递文本中的特定位置,在那里我们想要用从所选标签对象中获取的最终标签替换用户输入的部分标签。

splice方法与Array.prototype.splice函数类似,允许我们在字符串中删除一定部分并添加新部分到该字符串的同一位置。这允许我们非常具体地替换字符串中的某些区域,这正是我们在这个情况下所需要的。让我们在我们的字符串实用模块src/app/utilities/string-utilities.ts中实现这个小小的辅助函数:

export function splice(
  target: string,
  index: number,
  deleteCount: number,
  content: string): string {
  return target.slice(0, index) +
    content +
    target.slice(index + deleteCount);
}

让我们回到我们的编辑器组件,看看在src/app/ui/editor/editor.component.html中组件模板内的更改。模板中的有效更改以粗体标注:

<div #editableContentElement
     class="editable-content"
     contenteditable="true"
 macTagsInput></div>
<mac-tags-select
 *ngIf="tags && tagsInput.hashTagChange | async"
 [hashTagInput]="tagsInput.hashTagChange | async"
 [tags]="tags"
 (outSelectTag)="selectTag($event)">
</mac-tags-select>
<div class="output" [innerHTML]="content ? (content | tags | async) : '-'"></div>
<div *ngIf="showControls && !editMode"
     class="controls">
  <button (click)="beginEdit()" class="icon-edit"></button>
</div>
<div *ngIf="showControls && editMode"
     class="controls">
  <button (click)="saveEdit()" class="icon-save"></button>
  <button (click)="cancelEdit()" class="icon-cancel"></button>
</div>

我们需要添加的第一件事是我们的标签输入指令,它将帮助我们收集用户在可编辑内容字段中输入的标签数据。

在可编辑内容元素下方,我们添加了新的标签选择组件。只有当将标签列表作为输入提供给编辑器组件时,我们才会渲染标签选择组件。我们使用从标签输入指令中提取的标签输入对象,并将其传递到标签选择的hashTagInput输入。如果标签选择组件发出outSelectTag事件,我们将调用一个新的selectTag方法,我们将在我们的编辑器组件中实现它。

现在,让我们将必要的更改应用到我们的组件类中,位于src/app/ui/editor/editor.component.html。省略号字符()表示未更改的代码部分。有效更改以粗体标注:

import {TagsInputDirective} from '../../tags/tags-input.directive';
import {Tag, TagSelection} from '../../model';
import {splice} from '../../utilities/string-utilities';

@Component({
  selector: 'mac-editor',
  templateUrl: './editor.component.html',
  styleUrls: ['./editor.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditorComponent implements OnChanges, AfterViewInit {
  @ViewChild('editableContentElement') editableContentElement: ElementRef;
  @ViewChild('editableContentElement', {
 read: TagsInputDirective
 }) tagsInput: TagsInputDirective;
  …

  saveEdit() {
    this.editMode = false;
    this.tagsInput.reset();
    this.outSaveEdit.emit(this.getEditableContent());
  }

  cancelEdit() {
    this.editMode = false;
 this.tagsInput.reset();
    this.setEditableContent(this.content);
    this.outCancelEdit.emit();
  }
  …

  selectTag(tagSelection: TagSelection) {
 this.setEditableContent(
 splice(
 this.getEditableContent(),
 tagSelection.hashTagInput.position.caretOffset,
 tagSelection.hashTagInput.hashTag.length,
 tagSelection.tag.hashTag
 ));
 this.tagsInput.reset();
 }
}

首先,我们为我们的可编辑内容元素添加了另一个视图查询,使用视图引用editableContentElement。然而,这次我们在视图查询装饰器中使用了额外的配置对象。视图查询选项中的read属性允许我们指定我们不想选择默认的ElementRef对象,而是选择一个在元素上存在的组件实例或指令实例的引用。在我们的例子中,我们想要获取到放置在可编辑内容元素上的标签输入指令的引用。

在我们的编辑器的saveEditcancelEdit方法中,我们现在可以额外调用我们的标签输入指令的 reset 方法。这将确保当用户保存或取消编辑时,我们不会持久化任何之前的标签条目。

最后,我们添加了一个新的方法:selectTag。这个方法是从编辑视图调用的,作为对来自标签选择组件的outSelectTag事件的响应。我们在这里所做的一切就是用标签选择对象中发出的标签替换掉用户输入的可编辑内容元素中的哈希标签部分。

太棒了!我们已经完成了对标签选择组件的工作,并将其与我们的标签输入指令集成到编辑组件中。

在项目评论中集成标签选择

由于编辑组件现在依赖于一个要作为输入传递的可用标签列表,我们需要对我们的项目评论组件应用一些更改。

让我们从项目评论容器组件开始,该组件位于src/app/container/project-comments-container/project-comments-container.component.ts。省略号字符隐藏了无关的代码部分,而有效的更改以粗体显示:

import {Comment, CommentUpdate, Project, Tag, User} from '../../model';
…
import {TagsService} from '../../tags/tags.service';

@Component({
  …
})
export class ProjectCommentsContainerComponent {
  …
  tags: Observable<Tag[]>;

  constructor(private projectService: ProjectService,
              private userService: UserService,
              private route: ActivatedRoute,
              private activitiesService: ActivitiesService,
 private tagsService: TagsService) {
    …
    this.tags = this.tagsService.tags;
  }
  …
}

现在,让我们看看容器组件中的视图模板更改,该组件位于src/app/container/project-comments-container/project-comments-container.component.ts

<mac-comments [user]="user | async"
              [comments]="projectComments | async"
              [tags]="tags | async"
              (outCreateComment)="createComment($event)"
              (outUpdateComment)="updateComment($event)">
</mac-comments>

真的很简单!我们只是从我们的标签服务中获取了标签可观察对象,在视图中订阅,并将生成的标签列表传递到我们的评论组件中。

让我们将我们的评论组件更改为接受该标签列表作为输入。打开文件src/app/comments/comments/comments.component.ts,并添加以下更改:

import {Comment, CommentUpdate, Tag, TagSelection, User} from '../../model';
import {TagsInputDirective} from '../../tags/tags-input.directive';
import {splice} from '../../utilities/string-utilities';

@Component({
  …
})
export class CommentsComponent {
  …
  @Input() tags: Tag[];
 @ViewChild('commentContentEditable', {
 read: TagsInputDirective
 }) tagsInput: TagsInputDirective;
  …

  selectTag(tagSelection: TagSelection) {
 this.commentContentEditable.nativeElement.textContent =
 splice(
 this.commentContentEditable.nativeElement.textContent,
 tagSelection.hashTagInput.position.caretOffset,
 tagSelection.hashTagInput.hashTag.length,
 tagSelection.tag.hashTag
 );
 this.tagsInput.reset();
 }
}

使用我们在编辑器中使用的相同机制,我们也在评论可编辑元素中启用了标签选择。让我们看看评论组件视图模板中的更改,该模板位于src/app/comments/comments/comments.component.html

<div class="title">Add new comment</div>
<div class="add-comment-section">
  <div #commentContentEditable
       class="add-comment-box"
       contenteditable="true"
       macTagsInput>
  </div>
 <mac-tags-select
 *ngIf="tags && tagsInput.hashTagChange | async"
 [hashTagInput]="tagsInput.hashTagChange | async"
 [tags]="tags"
 (outSelectTag)="selectTag($event)">
 </mac-tags-select>
  <button (click)="createComment()"
          class="button" >Add comment</button>
</div>

<ng-container *ngIf="comments.length > 0">
  <div class="title">All comments</div>
  <mac-comment *ngFor="let comment of comments; let index = index"
               [comment]="comment"
               [user]="user"
               [tags]="tags"
               (outUpdateComment)="updateComment(index, $event)">
  </mac-comment>
</ng-container>

除了为评论可编辑内容元素实现我们自己的标签选择外,我们还向下传递了从我们的父容器组件接收到的标签,作为每个评论组件的输入。

让我们继续并完成标签系统的集成。打开位于src/app/comments/comment/comment.component.ts的评论组件类,并应用以下更改:

import {Comment, Tag, User} from '../../model';

@Component({
  …
})
export class CommentComponent {
  …
  @Input() tags: Tag[];
  …
}

我们需要添加一个额外的输入来接收我们的标签列表。让我们也将必要的更改反映到我们的评论组件视图模板中,该模板位于src/app/comments/comment/comment.component.html

…
<div class="main">
  <div class="content">
    <mac-editor [content]="comment.content"
                [showControls]="comment.user.id === user.id"
                [tags]="tags"
                (outSaveEdit)="updateComment($event)">
    </mac-editor>
  </div>
</div>

好的!为了集成,确实有很多更改,但它们都很简单,现在我们可以使用我们的标签系统了!

完成我们的标签系统

恭喜!你现在已经成功实现了三个可用性组件中的第一个。

在标签输入指令的帮助下,我们隐藏了用户输入的低级编程和用户光标位置的处理。然后,我们创建了一个组件来向用户显示可用的标签,并提供了他们可以通过点击来选择标签的方式。在我们的编辑器组件中,我们使用了标签输入指令以及标签选择组件,以在编辑注释时平滑地输入标签。

在本节中,我们涵盖了以下概念:

  • 我们在指定的指令中处理了复杂的用户输入,以将逻辑从我们的组件中卸载

  • 我们使用宿主绑定来设置位置样式属性

  • 我们使用ViewChild装饰器上的read属性来查询指令实例

  • 我们实现了完全响应式组件,这些组件依赖于可观察对象,并在变更检测期间不产生副作用

在下一节中,我们将探讨如何将拖放功能集成到我们的应用程序中。我们将构建 Angular 指令,这将帮助我们轻松地将拖放功能集成到任务管理应用程序的任何区域。

拖放

我们已经学会了高效地使用我们的计算机鼠标和键盘。使用键盘快捷键、不同的点击动作和上下文鼠标菜单,可以为我们执行任务提供支持。然而,在当前的移动和触摸设备热潮中,有一个模式最近受到了更多的关注。拖放动作是一种非常直观和逻辑的表达动作的方式,例如移动或复制项目。在用户界面执行的一个特定任务受益于拖放:在列表中对项目进行排序。如果我们需要通过动作菜单对项目进行排序,会变得非常混乱。逐步移动项目,使用上下按钮,效果很好,但需要花费很多时间。如果你可以拖动项目并将它们拖放到你希望它们重新排序的位置,你可以非常快速地对项目列表进行排序。

在本主题中,我们将构建启用应用程序内拖放所需的基本元素。我们将使用拖放功能来启用用户重新排序他们的任务列表。通过开发可重用的指令来提供此功能,我们可以在应用程序的任何位置启用该功能。

为了实现我们的指令,我们将利用 HTML5 拖放 API,该 API 在撰写本书时得到了所有主要浏览器的支持。

由于我们希望在多个组件上重用我们的拖放行为,我们将使用指令来实现。在本节中,我们将创建两个指令:

  • 可拖动指令:此指令应附加到可以启用拖动的组件

  • 可拖动放置区域指令:此指令应附加到将作为放置目标的组件

我们还将实现一个功能,可以让我们选择性地决定哪些内容可以被拖放到哪些位置。为此,我们将在我们的可拖动指令中使用类型属性,以及在目标区域中使用接受类型属性。

更新我们的任务排序模型

作为第一步,我们应该使我们的任务模型支持排序。通过在我们的任务对象中引入一个 order 字段,然后我们可以使用该字段相应地排序任务。让我们对我们的模型文件进行以下更改,该文件位于 src/app/model.ts

export interface Task {
  readonly id?: number;
  readonly projectId?: number;
  readonly title: string;
  readonly done: boolean;
  readonly order: number;
}
…

export type DraggableType = 'task';

我们还添加了一个新的类型别名,DraggableType,我们使用它来识别我们应用程序内可以拖动的对象。我们将使用此类型来确保我们只能将拖放操作到支持给定类型的区域。

由于我们已经更改了任务模型以包含 order 属性,我们需要对我们的现有应用程序状态管理进行一些更改,以便与 order 属性一起工作。

首先,让我们更改我们的内存数据库,并打开文件 src/app/database.ts 以应用以下更改:

export class Database implements InMemoryDbService {
  createDb() {
    …

    const tasks: Task[] = [
      {id: 1, projectId: 1, title: 'Task 1', done: false, order: 1},
      {id: 2, projectId: 1, title: 'Task 2', done: false, order: 2},
      {id: 3, projectId: 1, title: 'Task 3', done: true, order: 3},
      {id: 4, projectId: 1, title: 'Task 4', done: false, order: 4}
    ];

    …
  }
}

现在,我们所有的初始任务都包含了一个 order 属性。现在,我们需要处理两件额外的事情:

  • 当创建新任务时,我们需要计算下一个可用的顺序值并使用它来创建新任务

  • 我们需要将任务列表更改为使用 order 属性进行排序

我们可以在任务列表容器组件中实现这两个更改。让我们打开文件 src/app/container/task-list-container/task-list-container.component.ts,并应用一些更改。不相关的代码部分使用省略号字符隐藏,而有效的更改以粗体显示:

…

@Component({
  selector: 'mac-task-list-container',
  templateUrl: './task-list-container.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListContainerComponent {
  …

  constructor(private taskService: TaskService,
              private projectService: ProjectService,
              private route: ActivatedRoute,
              private activitiesService: ActivitiesService) {
    …

    this.tasks = this.selectedProject.pipe(
      switchMap((project) => this.taskService.getProjectTasks(project.id)),
 map(tasks => tasks.sort((a: Task, b: Task) => b.order - a.order))
    );

    …
  }

  …

  addTask(title: string) {
    combineLatest(this.selectedProject, this.tasks)
      .pipe(
        take(1)
      )
      .subscribe(([project, tasks]) => {
        const position = tasks.reduce(
 (max, t: Task) => t.order > max ? t.order : max, 0
 ) + 1;
        const task: Task = {
          projectId: project.id, title, done: false, order: position
        };
        this.taskService.addTask(task);
        this.activitiesService.logProjectActivity(
          project.id,
          'tasks',
          'A task was added',
          `A new task "${limitWithEllipsis(title, 30)}" was added to 
         #project-${project.id}.`
        );
      });
  }

  …
}

好了;目前就是这样。我们已经成功引入了一个新的 order 属性,现在它被用来排序我们的任务列表。当我们想要使用拖放功能来排序任务列表时,这个顺序变得非常重要。

实现可拖动指令

draggable 指令将被附加到我们希望启用拖放功能的元素上。让我们使用 Angular CLI 工具创建一个新的指令开始:

ng generate directive --spec false draggable/draggable

让我们打开位于 src/app/draggable/draggable.directive.ts 的指令类文件,并添加以下代码:

import {Directive, HostBinding, HostListener, Input} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggable]'
})
export class DraggableDirective {
  @HostBinding('draggable') draggable = 'true';
  @Input() draggableData: any;
  @Input() draggableType: DraggableType;
  @HostBinding('class.dragging') dragging = false;
}

通过将 HTML 属性 draggable 设置为 true,使用宿主绑定,我们告诉浏览器我们正在考虑这个元素为可拖动元素。这个 HTML 属性已经是浏览器拖放 API 的一部分。

draggableData 输入用于指定表示可拖动元素的的数据。一旦拖动操作完成,这些数据将被序列化为 JSON 并传输到我们的目标区域。

通过使用我们引入到模型中的 draggableType 输入指定可拖动类型,当元素拖动到目标区域时,我们可以更加选择性地进行。在目标区域内,我们可以包括一个控制可接受的拖放类型的对应元素。

此外,我们可以使用主机绑定来设置一个名为dragging的类,这将应用一些特殊样式,使得识别出被拖动的元素变得容易。

现在,我们需要在我们的指令中处理两个事件,以实现可拖动元素的行为。以下 DOM 事件由拖放 DOM API 触发:

  • dragstart:这个事件在元素被抓住并在屏幕上移动时发出

  • dragend:如果之前启动的元素拖动因为成功的放置或释放到有效的放置目标之外而结束,这个 DOM 事件将被触发

让我们使用HostListener装饰器来实现dragstart事件的逻辑:

import {Directive, HostBinding, HostListener, Input} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggable]'
})
export class DraggableDirective {
  @HostBinding('draggable') draggable = 'true';
  @Input() draggableData: any;
  @Input() draggableType: DraggableType;
  @HostBinding('class.dragging') dragging = false;

 @HostListener('dragstart', ['$event'])
 dragStart(event) {
 event.dataTransfer.effectAllowed = 'move';
 event.dataTransfer.setData('application/json', JSON.stringify(this.draggableData));
 event.dataTransfer.setData(`draggable-type:${this.draggableType}`, '');
 this.dragging = true;
 }
}

现在,让我们讨论在实现我们的主机监听器时可以执行的不同操作。

我们需要在我们的主机监听器中访问 DOM 事件对象。如果我们要在模板中创建这个绑定,我们可能需要写一些类似这样的代码:(dragstart)="dragStart($event)"。在事件绑定中,我们可以利用合成变量$event,它是触发事件绑定的事件的引用。如果我们使用HostListener装饰器在我们的主机元素上创建事件绑定,我们需要通过装饰器的第二个参数来构造绑定的参数列表。

我们事件监听器的第一个操作是在数据传输对象上设置所需的effectAllowed属性。目前,我们只支持move效果,因为我们的主要关注点是使用拖放重新排序任务列表。拖放 API 非常特定于系统,但通常,如果用户在启动拖动时按住修饰键(如CtrlShift),会有不同的拖放效果。在我们的draggable指令中,我们可以强制所有拖动操作都使用move效果。

在下一个代码片段中,我们设置了通过拖动应该传输的数据。理解拖放 API 的核心目的是非常重要的。它不仅提供了一种在 DOM 元素中实现拖放的方法,而且还支持将文件和其他对象拖入浏览器。正因为如此,API 存在一些限制,其中之一是使得除了简单的字符串值之外的数据传输变得不可能。为了使我们能够传输复杂对象,我们将使用JSON.stringifydraggableData输入的数据序列化。

由于 API 中的一些安全约束导致的另一个限制是,数据只能在成功释放后读取。这意味着如果用户只是悬停在元素上,我们无法检查数据。然而,当悬停在释放区域上时,我们需要了解一些关于数据的事实。我们需要知道当进入释放区域时,可拖动元素的类型。这样我们就可以控制某些可拖动元素只能被放置在特定的释放区域中。我们为此问题使用了一个小的解决方案。拖放 API 在拖动数据到释放目标上时隐藏数据。然而,它告诉我们数据的类型。了解这一事实后,我们可以使用setData函数来编码我们的可拖动类型。仅访问数据键被认为是安全的,因此可以在所有释放区域事件中执行。

最后,我们将拖动标志设置为true,这将导致类绑定重新验证并添加dragging类到元素上。

在处理完dragstart事件后,我们现在需要处理dragend事件,以完成我们的可拖动指令。在绑定到dragend事件的dragEnd方法中,我们唯一做的事情是将拖动成员设置为false。这将导致dragging类从宿主元素中移除:

import {Directive, HostBinding, HostListener, Input} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggable]'
})
export class DraggableDirective {
  …

 @HostListener('dragend')
 onDragEnd() {
 this.dragging = false;
 }
}

这就是我们的可拖动指令的行为。现在,我们需要创建其对应指令,以提供释放区域的行为。

实现释放目标指令

释放区域将作为容器,其中可拖动元素可以被放置。为此,我们将创建一个新的可拖动释放区域指令。让我们使用 Angular CLI 来创建指令:

ng generate directive --spec false draggable/draggable-drop-zone

让我们打开位于src/app/draggable/draggable-drop-zone.directive.ts的指令文件,并添加以下代码:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  @Input() dropAcceptType: DraggableType;
  @Output() outDropDraggable = new EventEmitter<any>();
  @HostBinding('class.over') over = false;

  dragEnterCount = 0;
}

使用dropAcceptType输入,我们可以指定在这个释放区域中我们接受哪些类型的可拖动元素。这将帮助用户识别他们是否能够将可拖动元素放置在释放区域上。

在成功将项目拖放到释放区域后,我们需要发出一个事件,以便使用我们的拖放功能的组件可以相应地做出反应。为此,我们将使用dropDraggable输出属性。

over成员字段将存储如果被接受元素正在拖动到释放区域上时所处的状态。我们使用宿主绑定在宿主元素上设置类over。这样,当我们将项目拖放到释放区域上时,释放区域元素可以有不同的样式。

现在,让我们添加一个方法来检查我们的释放区域是否应该接受任何给定的拖放事件,通过检查我们的dropAcceptType成员。记得我们在创建可拖动指令时需要解决的那些安全问题吗?现在,我们正在实现其对应部分,从拖动事件中提取可拖动类型并检查拖动的项目是否被这个释放区域支持:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  @Input() dropAcceptType: DraggableType;
  @Output() outDropDraggable = new EventEmitter<any>();
  @HostBinding('class.over') over = false;

  dragEnterCount = 0;

 private typeIsAccepted(event: DragEvent) {
 const draggableType = Array.from(event.dataTransfer.types).find((key) =>
 key.indexOf('draggable-type') === 0);
 return draggableType && draggableType.split(':')[1] === this.dropAcceptType;
 }
}

我们只能读取拖动事件中数据传输对象内的键,其中数据本身在成功的drop事件发生之前是隐藏的。为了绕过这个安全限制,我们将可拖动类型信息编码到数据键本身中。由于我们可以通过使用数据传输对象的types字段安全地列出所有数据键,因此提取编码的可拖动类型信息并不太难。我们寻找一个以'draggable-type'开头的数据类型键,然后通过列字符进行分割。列字符后面的值是我们的类型信息,然后我们可以将其与dropAcceptType指令输入属性进行比较。

我们将使用两个事件来确定可拖动元素是否被移动到我们的拖放区域:

  • dragenter:当另一个元素被拖动到它上面时,该事件由一个元素触发

  • dragleave:当之前进入的元素再次离开时,该事件由一个元素触发

前面的事件有一个问题,就是它们实际上会冒泡,如果拖动的元素被移动到我们的拖放区域内的子元素中,我们将会收到一个dragleave事件。由于冒泡,我们还会从子元素那里收到dragenterdragleave事件。在我们的情况下,这并不是我们想要的,我们需要构建一些功能来改善这种行为。我们使用一个计数成员字段dragEnterCount,它在所有dragenter事件上增加计数,在dragleave事件上减少计数。这样,我们现在可以说,只有在dragleave事件中,当计数器变为零时,用户的鼠标光标才会离开拖放区域。让我们看看以下图表,它说明了这个问题:

我们计算的重要变量和函数的可视化

让我们实现这个逻辑,以在我们的拖放区域内为dragenterdragleave事件构建适当的行为:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  @Input() dropAcceptType: DraggableType;
  @Output() outDropDraggable = new EventEmitter<any>();
  @HostBinding('class.over') over = false;

  dragEnterCount = 0;

  private typeIsAccepted(event: DragEvent) {
    const draggableType = Array.from(event.dataTransfer.types).find((key) =>
      key.indexOf('draggable-type') === 0);
    return draggableType && draggableType.split(':')[1] === this.dropAcceptType;
  }

  @HostListener('dragenter', ['$event'])
 dragEnter(event: DragEvent) {
 if (this.typeIsAccepted(event)) {
 this.over = true;
 this.dragEnterCount++;
 }
 }

 @HostListener('dragleave', ['$event'])
 dragLeave(event: DragEvent) {
 if (this.typeIsAccepted(event) && --this.dragEnterCount === 0) {
 this.over = false;
 }
 }
}

在这两个事件中,我们首先检查事件是否携带数据传输对象,我们接受其类型。在通过我们的typeIsAccepted方法验证类型后,我们处理计数器,并在需要时设置over成员字段。

我们需要处理另一个事件,这对于拖放功能非常重要。dragover事件帮助我们设置当前拖动操作的接受dropEffect。这将告诉我们的浏览器,从我们的可拖动元素发起的拖动操作适合这个拖放区域。同样重要的是,我们需要防止默认的浏览器行为,这样就不会干扰我们自定义的拖放实现。让我们添加另一个主机监听器来覆盖这些关注点:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  …

 @HostListener('dragover', ['$event'])
 dragOver(event: DragEvent) {
 if (this.typeIsAccepted(event)) {
 event.preventDefault();
 event.dataTransfer.dropEffect = 'move';
 }
 }
}

最后,我们需要处理拖放区域中最重要的事件,即当用户将可拖动项拖入我们的拖放区域时触发的drop事件:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  …

  @HostListener('dragover', ['$event'])
  dragOver(event: DragEvent) {
    if (this.typeIsAccepted(event)) {
      event.preventDefault();
      event.dataTransfer.dropEffect = 'move';
    }
  }
  @HostListener('drop', ['$event'])
 drop(event: DragEvent) {
 if (this.typeIsAccepted(event)) {
 const data = JSON.parse(event.dataTransfer.getData('application/json'));
 this.over = false;
 this.dragEnterCount = 0;
 this.outDropDraggable.next(data);
 }
 }
}

在检查放下元素是否为接受类型之后,我们可以继续从事件中读取数据传输对象数据。这些数据之前由可拖动指令设置,需要使用 JSON.parse 进行反序列化。

由于放置成功,我们可以重置我们的 dragEnterCount 成员并将 over 标志设置为 false

最后,我们将使用我们的 outDropDraggable 输出属性发出来自可拖动元素的已反序列化数据。

这就是我们构建高度可重用拖放行为所需的所有内容。现在,我们可以将可拖动和可拖动放置区域附加到我们应用程序中任何需要启用拖放的组件上。

在下一节中,我们将集成应用程序中的拖放功能。

集成拖放

现在,我们可以在任务列表组件中使用可拖动和可拖动放置区域指令,这样我们就可以通过拖放来启用任务的重新排序。

我们可以通过将这两个指令都附加到任务列表组件模板中的任务元素上来做到这一点。没错!我们希望使我们的任务组件可拖动,同时也是一个放置区域。这样,我们就可以将任务拖放到其他任务上,这为我们提供了重新排序的基础。我们将执行的操作是在放置时重新排序列表,以便放置的任务将挤入它被放置的任务之前的位置。

首先,让我们将拖放指令应用到任务列表组件模板中的任务宿主元素上。打开文件 src/app/tasks/task-list/task-list.component.html,并应用以下更改:

<mac-toggle [buttonList]="taskFilterTypes"
            [activeButton]="activeTaskFilterType"
            (outActivate)="activateFilterType($event)">
</mac-toggle>
<mac-enter-task (outEnterTask)="addTask($event)"></mac-enter-task>
<div class="tasks">
  <mac-task *ngFor="let task of tasks"
            [task]="task"
            (outUpdateTask)="updateTask($event)"
            macDraggable
 draggableType="task"
 [draggableData]="task"
 macDraggableDropZone
 dropAcceptType="task"
 (outDropDraggable)="dropTask(task, $event)"></mac-task>
</div>

好的;使用前面的属性,我们使我们的任务不仅可拖动,同时也可以作为放置区域。通过将 draggableTypedropAcceptType 都指定为 'task' 字符串,我们告诉我们的拖放行为这些任务元素可以被拖放到其他任务元素中。我们的可拖动放置区域指令被设置为在有效可拖动元素被放下时发出一个 outDropDraggable 事件。在成功放置后,我们将在任务列表组件中调用一个新的方法 dropTask,我们将传递当前任务和放置区域事件对象。可拖动放置区域指令将发出之前使用可拖动指令的 draggableData 输入设置的任何数据。换句话说,dropTask 方法以目标任务作为第一个参数,源任务作为第二个参数被调用。

让我们在组件类中实现 dropTask 方法,该类位于 src/app/tasks/task-list/task-list.component.ts

…

@Component({
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListComponent {
  …

  dropTask(target: Task, source: Task) {
 if (target.id === source.id) {
 return;
 }

 this.outUpdateTask.emit({
 ...target,
 order: source.order
 });
 this.outUpdateTask.emit({
 ...source,
 order: target.order
 });
 }
}

让我们详细说明任务列表组件中的实现:

  1. 如果你再次检查模板,你会看到我们使用以下表达式绑定到dropTask方法:(outDropDraggable)="dropTask(task, $event)"。由于拖放区域发出的事件包含使用可拖动输入属性draggableData绑定的反序列化数据,我们可以安全地假设我们将收到一个被拖放到拖放区域的任务的副本。作为我们绑定的第一个参数,我们添加了本地视图变量task,它实际上是作为拖放区域起作用的任务。因此,我们可以说我们的dropTask方法的第一个参数代表目标,而第二个参数代表源任务。

  2. 在我们的方法中,我们首先比较源 ID 和目标 ID,如果它们匹配,我们可以假设任务被拖放到自身,我们不需要执行任何进一步的操作。

  3. 现在,我们只需从我们的任务列表组件中发出两个更新任务事件,以重新排列源任务和目标任务。我们通过在源任务和目标任务之间切换顺序属性来实现这一点。这只是重新排列的一种方式,我们也可以以不同的方式实现。

这有多棒?我们已经成功地在任务列表中实现了拖放,为用户提供了一个非常有用的功能来重新排列任务。

拖放回顾

通过使用低级拖放 API,使用事件和数据传输对象,我们已经实现了两个指令,现在可以在我们的应用程序中执行平滑的拖放功能,无论我们希望在何处。

几乎没有花费任何力气,我们就已经在任务列表上实现了拖放行为,为用户提供了一个很好的功能来重新排列列表中的任务。除了连接指令之外,我们唯一需要做的事情是实现一个方法,我们可以根据可拖动拖放区域指令的输出信息重新排列任务。

在本节中,我们使用了以下概念:

  • 我们学习了 HTML5 拖放 API 的基础知识

  • 我们使用数据传输对象在拖放事件中安全地传输数据

  • 我们使用指令构建了可重用的行为模式

  • 我们通过提供我们自己的自定义选择机制,使用自定义数据类型来编码可拖动类型信息,丰富了标准的拖放 API

摘要

在本章中,我们构建了两个功能来增强我们应用程序的可用性。用户现在可以使用标签,轻松地使用可导航的项目注释注释,这些项目提供了主题的摘要。他们还可以使用拖放,在任务列表组件中重新排列任务。

用户体验是当今应用的关键资产,通过提供高度封装和可重用的组件来解决用户体验问题,我们可以在构建这些应用时使生活变得更加轻松。在处理用户体验时,从组件的角度思考是非常好的,这不仅有助于简化开发,还有助于建立一致性。一致性本身在使应用易于使用方面发挥着重要作用。

在下一章中,我们将创建一些巧妙的组件来管理任务管理系统中的时间。这还将包括一些新的用户输入组件,以实现简单的工时输入字段。