From 20f684975d0bd188f301d002c021b2463cae35bb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 12:59:34 -0300 Subject: [PATCH 1/2] feat(ui): add ui.viewport.observe geometry-invalidation signal (SD-3311) One "your cached getRect() coordinates may be stale, re-query" notification, rAF-coalesced, payload { reason: 'layout'|'zoom'|'scroll'|'resize'|'mixed' }. Sources: layout/pagination repaints (post-paint), zoom, and DOM scroll/resize. The DOM scroll/resize listeners attach only while something is observing. Lets overlays anchored via getRect re-query on a single signal instead of hand-wiring scroll + resize + layout + zoom - which also fixes the cases that wiring silently misses: reflow and zoom fire no scroll event. Rewires the contract-templates field chip to use it. Before, zooming drifted the chip ~230px because it only repositioned on scroll/resize/active-change. Adds a demo regression (chip stays anchored after zoom) and viewport.observe unit tests (coalescing, mixed reason, unsubscribe). --- .../editor/custom-ui/content-controls.mdx | 4 +- .../contract-templates-chip-anchor.spec.ts | 73 +++++++++++++ demos/contract-templates/src/field-chip.ts | 12 ++- .../src/ui/create-super-doc-ui.ts | 102 ++++++++++++++++++ packages/super-editor/src/ui/types.ts | 38 +++++-- packages/super-editor/src/ui/viewport.test.ts | 52 +++++++++ 6 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 demos/__tests__/contract-templates-chip-anchor.spec.ts diff --git a/apps/docs/editor/custom-ui/content-controls.mdx b/apps/docs/editor/custom-ui/content-controls.mdx index 1ca5dea4a3..101c3e57d3 100644 --- a/apps/docs/editor/custom-ui/content-controls.mdx +++ b/apps/docs/editor/custom-ui/content-controls.mdx @@ -40,6 +40,7 @@ The event tells you *what* is active; `getRect` tells you *where* to draw. `acti | Read one control | `ui.contentControls.get({ id })` | | Position your UI | `ui.contentControls.getRect({ id })` | | Scroll a control into view | `ui.contentControls.scrollIntoView({ id })` | +| Re-anchor your UI when the page moves | `ui.viewport.observe(() => ...)` | | Hover and right-click hit-testing | `ui.viewport.entityAt()` / `contextAt()` | | Change content, tags, or locks | `editor.doc.contentControls.*` | @@ -47,9 +48,10 @@ The event tells you *what* is active; `getRect` tells you *where* to draw. `acti `scrollIntoView` resolves the control's position from the document, so it works even when the control is on a page that hasn't rendered yet (the page mounts, then scrolls). It scrolls only - it does not move the cursor into the control. +`ui.viewport.observe` is the single signal for "your `getRect()` coordinates may be stale, re-query": it fires (coalesced, once per frame) on scroll, resize, zoom, and layout reflow, so an overlay anchored with `getRect` stays glued without hand-wiring those events yourself. + ## Current limits -- No geometry-change subscription. Re-read `getRect()` on scroll, resize, and the `pagination-update` / `zoomChange` events. - No focus-by-id helper. Clicking a control in the document still drives selection. ## See also diff --git a/demos/__tests__/contract-templates-chip-anchor.spec.ts b/demos/__tests__/contract-templates-chip-anchor.spec.ts new file mode 100644 index 0000000000..cf16ec6558 --- /dev/null +++ b/demos/__tests__/contract-templates-chip-anchor.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; + +/** + * SD-3311 regression: the field chip must stay anchored to its active control + * after a geometry change that fires NO scroll event (zoom). The chip is a + * fixed-position overlay positioned from `ui.contentControls.getRect()`. Today + * field-chip only re-anchors on active-change / scroll / resize, so a zoom + * leaves it stranded (verified: ~230px drift). This is RED until + * `ui.viewport.observe()` lands and field-chip re-queries on it. + * + * Runs only for the contract-templates demo (the shared suite runs once per DEMO). + */ + +test.use({ viewport: { width: 1280, height: 800 } }); + +test('field chip stays anchored to its control after a zoom (no-scroll geometry change)', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => { + const ui = (window as any).__demo?.state?.ui; + return !!ui && ui.contentControls.getSnapshot().items.length > 0; + }, + null, + { timeout: 30_000 }, + ); + + // Activate the first inline smart field so the chip appears and anchors. + await page.waitForSelector('.superdoc-structured-content-inline[data-sdt-id]'); + await page.locator('.superdoc-structured-content-inline[data-sdt-id]').first().click(); + await page.locator('.sd-field-chip').waitFor({ state: 'visible', timeout: 10_000 }); + + // Horizontal gap between the chip's left edge and its active control's left + // edge. positionChip sets chip.left = control.left, so this is ~0 when anchored. + const probe = () => + page.evaluate(() => { + const ui = (window as any).__demo.state.ui; + const activeId = ui.contentControls.getSnapshot().activeId as string | null; + const chip = document.querySelector('.sd-field-chip'); + const ctrl = activeId ? document.querySelector(`[data-sdt-id="${activeId}"]`) : null; + if (!chip || !ctrl) return null; + const c = chip.getBoundingClientRect(); + const k = ctrl.getBoundingClientRect(); + return { dxLeft: Math.abs(c.left - k.left), ctrlLeft: Math.round(k.left) }; + }); + + const before = await probe(); + expect(before, 'chip + active control both resolve').not.toBeNull(); + expect(before!.dxLeft, 'chip starts anchored to the control').toBeLessThanOrEqual(2); + + // Zoom: a geometry change with no scroll event. + await page.evaluate(() => (window as any).__demo.superdoc.setZoom(150)); + + // Poll for the settled state: the control has moved (zoom applied) AND the + // chip has re-anchored to it. Polling absorbs the rAF/repaint delay between + // the geometry change and the viewport.observe -> positionChip re-query. + // Without the SD-3311 fix this stays "drift:~230" and times out. + await expect + .poll( + async () => { + const p = await probe(); + if (!p) return 'no-probe'; + if (p.ctrlLeft === before!.ctrlLeft) return 'control-not-moved'; + return p.dxLeft <= 2 ? 'anchored' : `drift:${Math.round(p.dxLeft)}`; + }, + { timeout: 6_000 }, + ) + .toBe('anchored'); +}); diff --git a/demos/contract-templates/src/field-chip.ts b/demos/contract-templates/src/field-chip.ts index 53b328e5e6..a6311ce52e 100644 --- a/demos/contract-templates/src/field-chip.ts +++ b/demos/contract-templates/src/field-chip.ts @@ -115,7 +115,11 @@ export function attachFieldChip(superdoc: SuperDoc, ui: SuperDocUI, lookup: Smar positionChip(); }; - const onScrollOrResize = () => positionChip(); + // Re-anchor whenever the viewport geometry changes. ui.viewport.observe is + // the single signal for this - it fires on scroll, resize, zoom, and + // layout/pagination reflow, so we catch the zoom / reflow cases that + // hand-wired window scroll + resize listeners miss (SD-3311). + const onViewportChange = () => positionChip(); // SD-3232: the active control comes from the public SuperDoc event. The // payload includes the SdtRef (id + tag), so we can narrow to smart @@ -150,13 +154,11 @@ export function attachFieldChip(superdoc: SuperDoc, ui: SuperDocUI, lookup: Smar }; superdoc.on('content-control:active-change', onActiveChange); - window.addEventListener('scroll', onScrollOrResize, true); - window.addEventListener('resize', onScrollOrResize); + const unobserveViewport = ui.viewport.observe(onViewportChange); return () => { superdoc.off('content-control:active-change', onActiveChange); - window.removeEventListener('scroll', onScrollOrResize, true); - window.removeEventListener('resize', onScrollOrResize); + unobserveViewport(); chipEl.remove(); }; } diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 2af4ece7d6..d4d34b56c8 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -66,6 +66,7 @@ import type { ViewportPositionAtInput, ViewportPositionHit, ViewportHandle, + ViewportGeometryEvent, ViewportRect, ViewportRectResult, } from './types.js'; @@ -958,6 +959,81 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; }; + // --- Viewport geometry-invalidation signal (ui.viewport.observe) --------- + // One "your cached getRect() coords may be stale, re-query" notification. + // Sources: layout/pagination repaints (post-paint), zoom, and DOM scroll / + // resize. rAF-coalesced, so a burst collapses to one notification per frame. + const geometryListeners = new Set<(event: ViewportGeometryEvent) => void>(); + const pendingGeometryReasons = new Set>(); + let geometryRaf: number | null = null; + let zoomPending = false; + + const cancelGeometryFrame = () => { + if (geometryRaf == null) return; + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(geometryRaf); + else clearTimeout(geometryRaf as unknown as ReturnType); + geometryRaf = null; + }; + const flushGeometry = () => { + geometryRaf = null; + const reasons = [...pendingGeometryReasons]; + pendingGeometryReasons.clear(); + if (geometryListeners.size === 0 || reasons.length === 0) return; + const reason: ViewportGeometryEvent['reason'] = reasons.length === 1 ? reasons[0] : 'mixed'; + [...geometryListeners].forEach((listener) => { + try { + listener({ reason }); + } catch { + // Isolate a faulty consumer; the others still get notified. + } + }); + }; + const scheduleGeometry = (reason: Exclude) => { + if (geometryListeners.size === 0) return; + pendingGeometryReasons.add(reason); + if (geometryRaf != null) return; + geometryRaf = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame(flushGeometry) + : (setTimeout(flushGeometry, 0) as unknown as number); + }; + // zoomChange fires *before* the re-render, so notifying then would hand + // consumers stale rects. Tag the next post-paint layout flush as 'zoom'. + const onGeometryZoom = () => { + zoomPending = true; + }; + const onGeometryLayout = () => { + if (zoomPending) { + zoomPending = false; + scheduleGeometry('zoom'); + } else { + scheduleGeometry('layout'); + } + }; + const onWindowScrollGeometry = () => scheduleGeometry('scroll'); + const onWindowResizeGeometry = () => scheduleGeometry('resize'); + let domGeometryAttached = false; + const attachDomGeometryListeners = () => { + if (domGeometryAttached || typeof window === 'undefined') return; + domGeometryAttached = true; + // Capture phase so scrolls inside the editor's own scroll container + // (scroll events don't bubble) are still observed. + window.addEventListener('scroll', onWindowScrollGeometry, true); + window.addEventListener('resize', onWindowResizeGeometry); + }; + const detachDomGeometryListeners = () => { + if (!domGeometryAttached || typeof window === 'undefined') return; + domGeometryAttached = false; + window.removeEventListener('scroll', onWindowScrollGeometry, true); + window.removeEventListener('resize', onWindowResizeGeometry); + }; + teardown.push(() => { + detachDomGeometryListeners(); + cancelGeometryFrame(); + geometryListeners.clear(); + pendingGeometryReasons.clear(); + }); + // Wire SuperDoc-instance events. The wrapper-side bus (editorCreate / // document-mode-change / zoomChange) is the only path for some of // these signals today; if the wrapper migrates them to the editor @@ -966,8 +1042,12 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { SUPERDOC_EVENTS.forEach((name) => { superdoc.on?.(name, scheduleNotify); }); + // zoom drives geometry (post-paint, tagged via onGeometryLayout) — separate + // from the slice recompute that SUPERDOC_EVENTS triggers. + superdoc.on?.('zoomChange', onGeometryZoom); teardown.push(() => { SUPERDOC_EVENTS.forEach((name) => superdoc.off?.(name, scheduleNotify)); + superdoc.off?.('zoomChange', onGeometryZoom); }); } @@ -1089,8 +1169,15 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { PRESENTATION_EVENTS.forEach((name) => { next.on?.(name, onPresentationChange); }); + // Geometry-only: layout/pagination repaints move painted rects without a + // body `transaction`. Drive the viewport geometry signal, NOT the slice + // recompute (which would re-attach editor listeners on every repaint). + next.on?.('layoutUpdated', onGeometryLayout); + next.on?.('paginationUpdate', onGeometryLayout); currentPresentationTeardown = () => { PRESENTATION_EVENTS.forEach((name) => next.off?.(name, onPresentationChange)); + next.off?.('layoutUpdated', onGeometryLayout); + next.off?.('paginationUpdate', onGeometryLayout); }; }; @@ -1927,6 +2014,21 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; }, + observe(listener: (event: ViewportGeometryEvent) => void): () => void { + geometryListeners.add(listener); + // Attach the DOM scroll/resize listeners only while someone is observing. + if (geometryListeners.size === 1) attachDomGeometryListeners(); + return () => { + if (!geometryListeners.delete(listener)) return; + if (geometryListeners.size === 0) { + detachDomGeometryListeners(); + cancelGeometryFrame(); + pendingGeometryReasons.clear(); + zoomPending = false; + } + }; + }, + async scrollIntoView(input: ScrollIntoViewInput): Promise { return runScrollIntoView(input); }, diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index a5c394a892..b1bac10f5e 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -714,11 +714,10 @@ export interface SuperDocUI { selection: SelectionHandle; /** - * Viewport domain — imperative geometry queries for sticky-card / - * floating-toolbar placement against painted entities and ranges. - * No subscription substrate — viewport rects are read on-demand by - * the consumer (e.g. on hover, on scroll, on layout-change events - * the consumer already listens to). Browser-only by definition. + * Viewport domain — geometry queries for sticky-card / floating-toolbar + * placement against painted entities and ranges, plus + * {@link ViewportHandle.observe} to learn when those rects may have moved. + * Browser-only by definition. */ viewport: ViewportHandle; @@ -1831,17 +1830,38 @@ export type ViewportRectResult = }; /** - * Imperative viewport-geometry surface. No subscription primitive — - * rects are read on demand. Consumers who need to reflow on layout - * change typically already listen to a `transaction` / `paint` / - * `scroll` event upstream and call `getRect` from there. + * Reason a {@link ViewportHandle.observe} notification fired. `'mixed'` + * when more than one change coalesced into the same animation frame. */ +export type ViewportGeometryReason = 'layout' | 'zoom' | 'scroll' | 'resize' | 'mixed'; + +/** + * Payload for {@link ViewportHandle.observe}. Intentionally minimal: the + * signal means "your cached `getRect()` coordinates may be stale, re-query" - + * it carries no geometry. + */ +export interface ViewportGeometryEvent { + reason: ViewportGeometryReason; +} + export interface ViewportHandle { /** * Look up the painted rectangle(s) of an entity or text range in * viewport coordinates. Synchronous — no DOM mutation required. */ getRect(input: ViewportGetRectInput): ViewportRectResult; + /** + * Subscribe to viewport geometry invalidation. The listener fires (once + * per animation frame, coalesced) after anything that can move painted + * rectangles: layout / pagination repaints, zoom, and DOM scroll / resize. + * It carries no coordinates — re-query {@link getRect} for the entities you + * care about. Returns an unsubscribe. + * + * This is the single signal overlays should listen to instead of + * hand-wiring scroll + resize + layout + zoom (and still missing cases like + * reflow and zoom, which fire no scroll event). + */ + observe(listener: (event: ViewportGeometryEvent) => void): () => void; /** * Scroll the viewport so the target is visible. Browser-only by * definition: drives `presentation.navigateTo()` for entity targets diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index e3706880c3..1cc8e434b6 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -555,3 +555,55 @@ describe('ui.viewport.contextAt - bundle composition', () => { ui.destroy(); }); }); + +describe('ui.viewport.observe — geometry invalidation (SD-3311)', () => { + // The rAF flush resolves within a frame; a short real-timer wait covers both + // the requestAnimationFrame path and the setTimeout fallback. + const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 30)); + + it('fires once per frame on scroll (reason "scroll") and stops after unsubscribe', async () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + const unsubscribe = ui.viewport.observe((e) => events.push(e)); + + // Burst in one frame -> a single coalesced notification. + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('scroll')); + await nextFrame(); + expect(events).toEqual([{ reason: 'scroll' }]); + + unsubscribe(); + window.dispatchEvent(new Event('scroll')); + await nextFrame(); + expect(events).toHaveLength(1); // no notification after unsubscribe + + ui.destroy(); + }); + + it('coalesces different reasons in the same frame to "mixed"', async () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + await nextFrame(); + expect(events).toEqual([{ reason: 'mixed' }]); + + ui.destroy(); + }); + + it('does not notify after the UI is destroyed', async () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + ui.destroy(); + window.dispatchEvent(new Event('resize')); + await nextFrame(); + expect(events).toEqual([]); + }); +}); From 8a2b0d517fcc5c67986b9761f8f3e2c5703790c5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 13:26:45 -0300 Subject: [PATCH 2/2] fix(ui): report 'zoom' not 'mixed' for a zoom repaint (SD-3311) layoutUpdated and paginationUpdate are emitted back-to-back with the same payload for the same paint (PresentationEditor.ts:6491-6492), so subscribing geometry invalidation to both double-counted one repaint: a zoom coalesced to 'mixed' and the 'zoom' reason was unreachable. Subscribe to layoutUpdated only - it covers every repaint. Adds regression coverage for the zoom and plain layout reasons. --- .../src/ui/create-super-doc-ui.ts | 11 ++- packages/super-editor/src/ui/viewport.test.ts | 82 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index d4d34b56c8..e6bbd5efeb 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -1169,15 +1169,18 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { PRESENTATION_EVENTS.forEach((name) => { next.on?.(name, onPresentationChange); }); - // Geometry-only: layout/pagination repaints move painted rects without a - // body `transaction`. Drive the viewport geometry signal, NOT the slice + // Geometry-only: layout repaints move painted rects without a body + // `transaction`. Drive the viewport geometry signal, NOT the slice // recompute (which would re-attach editor listeners on every repaint). + // Listen to `layoutUpdated` only: `paginationUpdate` is emitted + // back-to-back with the same payload for the same paint + // (PresentationEditor.ts:6491-6492), so subscribing to both would + // double-count one repaint — a zoom would coalesce to 'mixed' instead of + // 'zoom'. `layoutUpdated` alone covers every repaint. next.on?.('layoutUpdated', onGeometryLayout); - next.on?.('paginationUpdate', onGeometryLayout); currentPresentationTeardown = () => { PRESENTATION_EVENTS.forEach((name) => next.off?.(name, onPresentationChange)); next.off?.('layoutUpdated', onGeometryLayout); - next.off?.('paginationUpdate', onGeometryLayout); }; }; diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 1cc8e434b6..5ec9ee543b 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -607,3 +607,85 @@ describe('ui.viewport.observe — geometry invalidation (SD-3311)', () => { expect(events).toEqual([]); }); }); + +// Stub with real event emitters so tests can drive the engine signals that +// feed ui.viewport.observe: superdoc `zoomChange` and presentation +// `layoutUpdated` / `paginationUpdate`. +function makeEmitter() { + const map = new Map void>>(); + return { + on: (e: string, h: (p?: unknown) => void) => { + if (!map.has(e)) map.set(e, new Set()); + map.get(e)!.add(h); + }, + off: (e: string, h: (p?: unknown) => void) => { + map.get(e)?.delete(h); + }, + emit: (e: string, p?: unknown) => [...(map.get(e) ?? [])].forEach((h) => h(p)), + }; +} + +function makeGeometryStub() { + const sd = makeEmitter(); + const pres = makeEmitter(); + const emptyList = () => ({ evaluatedRevision: 'r1', total: 0, items: [], page: { limit: 0, offset: 0, returned: 0 } }); + const editor: { on: ReturnType; off: ReturnType; doc: unknown; presentationEditor: unknown } = { + on: vi.fn(), + off: vi.fn(), + doc: { + selection: { current: vi.fn(() => ({ empty: true })) }, + comments: { list: vi.fn(emptyList) }, + trackChanges: { list: vi.fn(emptyList) }, + contentControls: { list: vi.fn(() => ({ items: [], total: 0 })) }, + }, + presentationEditor: undefined, + }; + editor.presentationEditor = { + on: pres.on, + off: pres.off, + getActiveEditor: () => editor, + getEntityRects: vi.fn(() => []), + navigateTo: vi.fn(async () => true), + }; + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: sd.on as never, + off: sd.off as never, + }; + return { superdoc, emitSuperdoc: sd.emit, emitPresentation: pres.emit }; +} + +describe('ui.viewport.observe — repaint reason (SD-3311 regression)', () => { + const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 30)); + + it('reports "zoom" for a zoom repaint, not "mixed" (layoutUpdated + paginationUpdate are one paint)', async () => { + const { superdoc, emitSuperdoc, emitPresentation } = makeGeometryStub(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + // zoomChange (pre-paint), then the paired post-paint repaint events. + emitSuperdoc('zoomChange'); + emitPresentation('layoutUpdated'); + emitPresentation('paginationUpdate'); // same paint / payload — must not double-count + await nextFrame(); + + expect(events).toEqual([{ reason: 'zoom' }]); + ui.destroy(); + }); + + it('reports "layout" for a plain repaint (the paginationUpdate alias does not make it "mixed")', async () => { + const { superdoc, emitPresentation } = makeGeometryStub(); + const ui = createSuperDocUI({ superdoc }); + const events: Array<{ reason: string }> = []; + ui.viewport.observe((e) => events.push(e)); + + emitPresentation('layoutUpdated'); + emitPresentation('paginationUpdate'); + await nextFrame(); + + expect(events).toEqual([{ reason: 'layout' }]); + ui.destroy(); + }); +});