新增: 画布批注层 AnnotationLayer,图片连线+可拖动气泡,SVG+DOM 分层

This commit is contained in:
lxy
2026-08-25 12:30:24 +08:00
parent 8eef4606c5
commit af5211d46d
3 changed files with 536 additions and 6 deletions
+439
View File
@@ -0,0 +1,439 @@
<!-- =====================================================================
AnnotationLayer.vue 画布批注叠加层
连线SVG1280×720 用户坐标+ 气泡DOM百分比定位可拖动
自读 store铺满 .canvas ElementView 平级pointer-events 精确放行
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { store, resolveColor, isDarkBg } from '../../core/store'
import { markdownToSegments, segmentsToHtml } from '../../core/richtext'
import type { Annotation, SlideElement } from '../../core/types'
const CANVAS_W = 1280
const CANVAS_H = 720
const slide = computed(() => store.currentSlide.value)
/** 当前页背景是否深色 → 文字反相 */
const dark = computed(() => isDarkBg(slide.value?.background || ''))
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
/** 扁平列表:{ el, anno },供连线与气泡渲染 */
const items = computed(() => {
const out: { el: SlideElement; anno: Annotation }[] = []
for (const el of slide.value?.elements || []) {
const list = el.style?.annotations
if (list?.length) for (const anno of list) out.push({ el, anno })
}
return out
})
/** 拖动中的临时覆盖:key = elId::annoId → 部分 box 字段(移动改 bx/by,缩放改 bw/bh */
const dragOverride = ref<Record<string, { bx?: number; by?: number; bw?: number; bh?: number }>>({})
const keyOf = (elId: string, annoId: string) => elId + '::' + annoId
/** 当前选中的批注(elId::annoId),高亮 + 显示缩放手柄;
* 同步到 store.selectedAnnoId 供全局 Delete 键裁决(批注删除优先于宿主元素) */
const selectedKey = ref<string | null>(null)
watch(selectedKey, v => store.setSelectedAnnoId(v ? v.split('::')[1] : null))
/** 宿主图片被取消选中 / 切页 / 批注被删 → 清空批注选中与编辑态 */
watch(
[() => store.selectedId.value, items],
([selId]) => {
if (!selectedKey.value) return
const stillExists = items.value.some(
({ el, anno }) => keyOf(el.id, anno.id) === selectedKey.value && el.id === selId
)
if (!stillExists) {
selectedKey.value = null
editingKey.value = null
}
}
)
/** 新增批注自动进入编辑:选中图片的 annotations 出现新 id(且文本为空)→ 直接开写 */
const seenAnnoIds = new Set<string>()
// 挂载时把存量批注标记已见(老数据不触发编辑);此后出现的新 id 才自动进入编辑
for (const { anno } of items.value) seenAnnoIds.add(anno.id)
watch(items, (list) => {
const selId = store.selectedId.value
for (const { el, anno } of list) {
if (!seenAnnoIds.has(anno.id)) {
seenAnnoIds.add(anno.id)
if (el.id === selId && !anno.text) {
selectedKey.value = keyOf(el.id, anno.id)
editingKey.value = keyOf(el.id, anno.id)
nextTick(() => { editRef.value?.focus() })
}
}
}
}, { deep: false })
/** 气泡当前坐标(拖动时用临时覆盖,否则用 anno 原值) */
function box(el: SlideElement, anno: Annotation) {
const ov = dragOverride.value[keyOf(el.id, anno.id)]
return {
bx: ov?.bx ?? anno.bx, by: ov?.by ?? anno.by,
bw: ov?.bw ?? anno.bw, bh: ov?.bh ?? anno.bh
}
}
/**
* 射线-矩形边框求交:从矩形中心 (cx,cy) 朝目标 (tx,ty) 方向,
* 取与矩形边框(半宽 hw、半高 hh)的交点。用于让连线从图片/气泡「边缘」出发,
* 不穿越本体。目标与中心重合时回退中心。
*/
function edgePoint(cx: number, cy: number, hw: number, hh: number, tx: number, ty: number) {
const dx = tx - cx, dy = ty - cy
if (!dx && !dy) return { x: cx, y: cy }
// 缩放因子:让 |dx*s|<=hw 且 |dy*s|<=hh,取首先触边者
const sx = dx ? hw / Math.abs(dx) : Infinity
const sy = dy ? hh / Math.abs(dy) : Infinity
const s = Math.min(sx, sy)
return { x: cx + dx * s, y: cy + dy * s }
}
/** 连线几何:起点=图片边缘、终点=气泡边缘(沿两中心连线方向),坐标转 1280×720 用户单位。
* 气泡中心落入图片框内(或两框重叠到边缘交点交叉)时返回 null → 不画线。 */
function lineGeom(el: SlideElement, anno: Annotation) {
const ex = el.x / 100 * CANVAS_W, ey = el.y / 100 * CANVAS_H
const ew = el.w / 100 * CANVAS_W, eh = el.h / 100 * CANVAS_H
const b = box(el, anno)
const bx = b.bx / 100 * CANVAS_W, by = b.by / 100 * CANVAS_H
const bw = b.bw / 100 * CANVAS_W, bh = b.bh / 100 * CANVAS_H
// 图片锚点中心(ax/ay 允许自定义偏移,默认几何中心)
const icx = ex + (anno.ax ?? 50) / 100 * ew
const icy = ey + (anno.ay ?? 50) / 100 * eh
const bcx = bx + bw / 2, bcy = by + bh / 2
// 气泡中心落在图片框内 → 连线无意义,隐藏
if (bcx >= ex && bcx <= ex + ew && bcy >= ey && bcy <= ey + eh) return null
const dist = Math.hypot(bcx - icx, bcy - icy)
// 起点从图片边缘出发(朝气泡方向),终点落在气泡边缘(朝图片方向)
const s = edgePoint(icx, icy, ew / 2, eh / 2, bcx, bcy)
const t = edgePoint(bcx, bcy, bw / 2, bh / 2, icx, icy)
// 两边缘点分别从各自中心前进的距离之和 ≥ 两中心距离 → 边框重叠,画出来会反向/交叉,隐藏
const sd = Math.hypot(s.x - icx, s.y - icy)
const td = Math.hypot(t.x - bcx, t.y - bcy)
if (sd + td >= dist) return null
return { x1: s.x, y1: s.y, x2: t.x, y2: t.y }
}
function lineColor(anno: Annotation) {
return resolveColor(anno.line?.color, dark.value) || (dark.value ? '#ffffff' : '#64748b')
}
function lineWidth(anno: Annotation) {
return (anno.line?.width ?? 0.5) * 2 // viewBox 用户单位偏细,放大系数;默认 0.5px
}
function dash(anno: Annotation) {
return anno.line?.style === 'dashed' ? '10 8' : undefined
}
/** cap → marker urlnone 返回 undefined */
function markerUrl(cap: string | undefined, kind: 'start' | 'end') {
if (cap === 'arrow') return 'url(#anno-arrow-' + kind + ')'
if (cap === 'dot') return 'url(#anno-dot)'
return undefined
}
/** 气泡文字样式 */
function bubbleTextStyle(anno: Annotation) {
return {
fontSize: (anno.fontSize || 14) + 'px',
color: resolveColor(anno.color, false) || '#1e293b',
fontWeight: anno.bold ? 700 : 400,
fontStyle: anno.italic ? 'italic' : 'normal',
textAlign: anno.align || 'left'
} as Record<string, string | number>
}
/** 气泡文字 → md 渲染 HTML(复用 richtext 行内格式 + 本组件补充 # 标题块级语法) */
function renderText(anno: Annotation): string {
const t = anno.text || ''
if (!t.trim()) return '<span class="anno-empty">双击输入批注</span>'
const base = anno.fontSize || 14
const HSIZE = [1.8, 1.5, 1.25] // #, ##, ### 相对基准字号的倍数
return t.split('\n').map(line => {
const h = /^(#{1,3})\s+(.*)$/.exec(line)
if (h) {
const level = h[1].length // 1~3
const inner = segmentsToHtml(markdownToSegments(h[2]))
const size = Math.round(base * HSIZE[level - 1])
return `<span style="display:block;font-size:${size}px;font-weight:700;line-height:1.25">${inner}</span>`
}
return segmentsToHtml(markdownToSegments(line))
}).join('<br>')
}
/* ---------- 气泡内联编辑(双击进入,编辑 md 源文) ---------- */
const editingKey = ref<string | null>(null)
const editRef = ref<HTMLTextAreaElement | null>(null)
function startEdit(el: SlideElement, anno: Annotation) {
store.selectElement(el.id)
selectedKey.value = keyOf(el.id, anno.id)
editingKey.value = keyOf(el.id, anno.id)
nextTick(() => { editRef.value?.focus(); editRef.value?.select() })
}
function commitEdit(el: SlideElement, anno: Annotation, value: string) {
editingKey.value = null
if (value !== anno.text) store.updateAnnotation(el.id, anno.id, { text: value })
}
/* ---------- 气泡拖动 / 缩放 ---------- */
const MIN_BW = 6, MIN_BH = 4 // 气泡最小尺寸(百分比)
interface DragState {
mode: 'move' | 'resize'
key: string; el: SlideElement; anno: Annotation
startBx: number; startBy: number; startBw: number; startBh: number
px0: number; py0: number
rect: DOMRect
}
let ds: DragState | null = null
function startDrag(mode: 'move' | 'resize', e: MouseEvent, el: SlideElement, anno: Annotation) {
e.preventDefault(); e.stopPropagation()
store.selectElement(el.id)
selectedKey.value = keyOf(el.id, anno.id)
const canvas = (e.currentTarget as HTMLElement).closest('.canvas') as HTMLElement | null
if (!canvas) return
ds = {
mode, key: keyOf(el.id, anno.id), el, anno,
startBx: anno.bx, startBy: anno.by, startBw: anno.bw, startBh: anno.bh,
px0: e.clientX, py0: e.clientY,
rect: canvas.getBoundingClientRect()
}
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragUp)
}
function onBubbleDown(e: MouseEvent, el: SlideElement, anno: Annotation) {
startDrag('move', e, el, anno)
}
function onResizeDown(e: MouseEvent, el: SlideElement, anno: Annotation) {
startDrag('resize', e, el, anno)
}
function onDragMove(e: MouseEvent) {
if (!ds) return
const dxPct = (e.clientX - ds.px0) / ds.rect.width * 100
const dyPct = (e.clientY - ds.py0) / ds.rect.height * 100
if (ds.mode === 'resize') {
const nbw = clamp(ds.startBw + dxPct, MIN_BW, 100 - ds.startBx)
const nbh = clamp(ds.startBh + dyPct, MIN_BH, 100 - ds.startBy)
dragOverride.value = { ...dragOverride.value, [ds.key]: { bw: nbw, bh: nbh } }
} else {
const nbx = clamp(ds.startBx + dxPct, 0, 100 - ds.startBw)
const nby = clamp(ds.startBy + dyPct, 0, 100 - ds.startBh)
dragOverride.value = { ...dragOverride.value, [ds.key]: { bx: nbx, by: nby } }
}
}
function onDragUp() {
if (ds) {
const ov = dragOverride.value[ds.key]
if (ov) {
const patch = ds.mode === 'resize' ? { bw: ov.bw, bh: ov.bh } : { bx: ov.bx, by: ov.by }
store.updateAnnotation(ds.el.id, ds.anno.id, patch)
}
const next = { ...dragOverride.value }
delete next[ds.key]
dragOverride.value = next
}
ds = null
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragUp)
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragUp)
document.removeEventListener('keydown', onLayerKeydown)
})
/** 选中批注时 Delete/Backspace 删除该批注(输入态/编辑态放行)。
* store.selectedAnnoId 已同步,App.vue 的元素删除会先让位 */
function onLayerKeydown(e: KeyboardEvent) {
if (e.key !== 'Delete' && e.key !== 'Backspace') return
if (document.body.dataset.mode === 'present') return
const t = e.target as HTMLElement
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable) return
if (!selectedKey.value || editingKey.value) return
const hit = items.value.find(({ el, anno }) => keyOf(el.id, anno.id) === selectedKey.value)
if (!hit) return
e.preventDefault()
store.delAnnotation(hit.el.id, hit.anno.id)
selectedKey.value = null
}
onMounted(() => {
document.addEventListener('keydown', onLayerKeydown)
})
</script>
<template>
<div class="anno-layer">
<!-- 连线层 -->
<svg
class="anno-svg"
viewBox="0 0 1280 720"
preserveAspectRatio="none"
>
<defs>
<marker
id="anno-arrow-end" markerUnits="userSpaceOnUse"
markerWidth="16" markerHeight="16" refX="12" refY="6" orient="auto"
>
<path d="M0,0 L12,6 L0,12 Z" fill="context-stroke" />
</marker>
<marker
id="anno-arrow-start" markerUnits="userSpaceOnUse"
markerWidth="16" markerHeight="16" refX="0" refY="6" orient="auto"
>
<path d="M12,0 L0,6 L12,12 Z" fill="context-stroke" />
</marker>
<marker
id="anno-dot" markerUnits="userSpaceOnUse"
markerWidth="12" markerHeight="12" refX="5" refY="5"
>
<circle cx="5" cy="5" r="4" fill="context-stroke" />
</marker>
</defs>
<template v-for="{ el, anno } in items" :key="'ln-' + el.id + anno.id">
<line
v-if="lineGeom(el, anno)"
v-bind="lineGeom(el, anno)!"
:stroke="lineColor(anno)"
:stroke-width="lineWidth(anno)"
:stroke-dasharray="dash(anno)"
stroke-linecap="round"
:marker-start="markerUrl(anno.line?.startCap, 'start')"
:marker-end="markerUrl(anno.line?.endCap, 'end')"
/>
</template>
</svg>
<!-- 气泡层 -->
<div
v-for="{ el, anno } in items"
:key="'bb-' + el.id + anno.id"
class="anno-bubble"
:class="{ selected: selectedKey === keyOf(el.id, anno.id) }"
:data-el-id="el.id"
:data-anno-id="anno.id"
:style="{
left: box(el, anno).bx + '%',
top: box(el, anno).by + '%',
width: box(el, anno).bw + '%',
height: box(el, anno).bh + '%',
...bubbleTextStyle(anno)
}"
@mousedown="onBubbleDown($event, el, anno)"
@dblclick.stop="startEdit(el, anno)"
>
<!-- 编辑态md 源文 textarea -->
<textarea
v-if="editingKey === keyOf(el.id, anno.id)"
ref="editRef"
class="anno-bubble-edit"
:value="anno.text"
placeholder="支持 **加粗** *斜体* ==高亮== # 标题"
@mousedown.stop
@blur="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.ctrl.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
@keydown.esc.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
></textarea>
<!-- 展示态md 渲染 -->
<span v-else class="anno-bubble-text" v-html="renderText(anno)"></span>
<span
class="anno-resize"
title="拖动调整气泡大小"
@mousedown="onResizeDown($event, el, anno)"
></span>
</div>
</div>
</template>
<style scoped>
.anno-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.anno-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
/* SVG 子元素默认 visiblePainted 会拦截点击,父级 pointer-events:none 不继承 → 显式关闭,
否则贴着图片边缘的连线描边会挡住图片的点选(回归根因) */
.anno-svg * {
pointer-events: none;
}
.anno-bubble {
position: absolute;
box-sizing: border-box;
pointer-events: auto;
cursor: move;
padding: 8px 10px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.1);
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
user-select: none;
overflow: hidden;
}
.anno-bubble.selected {
border-color: var(--ui-primary, #4f46e5);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.25), 0 2px 8px rgba(15, 23, 42, 0.1);
}
.anno-bubble-text {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
.anno-bubble-text :deep(.anno-empty) {
color: var(--ui-muted, #94a3b8);
font-style: italic;
}
.anno-bubble-edit {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
border: none;
outline: none;
padding: 0;
margin: 0;
resize: none;
background: transparent;
font: inherit;
color: inherit;
cursor: text;
}
.anno-resize {
position: absolute;
right: 0;
bottom: 0;
width: 14px;
height: 14px;
cursor: nwse-resize;
background:
linear-gradient(135deg, transparent 50%, var(--ui-primary, #4f46e5) 50%);
border-bottom-right-radius: 8px;
opacity: 0;
transition: opacity .12s;
}
.anno-bubble:hover .anno-resize,
.anno-bubble.selected .anno-resize {
opacity: 1;
}
</style>
+38 -4
View File
@@ -7,6 +7,7 @@ import { store, resolveBg } from '../../core/store'
import { CANVAS_W } from '../../core/sample' import { CANVAS_W } from '../../core/sample'
import { useEditor } from '../../composables/useEditor' import { useEditor } from '../../composables/useEditor'
import ElementView from './ElementView.vue' import ElementView from './ElementView.vue'
import AnnotationLayer from './AnnotationLayer.vue'
const canvasRef = ref<HTMLElement>() const canvasRef = ref<HTMLElement>()
const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准 const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准
@@ -20,6 +21,7 @@ const slide = computed(() => store.currentSlide.value)
const selectedId = computed(() => store.selectedId.value) const selectedId = computed(() => store.selectedId.value)
const selectedIds = computed(() => store.selectedIds.value || []) const selectedIds = computed(() => store.selectedIds.value || [])
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br'] const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
const noteOpen = ref(false) // 演讲者备注栏展开态
/** 框选矩形样式(% → CSS) */ /** 框选矩形样式(% → CSS) */
const marqueeBox = computed(() => { const marqueeBox = computed(() => {
@@ -45,11 +47,16 @@ function onElementBlur(id: string, field: string, value: string) {
editing.value = false editing.value = false
if (field === 'label') { if (field === 'label') {
store.updateElement(id, { style: { label: value } }) store.updateElement(id, { style: { label: value } })
} else if (store.findElement(id)?.type === 'list') { return
store.updateElement(id, { content: value })
} else {
store.updateElement(id, { content: value })
} }
// 形状输入文字:装饰细条(h 过小)自动撑高到能容纳一行字,避免文字被裁
const el = store.findElement(id)
if (el?.type === 'shape' && value.trim() && el.h < 6) {
const grow = 6 - el.h
store.updateElement(id, { content: value, h: 6, y: Math.max(0, el.y - grow / 2) })
return
}
store.updateElement(id, { content: value })
} }
/** 拖拽中元素的实时位置(覆盖 state) */ /** 拖拽中元素的实时位置(覆盖 state) */
@@ -76,6 +83,10 @@ function liveMultiBox(elId: string) {
return null return null
} }
function onNoteInput(e: Event) {
store.setSlideNote((e.target as HTMLTextAreaElement).value)
}
const onResize = () => fitCanvas() const onResize = () => fitCanvas()
/** 监听 frame 尺寸:v-show 隐藏→显示、窗口缩放、布局变化都会触发(隐藏时 rect=0,onMounted 的 rAF 会空跑) */ /** 监听 frame 尺寸:v-show 隐藏→显示、窗口缩放、布局变化都会触发(隐藏时 rect=0,onMounted 的 rAF 会空跑) */
@@ -134,10 +145,33 @@ onUnmounted(() => {
<!-- 框选矩形 --> <!-- 框选矩形 -->
<div v-if="marqueeBox" class="marquee-box" :style="marqueeBox"></div> <div v-if="marqueeBox" class="marquee-box" :style="marqueeBox"></div>
<AnnotationLayer />
</div> </div>
</div> </div>
<div class="canvas-footer">
<div class="canvas-status"> <div class="canvas-status">
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span> <span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
</div> </div>
<button
class="note-toggle"
:class="{ active: noteOpen || !!slide?.note }"
:title="noteOpen ? '收起备注' : '演讲者备注'"
@click="noteOpen = !noteOpen"
>
<span class="note-toggle-icon"></span>
<span>备注</span>
<span v-if="slide?.note && !noteOpen" class="note-dot"></span>
</button>
</div>
<div v-show="noteOpen" class="canvas-note">
<textarea
class="canvas-note-input"
:value="slide?.note || ''"
placeholder="演讲者备注(不显示在幻灯片上)…"
rows="2"
@input="onNoteInput"
></textarea>
</div>
</main> </main>
</template> </template>
+57
View File
@@ -0,0 +1,57 @@
/* shape-render.test.ts — 形状元素渲染回归(细条/装饰形状不被文字层污染) */
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ElementView from '../src/components/editor/ElementView.vue'
import { store } from '../src/core/store'
import type { SlideElement } from '../src/core/types'
/** 目录页 t2:标题下方的细横条(rect 128×11.5px */
const t2: SlideElement = {
id: 't2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '',
style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' }
}
/** 步骤页 st4:步骤间的小三角 */
const st4: SlideElement = {
id: 'st4', type: 'shape', x: 27.5, y: 40, w: 5, h: 0.8, content: '',
style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' }
}
describe('装饰形状渲染', () => {
it('细横条 rect:编辑态渲染图形 div + 空 contenteditable 文字层,无占位文字', () => {
const w = mount(ElementView, { props: { el: t2, bg: 'bg', edit: true }, attachTo: document.body })
const shape = w.find('.el-shape')
expect(shape.exists()).toBe(true)
expect(shape.attributes('style')).toContain('border-radius: 4px')
const text = w.find('.el-shape-text')
expect(text.exists()).toBe(true) // 编辑态保留可点击输入
expect(text.text()).toBe('') // 无占位文字
// 根节点暴露 data-shape 供 CSS 区分满幅/多边形安全区
expect(w.find('.el').attributes('data-shape')).toBe('rect')
w.unmount()
})
it('小三角 triangle:走 clip-path 分支,非正比框渲染', () => {
const w = mount(ElementView, { props: { el: st4, bg: 'bg', edit: true }, attachTo: document.body })
const shape = w.find('.el-shape')
expect(shape.attributes('style') || '').toContain('clip-path')
w.unmount()
})
it('非编辑态(模板预览):空 content 不渲染文字层', () => {
const w = mount(ElementView, { props: { el: t2, bg: 'bg' }, attachTo: document.body })
expect(w.find('.el-shape-text').exists()).toBe(false)
w.unmount()
})
it('有文字的形状:非编辑态渲染 md 文字层且默认白字', () => {
void store
const el = { ...t2, h: 10, content: '**加粗**文字' }
const w = mount(ElementView, { props: { el, bg: 'bg' }, attachTo: document.body })
const text = w.find('.el-shape-text')
expect(text.exists()).toBe(true)
expect(text.html()).toContain('<strong>加粗</strong>')
expect(text.attributes('style') || '').toContain('rgb(255, 255, 255)')
w.unmount()
})
})