pptx-angular-viewer 1.1.58 → 1.1.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22501,117 +22501,22 @@ function asSelectionIds(value) {
22501
22501
  }
22502
22502
 
22503
22503
  /**
22504
- * collaboration-sync.ts: Framework-agnostic CRDT sync utilities for the
22505
- * pptx-viewer collaboration stack (Yjs backend).
22504
+ * collaboration-text-codec.ts: TextSegment[] <-> Y.Text delta codec used by the
22505
+ * collaboration sync layer. Split out of collaboration-sync.ts to keep both
22506
+ * modules focused.
22506
22507
  *
22507
22508
  * Exports:
22508
- * - Structural Yjs interfaces (no hard yjs import - bindings pass live instances)
22509
- * - YjsFactories: factory interface bindings implement using `new Y.Map()` etc.
22510
- * - encodeTextBody / decodeTextBody: TextSegment[] <-> YTextLike delta
22511
- * - writeElementToYMap / readElementFromYMap: PptxElement <-> YMapLike
22512
- * - writeSlideToYMap / readSlideFromYMap: PptxSlide <-> YMapLike
22513
- * - writeSlidesToYDoc / readSlidesFromYDoc: PptxSlide[] <-> Y.Doc
22514
- * - observeYDocSlides: register a change listener on the pptx:slides array
22515
- *
22516
- * Y.Doc schema (matches pptx-codec.ts in packages/tools):
22517
- * pptx:slides - Y.Array of slide Y.Maps
22518
- * Each slide Y.Map has scalar keys + `_`-prefixed JSON blobs + `elements`
22519
- * Each element Y.Map has scalar keys + `_`-prefixed JSON blobs + `textBody`
22520
- * textBody is a Y.Text with one delta-op per TextSegment
22509
+ * - DeltaOp / YTextLike: structural Yjs text interfaces (no yjs import)
22510
+ * - encodeTextBody: write TextSegment[] into a live YTextLike
22511
+ * - encodeSegmentsToDelta: pure simulation of the delta Y.Text would produce
22512
+ * - decodeDelta / decodeTextBody: delta -> TextSegment[]
22513
+ * - isYTextLike: runtime guard
22521
22514
  */
22522
- // ---------------------------------------------------------------------------
22523
- // Y.Doc schema constants (mirror of pptx-codec.ts)
22524
- // ---------------------------------------------------------------------------
22525
- const YDOC_SLIDES_KEY = 'pptx:slides';
22526
- const YDOC_META_KEY = 'pptx:meta';
22527
- const SCALAR_ELEMENT_KEYS = new Set([
22528
- 'id',
22529
- 'type',
22530
- 'x',
22531
- 'y',
22532
- 'width',
22533
- 'height',
22534
- 'rotation',
22535
- 'flipHorizontal',
22536
- 'flipVertical',
22537
- 'hidden',
22538
- 'opacity',
22539
- 'text',
22540
- 'name',
22541
- 'altText',
22542
- 'shapeType',
22543
- 'placeholder',
22544
- 'imagePath',
22545
- 'imageData',
22546
- 'svgContent',
22547
- 'inkSvg',
22548
- 'sourceSlideId',
22549
- 'mediaType',
22550
- 'mediaPath',
22551
- 'linkedTxbxId',
22552
- 'linkedTxbxSeq',
22553
- 'promptText',
22554
- ]);
22555
- const COMPLEX_ELEMENT_FIELDS = {
22556
- textStyle: '_ts',
22557
- shapeStyle: '_ss',
22558
- shapeAdjustments: '_sa',
22559
- adjustmentHandles: '_ah',
22560
- tableData: '_td',
22561
- chartData: '_cd',
22562
- smartArtData: '_smad',
22563
- connectionStart: '_cs',
22564
- connectionEnd: '_ce',
22565
- animations: '_an',
22566
- nativeAnimations: '_na',
22567
- children: '_ch',
22568
- paragraphIndents: '_pi',
22569
- rawXml: '_rx',
22570
- actionClick: '_ac',
22571
- actionHover: '_av',
22572
- locks: '_lk',
22573
- imageEffects: '_ie',
22574
- cropShape: '_cr',
22575
- mediaBookmarks: '_mb',
22576
- captionTracks: '_ct',
22577
- };
22578
- const REV_COMPLEX_ELEMENT = Object.fromEntries(Object.entries(COMPLEX_ELEMENT_FIELDS).map(([k, v]) => [v, k]));
22579
- const SCALAR_SLIDE_KEYS = new Set([
22580
- 'id',
22581
- 'rId',
22582
- 'sourceSlideId',
22583
- 'layoutPath',
22584
- 'layoutName',
22585
- 'slideNumber',
22586
- 'hidden',
22587
- 'sectionName',
22588
- 'sectionId',
22589
- 'backgroundColor',
22590
- 'backgroundImage',
22591
- 'backgroundGradient',
22592
- 'notes',
22593
- 'backgroundShowAnimation',
22594
- 'showMasterShapes',
22595
- 'isDirty',
22596
- ]);
22597
- const COMPLEX_SLIDE_FIELDS = {
22598
- transition: '_tr',
22599
- animations: '_an',
22600
- nativeAnimations: '_na',
22601
- rawTiming: '_rt',
22602
- notesSegments: '_ns',
22603
- comments: '_cm',
22604
- warnings: '_wa',
22605
- rawXml: '_rx',
22606
- clrMapOverride: '_cm2',
22607
- guides: '_gu',
22608
- customerData: '_cu',
22609
- activeXControls: '_ax',
22610
- };
22611
- const REV_COMPLEX_SLIDE = Object.fromEntries(Object.entries(COMPLEX_SLIDE_FIELDS).map(([k, v]) => [v, k]));
22612
- // ---------------------------------------------------------------------------
22613
- // Text-body encode/decode (TextSegment[] <-> YTextLike delta)
22614
- // ---------------------------------------------------------------------------
22515
+ function isYTextLike(value) {
22516
+ return (typeof value === 'object' &&
22517
+ value !== null &&
22518
+ typeof value.toDelta === 'function');
22519
+ }
22615
22520
  function buildSegmentAttrs(seg) {
22616
22521
  const a = {};
22617
22522
  const style = seg.style;
@@ -22668,29 +22573,56 @@ function buildSegmentAttrs(seg) {
22668
22573
  }
22669
22574
  return a;
22670
22575
  }
22576
+ /** Resolve the literal text a segment contributes to the Y.Text document. */
22577
+ function segmentInsertText(seg) {
22578
+ if (seg.isParagraphBreak === true || seg.isLineBreak === true) {
22579
+ return '\n';
22580
+ }
22581
+ if (typeof seg.text === 'string' && seg.text.length > 0) {
22582
+ return seg.text;
22583
+ }
22584
+ // Empty non-break run: zero-width space holds the attributes.
22585
+ return '​';
22586
+ }
22671
22587
  function encodeTextBody(segments, ytext) {
22672
22588
  let offset = 0;
22673
22589
  for (const raw of segments) {
22674
22590
  const seg = raw;
22675
22591
  const attrs = buildSegmentAttrs(seg);
22676
- const hasAttrs = Object.keys(attrs).length > 0;
22677
- if (seg.isParagraphBreak === true || seg.isLineBreak === true) {
22678
- ytext.insert(offset, '\n', hasAttrs ? attrs : undefined);
22679
- offset += 1;
22680
- }
22681
- else if (typeof seg.text === 'string' && seg.text.length > 0) {
22682
- ytext.insert(offset, seg.text, hasAttrs ? attrs : undefined);
22683
- offset += seg.text.length;
22592
+ const text = segmentInsertText(seg);
22593
+ // Always pass an explicit attrs object: Yjs makes attribute-less inserts
22594
+ // inherit the preceding character's formatting, which bled styles and
22595
+ // paragraph-break markers into unstyled runs.
22596
+ ytext.insert(offset, text, attrs);
22597
+ offset += text.length;
22598
+ }
22599
+ }
22600
+ /**
22601
+ * Pure simulation of the delta a Y.Text produces after `encodeTextBody`:
22602
+ * adjacent runs with identical attribute maps merge into one op, matching
22603
+ * Yjs run-merging. Used to compare desired segments against a live Y.Text
22604
+ * without instantiating one.
22605
+ */
22606
+ function encodeSegmentsToDelta(segments) {
22607
+ const ops = [];
22608
+ for (const raw of segments) {
22609
+ const seg = raw;
22610
+ const attrs = buildSegmentAttrs(seg);
22611
+ const attributes = Object.keys(attrs).length > 0 ? attrs : undefined;
22612
+ const text = segmentInsertText(seg);
22613
+ const prev = ops[ops.length - 1];
22614
+ if (prev &&
22615
+ typeof prev.insert === 'string' &&
22616
+ JSON.stringify(prev.attributes ?? null) === JSON.stringify(attributes ?? null)) {
22617
+ prev.insert += text;
22684
22618
  }
22685
22619
  else {
22686
- // Empty non-break run: use zero-width space to hold attributes
22687
- ytext.insert(offset, '​', hasAttrs ? attrs : undefined);
22688
- offset += 1;
22620
+ ops.push(attributes ? { insert: text, attributes } : { insert: text });
22689
22621
  }
22690
22622
  }
22623
+ return ops;
22691
22624
  }
22692
- function decodeTextBody(ytext) {
22693
- const delta = ytext.toDelta();
22625
+ function decodeDelta(delta) {
22694
22626
  const segments = [];
22695
22627
  for (const op of delta) {
22696
22628
  if (typeof op.insert !== 'string' || op.insert === '') {
@@ -22791,6 +22723,128 @@ function decodeTextBody(ytext) {
22791
22723
  }
22792
22724
  return segments;
22793
22725
  }
22726
+ function decodeTextBody(ytext) {
22727
+ return decodeDelta(ytext.toDelta());
22728
+ }
22729
+
22730
+ /**
22731
+ * collaboration-sync.ts: Framework-agnostic CRDT sync utilities for the
22732
+ * pptx-viewer collaboration stack (Yjs backend).
22733
+ *
22734
+ * Exports:
22735
+ * - Structural Yjs interfaces (no hard yjs import - bindings pass live instances)
22736
+ * - YjsFactories: factory interface bindings implement using `new Y.Map()` etc.
22737
+ * - writeElementToYMap / readElementFromYMap: PptxElement <-> YMapLike
22738
+ * - writeSlideToYMap / readSlideFromYMap: PptxSlide <-> YMapLike
22739
+ * - writeSlidesToYDoc / readSlidesFromYDoc: PptxSlide[] <-> Y.Doc
22740
+ * - observeYDocSlides: register a change listener on the pptx:slides array
22741
+ * - re-exports of the text codec (collaboration-text-codec.ts)
22742
+ *
22743
+ * Y.Doc schema:
22744
+ * pptx:slides - Y.Array of slide Y.Maps
22745
+ * Each slide Y.Map has scalar keys + `_`-prefixed JSON blobs + `elements`
22746
+ * Each element Y.Map has scalar keys + `_`-prefixed JSON blobs + `textBody`
22747
+ * textBody is a Y.Text with one delta-op per TextSegment
22748
+ *
22749
+ * NOTE: packages/tools' pptx-codec.ts implements a similar schema but with
22750
+ * different complex-field key prefixes (e.g. `_textStyle` vs `_ts` here); the
22751
+ * two doc layouts are NOT interchangeable on the same Y.Doc.
22752
+ *
22753
+ * Prefer `reconcileSlidesInYDoc` (collaboration-reconcile.ts) over
22754
+ * `writeSlidesToYDoc` for live editing: it updates only what changed instead
22755
+ * of replacing the whole slides array, so concurrent edits merge per
22756
+ * slide/element/field rather than colliding at document granularity.
22757
+ */
22758
+ // ---------------------------------------------------------------------------
22759
+ // Y.Doc schema constants
22760
+ // ---------------------------------------------------------------------------
22761
+ const YDOC_SLIDES_KEY = 'pptx:slides';
22762
+ const YDOC_META_KEY = 'pptx:meta';
22763
+ const SCALAR_ELEMENT_KEYS = new Set([
22764
+ 'id',
22765
+ 'type',
22766
+ 'x',
22767
+ 'y',
22768
+ 'width',
22769
+ 'height',
22770
+ 'rotation',
22771
+ 'flipHorizontal',
22772
+ 'flipVertical',
22773
+ 'hidden',
22774
+ 'opacity',
22775
+ 'text',
22776
+ 'name',
22777
+ 'altText',
22778
+ 'shapeType',
22779
+ 'placeholder',
22780
+ 'imagePath',
22781
+ 'imageData',
22782
+ 'svgContent',
22783
+ 'inkSvg',
22784
+ 'sourceSlideId',
22785
+ 'mediaType',
22786
+ 'mediaPath',
22787
+ 'linkedTxbxId',
22788
+ 'linkedTxbxSeq',
22789
+ 'promptText',
22790
+ ]);
22791
+ const COMPLEX_ELEMENT_FIELDS = {
22792
+ textStyle: '_ts',
22793
+ shapeStyle: '_ss',
22794
+ shapeAdjustments: '_sa',
22795
+ adjustmentHandles: '_ah',
22796
+ tableData: '_td',
22797
+ chartData: '_cd',
22798
+ smartArtData: '_smad',
22799
+ connectionStart: '_cs',
22800
+ connectionEnd: '_ce',
22801
+ animations: '_an',
22802
+ nativeAnimations: '_na',
22803
+ children: '_ch',
22804
+ paragraphIndents: '_pi',
22805
+ rawXml: '_rx',
22806
+ actionClick: '_ac',
22807
+ actionHover: '_av',
22808
+ locks: '_lk',
22809
+ imageEffects: '_ie',
22810
+ cropShape: '_cr',
22811
+ mediaBookmarks: '_mb',
22812
+ captionTracks: '_ct',
22813
+ };
22814
+ const REV_COMPLEX_ELEMENT = Object.fromEntries(Object.entries(COMPLEX_ELEMENT_FIELDS).map(([k, v]) => [v, k]));
22815
+ const SCALAR_SLIDE_KEYS = new Set([
22816
+ 'id',
22817
+ 'rId',
22818
+ 'sourceSlideId',
22819
+ 'layoutPath',
22820
+ 'layoutName',
22821
+ 'slideNumber',
22822
+ 'hidden',
22823
+ 'sectionName',
22824
+ 'sectionId',
22825
+ 'backgroundColor',
22826
+ 'backgroundImage',
22827
+ 'backgroundGradient',
22828
+ 'notes',
22829
+ 'backgroundShowAnimation',
22830
+ 'showMasterShapes',
22831
+ 'isDirty',
22832
+ ]);
22833
+ const COMPLEX_SLIDE_FIELDS = {
22834
+ transition: '_tr',
22835
+ animations: '_an',
22836
+ nativeAnimations: '_na',
22837
+ rawTiming: '_rt',
22838
+ notesSegments: '_ns',
22839
+ comments: '_cm',
22840
+ warnings: '_wa',
22841
+ rawXml: '_rx',
22842
+ clrMapOverride: '_cm2',
22843
+ guides: '_gu',
22844
+ customerData: '_cu',
22845
+ activeXControls: '_ax',
22846
+ };
22847
+ const REV_COMPLEX_SLIDE = Object.fromEntries(Object.entries(COMPLEX_SLIDE_FIELDS).map(([k, v]) => [v, k]));
22794
22848
  // ---------------------------------------------------------------------------
22795
22849
  // Element serialization
22796
22850
  // ---------------------------------------------------------------------------
@@ -22815,11 +22869,6 @@ function writeElementToYMap(element, ymap, factories) {
22815
22869
  }
22816
22870
  }
22817
22871
  }
22818
- function isYTextLike(value) {
22819
- return (typeof value === 'object' &&
22820
- value !== null &&
22821
- typeof value.toDelta === 'function');
22822
- }
22823
22872
  function readElementFromYMap(ymap) {
22824
22873
  const element = {};
22825
22874
  ymap.forEach((value, key) => {
@@ -22897,6 +22946,11 @@ function readSlideFromYMap(ymap) {
22897
22946
  // ---------------------------------------------------------------------------
22898
22947
  // Y.Doc-level helpers
22899
22948
  // ---------------------------------------------------------------------------
22949
+ /**
22950
+ * Replace the full slides array in the Y.Doc. Coarse: prefer
22951
+ * `reconcileSlidesInYDoc` for live editing; this remains suitable for
22952
+ * one-shot seeding of an empty document.
22953
+ */
22900
22954
  function writeSlidesToYDoc(slides, ydoc, factories, origin) {
22901
22955
  ydoc.transact(() => {
22902
22956
  const arr = ydoc.getArray(YDOC_SLIDES_KEY);
@@ -22918,12 +22972,208 @@ function readSlidesFromYDoc(ydoc) {
22918
22972
  }
22919
22973
  return slides;
22920
22974
  }
22975
+ /**
22976
+ * Observe (deeply) the pptx:slides array. The handler receives the Yjs
22977
+ * events plus the transaction, so callers can skip their own writes by
22978
+ * checking `transaction.origin` (see LOCAL_SYNC_ORIGIN in
22979
+ * collaboration-reconcile.ts).
22980
+ */
22921
22981
  function observeYDocSlides(ydoc, onChange) {
22922
22982
  const arr = ydoc.getArray(YDOC_SLIDES_KEY);
22923
22983
  arr.observeDeep(onChange);
22924
22984
  return () => arr.unobserveDeep(onChange);
22925
22985
  }
22926
22986
 
22987
+ /**
22988
+ * collaboration-reconcile.ts: Granular Y.Doc reconciliation for collaborative
22989
+ * editing.
22990
+ *
22991
+ * `writeSlidesToYDoc` replaces the entire pptx:slides array on every write,
22992
+ * which makes concurrent edits collide at document granularity (last writer
22993
+ * wins for the whole deck). `reconcileSlidesInYDoc` instead diffs the desired
22994
+ * slide state against the live Y.Doc and only mutates what changed:
22995
+ *
22996
+ * - slides and elements are matched by `id`; unchanged ones keep their Y.Map
22997
+ * instance so concurrent field edits merge via Yjs
22998
+ * - scalar / complex fields are compared and only set when different
22999
+ * - textBody is only replaced when its canonical decoded form differs
23000
+ * - removed items are deleted, new ones inserted at their position; moves
23001
+ * are delete+reinsert (Yjs has no move primitive)
23002
+ *
23003
+ * All mutations run in a single transaction tagged with LOCAL_SYNC_ORIGIN (or
23004
+ * a caller-supplied origin) so observers can ignore their own writes.
23005
+ */
23006
+ /** Transaction origin used for local reconcile writes. */
23007
+ const LOCAL_SYNC_ORIGIN = 'pptx-viewer:local-sync';
23008
+ function jsonEqual(a, b) {
23009
+ return a === b || JSON.stringify(a) === JSON.stringify(b);
23010
+ }
23011
+ function reconcileScalars(ymap, rec, keys) {
23012
+ for (const key of keys) {
23013
+ const next = rec[key];
23014
+ const current = ymap.get(key);
23015
+ if (next === undefined) {
23016
+ if (current !== undefined) {
23017
+ ymap.delete(key);
23018
+ }
23019
+ }
23020
+ else if (!jsonEqual(current, next)) {
23021
+ ymap.set(key, next);
23022
+ }
23023
+ }
23024
+ }
23025
+ function reconcileComplexFields(ymap, rec, fields) {
23026
+ for (const [original, prefixed] of Object.entries(fields)) {
23027
+ const next = rec[original] === undefined ? undefined : JSON.stringify(rec[original]);
23028
+ const current = ymap.get(prefixed);
23029
+ if (next === undefined) {
23030
+ if (current !== undefined) {
23031
+ ymap.delete(prefixed);
23032
+ }
23033
+ }
23034
+ else if (current !== next) {
23035
+ ymap.set(prefixed, next);
23036
+ }
23037
+ }
23038
+ }
23039
+ function reconcileTextBody(ymap, rec, factories) {
23040
+ const segments = rec.textSegments;
23041
+ const current = ymap.get('textBody');
23042
+ if (!Array.isArray(segments)) {
23043
+ if (current !== undefined) {
23044
+ ymap.delete('textBody');
23045
+ }
23046
+ return;
23047
+ }
23048
+ const desired = decodeDelta(encodeSegmentsToDelta(segments));
23049
+ const existing = isYTextLike(current) ? decodeDelta(current.toDelta()) : undefined;
23050
+ if (existing !== undefined && jsonEqual(existing, desired)) {
23051
+ return;
23052
+ }
23053
+ const ytext = factories.createText();
23054
+ encodeTextBody(segments, ytext);
23055
+ ymap.set('textBody', ytext);
23056
+ }
23057
+ function reconcileElementYMap(ymap, element, factories) {
23058
+ const rec = element;
23059
+ reconcileScalars(ymap, rec, SCALAR_ELEMENT_KEYS);
23060
+ reconcileComplexFields(ymap, rec, COMPLEX_ELEMENT_FIELDS);
23061
+ reconcileTextBody(ymap, rec, factories);
23062
+ }
23063
+ function mapIdAt(arr, index) {
23064
+ const entry = arr.get(index);
23065
+ if (!entry || typeof entry.get !== 'function') {
23066
+ return undefined;
23067
+ }
23068
+ const id = entry.get('id');
23069
+ return typeof id === 'string' ? id : undefined;
23070
+ }
23071
+ /**
23072
+ * Reconcile a Y.Array of Y.Maps against a desired item list, matching by id.
23073
+ * Items without a string id fall back to positional matching.
23074
+ */
23075
+ function reconcileYArrayById(arr, items, adapter) {
23076
+ const desiredIds = new Set();
23077
+ for (const item of items) {
23078
+ const id = adapter.idOf(item);
23079
+ if (typeof id === 'string') {
23080
+ desiredIds.add(id);
23081
+ }
23082
+ }
23083
+ // Pass 1: delete maps whose id is gone (or duplicated); keep the last dup.
23084
+ const seen = new Set();
23085
+ for (let i = arr.length - 1; i >= 0; i--) {
23086
+ const id = mapIdAt(arr, i);
23087
+ if (id === undefined) {
23088
+ continue;
23089
+ }
23090
+ if (!desiredIds.has(id) || seen.has(id)) {
23091
+ arr.delete(i, 1);
23092
+ }
23093
+ else {
23094
+ seen.add(id);
23095
+ }
23096
+ }
23097
+ // Pass 2: walk desired order; update in place, move, or insert.
23098
+ for (let pos = 0; pos < items.length; pos++) {
23099
+ const item = items[pos];
23100
+ const id = adapter.idOf(item);
23101
+ const idAtPos = pos < arr.length ? mapIdAt(arr, pos) : undefined;
23102
+ if (id === undefined) {
23103
+ // Positional fallback for id-less items.
23104
+ if (pos < arr.length && idAtPos === undefined) {
23105
+ adapter.update(arr.get(pos), item);
23106
+ }
23107
+ else {
23108
+ arr.insert(pos, [adapter.create(item)]);
23109
+ }
23110
+ continue;
23111
+ }
23112
+ if (idAtPos === id) {
23113
+ adapter.update(arr.get(pos), item);
23114
+ continue;
23115
+ }
23116
+ let foundAt = -1;
23117
+ for (let j = pos + 1; j < arr.length; j++) {
23118
+ if (mapIdAt(arr, j) === id) {
23119
+ foundAt = j;
23120
+ break;
23121
+ }
23122
+ }
23123
+ if (foundAt >= 0) {
23124
+ // Move: Yjs cannot re-insert an integrated type, so rebuild at pos.
23125
+ arr.delete(foundAt, 1);
23126
+ }
23127
+ arr.insert(pos, [adapter.create(item)]);
23128
+ }
23129
+ // Pass 3: trim trailing leftovers.
23130
+ if (arr.length > items.length) {
23131
+ arr.delete(items.length, arr.length - items.length);
23132
+ }
23133
+ }
23134
+ const isYArrayLike = (value) => typeof value === 'object' &&
23135
+ value !== null &&
23136
+ typeof value.insert === 'function' &&
23137
+ typeof value.toArray === 'function';
23138
+ function reconcileSlideYMap(ymap, slide, factories) {
23139
+ const rec = slide;
23140
+ reconcileScalars(ymap, rec, SCALAR_SLIDE_KEYS);
23141
+ reconcileComplexFields(ymap, rec, COMPLEX_SLIDE_FIELDS);
23142
+ let elements = ymap.get('elements');
23143
+ if (!isYArrayLike(elements)) {
23144
+ elements = factories.createArray();
23145
+ ymap.set('elements', elements);
23146
+ }
23147
+ reconcileYArrayById(elements, slide.elements, {
23148
+ idOf: (el) => (typeof el.id === 'string' ? el.id : undefined),
23149
+ create: (el) => {
23150
+ const map = factories.createMap();
23151
+ writeElementToYMap(el, map, factories);
23152
+ return map;
23153
+ },
23154
+ update: (map, el) => reconcileElementYMap(map, el, factories),
23155
+ });
23156
+ }
23157
+ /**
23158
+ * Granular local -> Y.Doc sync: mutate only what changed, inside one
23159
+ * transaction tagged with `origin` (default LOCAL_SYNC_ORIGIN) so the
23160
+ * caller's own observer can skip the resulting events.
23161
+ */
23162
+ function reconcileSlidesInYDoc(slides, ydoc, factories, origin = LOCAL_SYNC_ORIGIN) {
23163
+ ydoc.transact(() => {
23164
+ const arr = ydoc.getArray(YDOC_SLIDES_KEY);
23165
+ reconcileYArrayById(arr, slides, {
23166
+ idOf: (slide) => (typeof slide.id === 'string' ? slide.id : undefined),
23167
+ create: (slide) => {
23168
+ const map = factories.createMap();
23169
+ writeSlideToYMap(slide, map, factories);
23170
+ return map;
23171
+ },
23172
+ update: (map, slide) => reconcileSlideYMap(map, slide, factories),
23173
+ });
23174
+ }, origin);
23175
+ }
23176
+
22927
23177
  // ---------------------------------------------------------------------------
22928
23178
  // Tuning constants (px tolerances for position / size changes)
22929
23179
  // ---------------------------------------------------------------------------
@@ -28777,6 +29027,15 @@ function formatRelativeTime(ts) {
28777
29027
  */
28778
29028
  /** Default y-websocket server URL used when no default is supplied. */
28779
29029
  const DEFAULT_BROADCAST_SERVER_URL = 'ws://localhost:1234';
29030
+ /**
29031
+ * Resolve the transport implied by a server-URL form field: a blank server
29032
+ * URL selects the serverless y-webrtc transport (peers meet via WebRTC
29033
+ * signaling plus same-browser BroadcastChannel); anything else is a
29034
+ * y-websocket server URL.
29035
+ */
29036
+ function resolveTransportForServerUrl(serverUrl) {
29037
+ return serverUrl.trim().length === 0 ? 'webrtc' : 'websocket';
29038
+ }
28780
29039
  /** Generate a fresh, broadcast-scoped room id (`broadcast-<suffix>`). */
28781
29040
  function generateBroadcastRoomId() {
28782
29041
  const suffix = Math.random().toString(36).slice(2, 10);
@@ -28792,30 +29051,44 @@ function seedBroadcastFields(defaults) {
28792
29051
  serverUrl: defaults?.serverUrl ?? DEFAULT_BROADCAST_SERVER_URL,
28793
29052
  };
28794
29053
  }
28795
- /** Whether both required fields are non-blank (after trimming). */
29054
+ /**
29055
+ * Whether the form can start: the room id is required; the server URL may be
29056
+ * blank, which selects the serverless webrtc transport.
29057
+ */
28796
29058
  function canStartBroadcast(fields) {
28797
- return fields.roomId.trim().length > 0 && fields.serverUrl.trim().length > 0;
29059
+ return fields.roomId.trim().length > 0;
28798
29060
  }
28799
29061
  /**
28800
29062
  * Assemble a {@link BroadcastConfig} from the (trimmed) form fields, or `null`
28801
- * when incomplete.
29063
+ * when incomplete. A blank server URL yields `transport: 'webrtc'`.
28802
29064
  */
28803
29065
  function buildBroadcastConfig(fields) {
28804
29066
  if (!canStartBroadcast(fields)) {
28805
29067
  return null;
28806
29068
  }
28807
- return { roomId: fields.roomId.trim(), serverUrl: fields.serverUrl.trim() };
29069
+ const serverUrl = fields.serverUrl.trim();
29070
+ return {
29071
+ roomId: fields.roomId.trim(),
29072
+ serverUrl,
29073
+ transport: resolveTransportForServerUrl(serverUrl),
29074
+ };
28808
29075
  }
28809
29076
  /**
28810
29077
  * Build the shareable viewer follow-link for a broadcast. Returns just the
28811
29078
  * room id when no `origin`/`pathname` are available (non-browser environments).
29079
+ * A blank server URL produces a `transport=webrtc` link instead of a
29080
+ * `server=` parameter.
28812
29081
  */
28813
29082
  function buildBroadcastViewerUrl(roomId, serverUrl, location) {
28814
29083
  if (!location) {
28815
29084
  return roomId;
28816
29085
  }
28817
29086
  const room = encodeURIComponent(roomId);
28818
- const server = encodeURIComponent(serverUrl);
29087
+ const trimmed = serverUrl.trim();
29088
+ if (trimmed.length === 0) {
29089
+ return `${location.origin}${location.pathname}?broadcast=${room}&transport=webrtc`;
29090
+ }
29091
+ const server = encodeURIComponent(trimmed);
28819
29092
  return `${location.origin}${location.pathname}?broadcast=${room}&server=${server}`;
28820
29093
  }
28821
29094
  /** Whether the runtime exposes a usable async clipboard write API. */
@@ -34830,6 +35103,9 @@ class BroadcastDialogComponent {
34830
35103
  /** The shareable follow link (shown while `active`). */
34831
35104
  viewerUrl = input(/* @ts-ignore */
34832
35105
  ...(ngDevMode ? [undefined, { debugName: "viewerUrl" }] : /* istanbul ignore next */ []));
35106
+ /** Whether the active broadcast is peer-to-peer (serverless webrtc). */
35107
+ p2p = input(false, /* @ts-ignore */
35108
+ ...(ngDevMode ? [{ debugName: "p2p" }] : /* istanbul ignore next */ []));
34833
35109
  /** Fired when the presenter starts a broadcast. */
34834
35110
  start = output();
34835
35111
  /** Fired when the presenter stops the active broadcast. */
@@ -34844,6 +35120,9 @@ class BroadcastDialogComponent {
34844
35120
  ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
34845
35121
  canStart = computed(() => canStartBroadcast({ roomId: this.roomId(), serverUrl: this.serverUrl() }), /* @ts-ignore */
34846
35122
  ...(ngDevMode ? [{ debugName: "canStart" }] : /* istanbul ignore next */ []));
35123
+ /** Blank server URL selects the serverless peer-to-peer (webrtc) transport. */
35124
+ isP2p = computed(() => this.serverUrl().trim().length === 0, /* @ts-ignore */
35125
+ ...(ngDevMode ? [{ debugName: "isP2p" }] : /* istanbul ignore next */ []));
34847
35126
  broadcastingTitle = translate('pptx.broadcast.broadcastingTitle');
34848
35127
  startTitle = translate('pptx.broadcast.startTitle');
34849
35128
  dialogTitle = computed(() => this.active() ? this.broadcastingTitle() : this.startTitle(), /* @ts-ignore */
@@ -34893,7 +35172,7 @@ class BroadcastDialogComponent {
34893
35172
  });
34894
35173
  }
34895
35174
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: BroadcastDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
34896
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: BroadcastDialogComponent, isStandalone: true, selector: "pptx-broadcast-dialog", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, defaults: { classPropertyName: "defaults", publicName: "defaults", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, connected: { classPropertyName: "connected", publicName: "connected", isSignal: true, isRequired: false, transformFunction: null }, viewerCount: { classPropertyName: "viewerCount", publicName: "viewerCount", isSignal: true, isRequired: false, transformFunction: null }, viewerUrl: { classPropertyName: "viewerUrl", publicName: "viewerUrl", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { start: "start", stop: "stop", close: "close" }, ngImport: i0, template: `
35175
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: BroadcastDialogComponent, isStandalone: true, selector: "pptx-broadcast-dialog", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, defaults: { classPropertyName: "defaults", publicName: "defaults", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, connected: { classPropertyName: "connected", publicName: "connected", isSignal: true, isRequired: false, transformFunction: null }, viewerCount: { classPropertyName: "viewerCount", publicName: "viewerCount", isSignal: true, isRequired: false, transformFunction: null }, viewerUrl: { classPropertyName: "viewerUrl", publicName: "viewerUrl", isSignal: true, isRequired: false, transformFunction: null }, p2p: { classPropertyName: "p2p", publicName: "p2p", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { start: "start", stop: "stop", close: "close" }, ngImport: i0, template: `
34897
35176
  <pptx-modal-dialog [open]="open()" [title]="dialogTitle()" (close)="onClose()">
34898
35177
  @if (active()) {
34899
35178
  <!-- Active: share the follow link + stop control -->
@@ -34913,6 +35192,10 @@ class BroadcastDialogComponent {
34913
35192
  </span>
34914
35193
  </div>
34915
35194
 
35195
+ @if (p2p()) {
35196
+ <p class="pptx-ng-broadcast-hint">{{ 'pptx.broadcast.p2pServerValue' | translate }}</p>
35197
+ }
35198
+
34916
35199
  <p class="pptx-ng-broadcast-desc">
34917
35200
  {{ 'pptx.broadcast.liveDesc' | translate }}
34918
35201
  </p>
@@ -34979,6 +35262,9 @@ class BroadcastDialogComponent {
34979
35262
  [value]="serverUrl()"
34980
35263
  (input)="serverUrl.set(asValue($event))"
34981
35264
  />
35265
+ @if (isP2p()) {
35266
+ <p class="pptx-ng-broadcast-hint">{{ 'pptx.broadcast.p2pHint' | translate }}</p>
35267
+ }
34982
35268
  </div>
34983
35269
  </div>
34984
35270
  }
@@ -35023,6 +35309,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
35023
35309
  </span>
35024
35310
  </div>
35025
35311
 
35312
+ @if (p2p()) {
35313
+ <p class="pptx-ng-broadcast-hint">{{ 'pptx.broadcast.p2pServerValue' | translate }}</p>
35314
+ }
35315
+
35026
35316
  <p class="pptx-ng-broadcast-desc">
35027
35317
  {{ 'pptx.broadcast.liveDesc' | translate }}
35028
35318
  </p>
@@ -35089,6 +35379,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
35089
35379
  [value]="serverUrl()"
35090
35380
  (input)="serverUrl.set(asValue($event))"
35091
35381
  />
35382
+ @if (isP2p()) {
35383
+ <p class="pptx-ng-broadcast-hint">{{ 'pptx.broadcast.p2pHint' | translate }}</p>
35384
+ }
35092
35385
  </div>
35093
35386
  </div>
35094
35387
  }
@@ -35110,7 +35403,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
35110
35403
  </div>
35111
35404
  </pptx-modal-dialog>
35112
35405
  `, styles: [".pptx-ng-broadcast{display:flex;flex-direction:column;gap:1rem}.pptx-ng-broadcast-desc{margin:0;font-size:.8125rem;line-height:1.5;color:var(--pptx-muted-foreground, #9ca3af)}.pptx-ng-broadcast-field{display:flex;flex-direction:column;gap:.375rem}.pptx-ng-broadcast-label{font-size:.75rem;font-weight:500;color:var(--pptx-foreground, #f3f4f6)}.pptx-ng-broadcast-input{width:100%;padding:.375rem .75rem;border:1px solid var(--pptx-border, #374151);border-radius:.375rem;background:var(--pptx-background, #030712);color:var(--pptx-foreground, #f3f4f6);font-size:.8125rem}.pptx-ng-broadcast-input:focus{outline:none;border-color:var(--pptx-primary, #6366f1)}.pptx-ng-broadcast-link-row{display:flex;align-items:center;gap:.5rem}.pptx-ng-broadcast-hint{margin:0;font-size:.6875rem;color:var(--pptx-muted-foreground, #9ca3af)}.pptx-ng-broadcast-btn{padding:.375rem .75rem;border:1px solid var(--pptx-border, #374151);border-radius:.375rem;background:var(--pptx-card, #111827);color:var(--pptx-foreground, #f3f4f6);font-size:.75rem;cursor:pointer;white-space:nowrap;transition:background .15s ease}.pptx-ng-broadcast-btn:hover:not(:disabled){background:var(--pptx-border, #374151)}.pptx-ng-broadcast-btn:disabled{opacity:.4;cursor:not-allowed}.pptx-ng-broadcast-btn-primary{border-color:var(--pptx-primary, #6366f1);background:var(--pptx-primary, #6366f1);color:#fff}.pptx-ng-broadcast-btn-primary:hover:not(:disabled){background:var(--pptx-primary, #6366f1);filter:brightness(1.1)}.pptx-ng-broadcast-stop{width:100%;padding:.5rem .75rem;border:1px solid rgba(239,68,68,.3);border-radius:.375rem;background:#ef44441a;color:#f87171;font-size:.75rem;font-weight:500;cursor:pointer;transition:background .15s ease}.pptx-ng-broadcast-stop:hover{background:#ef444433}.pptx-ng-broadcast-status-row{display:flex;align-items:center;gap:.5rem;font-size:.8125rem}.pptx-ng-broadcast-status-dot{width:.5rem;height:.5rem;border-radius:9999px;background:var(--pptx-muted-foreground, #9ca3af)}.pptx-ng-broadcast-status-dot.is-on{background:#22c55e}.pptx-ng-broadcast-status-text{font-weight:500;color:var(--pptx-foreground, #f3f4f6)}.pptx-ng-broadcast-count{margin-left:auto;color:var(--pptx-muted-foreground, #9ca3af)}\n"] }]
35113
- }], ctorParameters: () => [], propDecorators: { open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], defaults: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaults", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], connected: [{ type: i0.Input, args: [{ isSignal: true, alias: "connected", required: false }] }], viewerCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewerCount", required: false }] }], viewerUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewerUrl", required: false }] }], start: [{ type: i0.Output, args: ["start"] }], stop: [{ type: i0.Output, args: ["stop"] }], close: [{ type: i0.Output, args: ["close"] }] } });
35406
+ }], ctorParameters: () => [], propDecorators: { open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], defaults: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaults", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], connected: [{ type: i0.Input, args: [{ isSignal: true, alias: "connected", required: false }] }], viewerCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewerCount", required: false }] }], viewerUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewerUrl", required: false }] }], p2p: [{ type: i0.Input, args: [{ isSignal: true, alias: "p2p", required: false }] }], start: [{ type: i0.Output, args: ["start"] }], stop: [{ type: i0.Output, args: ["stop"] }], close: [{ type: i0.Output, args: ["close"] }] } });
35114
35407
 
35115
35408
  /**
35116
35409
  * Thin re-export shim → vendored `pptx-viewer-shared`
@@ -35246,6 +35539,107 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
35246
35539
  `, styles: [":host{position:absolute;inset:0;pointer-events:none;overflow:visible;z-index:9999}.pptx-ng-collab-cursor{position:absolute;top:0;left:0;pointer-events:none;will-change:transform;transition:transform 90ms linear}.pptx-ng-collab-pointer{display:block;filter:drop-shadow(0 1px 1px rgba(0,0,0,.35))}.pptx-ng-collab-label{position:absolute;top:16px;left:12px;max-width:150px;padding:2px 6px;border-radius:4px;color:#fff;font-family:system-ui,sans-serif;font-size:10px;font-weight:500;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 1px 2px #0000004d}\n"] }]
35247
35540
  }], propDecorators: { cursors: [{ type: i0.Input, args: [{ isSignal: true, alias: "cursors", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }] } });
35248
35541
 
35542
+ /**
35543
+ * collaboration-local-presence.ts: publishes the local user's presence into a
35544
+ * single awareness `presence` field.
35545
+ *
35546
+ * Cursor, selection, and active-slide updates all merge into one record so a
35547
+ * later cursor move never clobbers the selection (or vice versa). The remote
35548
+ * side reads this shape via the shared `derivePresenceList`.
35549
+ */
35550
+ class LocalPresencePublisher {
35551
+ awareness;
35552
+ identity;
35553
+ activeSlide = 0;
35554
+ cursor = { x: 0, y: 0 };
35555
+ selection;
35556
+ constructor(awareness, identity) {
35557
+ this.awareness = awareness;
35558
+ this.identity = identity;
35559
+ }
35560
+ /** Re-emit the merged presence record (also used as a heartbeat). */
35561
+ publish() {
35562
+ this.awareness.setLocalStateField('presence', {
35563
+ userName: this.identity.userName,
35564
+ userColor: this.identity.userColor,
35565
+ userAvatar: this.identity.userAvatar,
35566
+ role: this.identity.role,
35567
+ activeSlideIndex: this.activeSlide,
35568
+ cursorX: this.cursor.x,
35569
+ cursorY: this.cursor.y,
35570
+ selectedElementId: this.selection,
35571
+ lastUpdated: new Date().toISOString(),
35572
+ });
35573
+ }
35574
+ setCursor(x, y, activeSlideIndex = this.activeSlide) {
35575
+ this.cursor = { x, y };
35576
+ this.activeSlide = activeSlideIndex;
35577
+ // A bare `cursor` field mirrors the flat awareness shape some peers read.
35578
+ this.awareness.setLocalStateField('cursor', { x, y });
35579
+ this.publish();
35580
+ }
35581
+ setSelection(selectedElementId, activeSlideIndex = this.activeSlide) {
35582
+ this.selection = selectedElementId;
35583
+ this.activeSlide = activeSlideIndex;
35584
+ this.publish();
35585
+ }
35586
+ setActiveSlide(index) {
35587
+ this.activeSlide = Math.max(0, Math.floor(index));
35588
+ this.publish();
35589
+ }
35590
+ }
35591
+
35592
+ /**
35593
+ * collaboration-providers.ts: transport factories for the Angular
35594
+ * collaboration service.
35595
+ *
35596
+ * Isolates the dynamic `yjs` / `y-websocket` / `y-webrtc` imports and provider
35597
+ * construction so the service focuses on lifecycle + reactive state. The Yjs
35598
+ * packages are dynamically imported so they are fully tree-shaken when
35599
+ * collaboration is unused; each specifier is read from a variable so bundlers
35600
+ * do not eagerly follow it (mirrors the historical `/* @vite-ignore *\/`
35601
+ * pattern).
35602
+ */
35603
+ async function createDoc() {
35604
+ const yModule = 'yjs';
35605
+ const Y = (await import(/* @vite-ignore */ yModule));
35606
+ const doc = new Y.Doc();
35607
+ const factories = {
35608
+ createMap: () => new Y.Map(),
35609
+ createArray: () => new Y.Array(),
35610
+ createText: () => new Y.Text(),
35611
+ };
35612
+ return { doc, factories, Y };
35613
+ }
35614
+ /** Create a y-websocket transport bundle for `config`. */
35615
+ async function createWebsocketBundle(config) {
35616
+ const wsSpecifier = 'y-websocket';
35617
+ const [{ doc, factories }, yws] = await Promise.all([
35618
+ createDoc(),
35619
+ import(/* @vite-ignore */ wsSpecifier),
35620
+ ]);
35621
+ const provider = new yws.WebsocketProvider(config.serverUrl, config.roomId, doc, config.authToken ? { params: { token: config.authToken } } : undefined);
35622
+ return { doc, provider, awareness: provider.awareness, factories };
35623
+ }
35624
+ /**
35625
+ * Create a y-webrtc (peer-to-peer, serverless) transport bundle for `config`.
35626
+ * Peers rendezvous through the configured `signaling` servers (or y-webrtc's
35627
+ * public default) and same-browser tabs additionally sync via BroadcastChannel.
35628
+ * `authToken` is passed as the room `password`.
35629
+ */
35630
+ async function createWebrtcBundle(config) {
35631
+ const rtcSpecifier = 'y-webrtc';
35632
+ const [{ doc, factories }, yrtc] = await Promise.all([
35633
+ createDoc(),
35634
+ import(/* @vite-ignore */ rtcSpecifier),
35635
+ ]);
35636
+ const provider = new yrtc.WebrtcProvider(config.roomId, doc, {
35637
+ signaling: config.signaling?.length ? config.signaling : undefined,
35638
+ password: config.authToken || undefined,
35639
+ });
35640
+ return { doc, provider, awareness: provider.awareness, factories };
35641
+ }
35642
+
35249
35643
  /**
35250
35644
  * Split inherited template (master/layout) elements out of each loaded slide.
35251
35645
  *
@@ -35309,31 +35703,110 @@ function showsTemplateAffordance(element, editTemplateMode) {
35309
35703
  }
35310
35704
 
35311
35705
  /**
35312
- * CollaborationService: Angular port of the Vue `useCollaboration` composable.
35706
+ * collaboration-writeback.ts: elected-writer serialization helper for the
35707
+ * Angular collaboration service.
35313
35708
  *
35314
- * Changes from the original:
35315
- * - Slide sync uses the granular `pptx:slides` Y.Array (structural CRDT) via
35316
- * the shared `writeSlidesToYDoc` / `readSlidesFromYDoc` / `observeYDocSlides`
35317
- * helpers, replacing the monolithic JSON blob in 'presentation'.
35318
- * - Elected-writer write-back: when role === 'owner', changes are debounced
35319
- * and `config.onWriteBack` receives serialized PPTX bytes for persistence.
35709
+ * When the local user is the session `owner`, the service debounces Y.Doc
35710
+ * changes and calls this to serialize the live document (templates re-merged)
35711
+ * into `.pptx` bytes for `config.onWriteBack`. Kept out of the service so the
35712
+ * service stays within the repo's per-file size budget.
35713
+ */
35714
+ const WRITE_BACK_DEBOUNCE_MS = 5_000;
35715
+ /**
35716
+ * Serialize the current Y.Doc slide state (with the separated master/layout
35717
+ * template elements merged back) into `.pptx` bytes. `sourceBytes` seeds the
35718
+ * handler so package parts the CRDT does not carry (media, fonts, rels) survive.
35719
+ */
35720
+ async function serializeWriteBack(ydoc, sourceBytes, templateElements) {
35721
+ const { PptxHandler } = await import('pptx-viewer-core');
35722
+ const handler = new PptxHandler();
35723
+ await handler.load(sourceBytes.buffer);
35724
+ const slides = buildSaveSlides(readSlidesFromYDoc(ydoc), templateElements);
35725
+ return handler.save(slides);
35726
+ }
35727
+ /**
35728
+ * Debounced elected-writer write-back. Only the session `owner` with an
35729
+ * `onWriteBack` callback schedules anything; each new change resets the timer,
35730
+ * and on fire it serializes the live Y.Doc and hands the bytes to the host.
35731
+ */
35732
+ class WriteBackScheduler {
35733
+ timer = null;
35734
+ schedule(config, ydoc, getSourceBytes, getTemplateElements) {
35735
+ if (!config?.onWriteBack || config.role !== 'owner' || !ydoc) {
35736
+ return;
35737
+ }
35738
+ this.cancel();
35739
+ const ms = config.writeBackDebounceMs ?? WRITE_BACK_DEBOUNCE_MS;
35740
+ this.timer = setTimeout(() => {
35741
+ this.timer = null;
35742
+ const bytes = getSourceBytes?.();
35743
+ if (!bytes || !config.onWriteBack) {
35744
+ return;
35745
+ }
35746
+ void serializeWriteBack(ydoc, bytes, getTemplateElements?.() ?? {})
35747
+ .then((out) => config.onWriteBack?.(out))
35748
+ .catch(() => {
35749
+ /* non-fatal */
35750
+ });
35751
+ }, ms);
35752
+ }
35753
+ cancel() {
35754
+ if (this.timer !== null) {
35755
+ clearTimeout(this.timer);
35756
+ this.timer = null;
35757
+ }
35758
+ }
35759
+ }
35760
+
35761
+ /**
35762
+ * CollaborationService: Angular real-time collaboration (Yjs) service.
35763
+ *
35764
+ * Owns the provider lifecycle and the reactive collaboration state consumed by
35765
+ * the viewer component:
35766
+ * - Transport is `y-websocket` (default) or serverless `y-webrtc`
35767
+ * (`config.transport === 'webrtc'`), created via `./collaboration-providers`.
35768
+ * - Local edits sync via the granular `reconcileSlidesInYDoc` (only changed
35769
+ * slides/elements/fields mutate) in a transaction tagged `LOCAL_SYNC_ORIGIN`;
35770
+ * the remote observer skips its own local-origin writes.
35771
+ * - Websocket connections fail fast on mixed content and time out to `'error'`
35772
+ * after `CONNECTION_TIMEOUT_MS`; `retry()` reconnects with the last config.
35773
+ * - Elected-writer write-back (role === 'owner') via `WriteBackScheduler`.
35320
35774
  *
35321
35775
  * Provide at the component level: `@Component({ providers: [CollaborationService] })`.
35322
35776
  */
35323
35777
  const DEFAULT_CANVAS_BOUND = 100_000;
35324
- const WRITE_BACK_DEBOUNCE_MS = 5_000;
35325
35778
  class CollaborationService {
35326
35779
  // Reactive state
35327
- connected = signal(false, /* @ts-ignore */
35780
+ status = signal('disconnected', /* @ts-ignore */
35781
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
35782
+ connected = computed(() => this.status() === 'connected', /* @ts-ignore */
35328
35783
  ...(ngDevMode ? [{ debugName: "connected" }] : /* istanbul ignore next */ []));
35329
35784
  active = signal(false, /* @ts-ignore */
35330
35785
  ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
35786
+ /** Role of the local user in the active session, or undefined when idle. */
35787
+ activeRole = signal(undefined, /* @ts-ignore */
35788
+ ...(ngDevMode ? [{ debugName: "activeRole" }] : /* istanbul ignore next */ []));
35331
35789
  presence = signal([], /* @ts-ignore */
35332
35790
  ...(ngDevMode ? [{ debugName: "presence" }] : /* istanbul ignore next */ []));
35333
35791
  cursors = computed(() => presenceToCursors(this.presence()), /* @ts-ignore */
35334
35792
  ...(ngDevMode ? [{ debugName: "cursors" }] : /* istanbul ignore next */ []));
35335
35793
  connectedCount = computed(() => this.presence().length + (this.active() ? 1 : 0), /* @ts-ignore */
35336
35794
  ...(ngDevMode ? [{ debugName: "connectedCount" }] : /* istanbul ignore next */ []));
35795
+ /** The client id the local user is currently following (null when free). */
35796
+ followedClientId = signal(null, /* @ts-ignore */
35797
+ ...(ngDevMode ? [{ debugName: "followedClientId" }] : /* istanbul ignore next */ []));
35798
+ /** Active-slide index of the followed peer, or null when not following. */
35799
+ followedSlideIndex = computed(() => {
35800
+ const id = this.followedClientId();
35801
+ if (id === null) {
35802
+ return null;
35803
+ }
35804
+ return this.presence().find((p) => p.clientId === id)?.activeSlideIndex ?? null;
35805
+ }, /* @ts-ignore */
35806
+ ...(ngDevMode ? [{ debugName: "followedSlideIndex" }] : /* istanbul ignore next */ []));
35807
+ /** Active-slide index of the first `owner` peer (the broadcaster), or null. */
35808
+ broadcasterSlideIndex = computed(() => this.presence().find((p) => p.role === 'owner')?.activeSlideIndex ?? null, /* @ts-ignore */
35809
+ ...(ngDevMode ? [{ debugName: "broadcasterSlideIndex" }] : /* istanbul ignore next */ []));
35337
35810
  // Internal handles
35338
35811
  ydoc = null;
35339
35812
  provider = null;
@@ -35342,18 +35815,18 @@ class CollaborationService {
35342
35815
  applyingRemote = false;
35343
35816
  yFactories = null;
35344
35817
  lastSynced = '';
35345
- writeBackTimer = null;
35818
+ connectTimer = null;
35346
35819
  unobserveSlides = null;
35820
+ writeBack = new WriteBackScheduler();
35347
35821
  onRemoteSlides = null;
35348
35822
  canvasWidth = DEFAULT_CANVAS_BOUND;
35349
35823
  canvasHeight = DEFAULT_CANVAS_BOUND;
35350
35824
  getSourceBytes = null;
35351
35825
  getTemplateElements = null;
35352
- userName = 'Anonymous';
35353
- userColor = DEFAULT_CURSOR_COLOR;
35354
- userAvatar;
35355
- role;
35356
35826
  currentConfig = null;
35827
+ lastConfig = null;
35828
+ lastOptions = {};
35829
+ localPresence = null;
35357
35830
  refreshPresence = () => {
35358
35831
  if (!this.awareness) {
35359
35832
  this.presence.set([]);
@@ -35366,10 +35839,19 @@ class CollaborationService {
35366
35839
  }
35367
35840
  async connect(config, options = {}) {
35368
35841
  this.disconnect();
35842
+ this.lastConfig = config;
35843
+ this.lastOptions = options;
35369
35844
  try {
35370
35845
  validateRoomId(config.roomId);
35371
35846
  }
35372
35847
  catch {
35848
+ this.status.set('error');
35849
+ return;
35850
+ }
35851
+ // Fail fast on mixed content (websocket only): an https page cannot open a
35852
+ // ws:// socket, so surface the error rather than hanging until the timeout.
35853
+ if (config.transport !== 'webrtc' && isMixedContentBlocked(config.serverUrl)) {
35854
+ this.status.set('error');
35373
35855
  return;
35374
35856
  }
35375
35857
  this.onRemoteSlides = options.onRemoteSlides ?? null;
@@ -35377,60 +35859,104 @@ class CollaborationService {
35377
35859
  this.canvasHeight = options.canvasHeight ?? DEFAULT_CANVAS_BOUND;
35378
35860
  this.getSourceBytes = options.getSourceBytes ?? null;
35379
35861
  this.getTemplateElements = options.getTemplateElements ?? null;
35380
- this.userName = config.userName;
35381
- this.userColor = config.userColor ?? DEFAULT_CURSOR_COLOR;
35382
- this.userAvatar = config.userAvatar;
35383
- this.role = config.role;
35384
35862
  this.currentConfig = config;
35863
+ this.activeRole.set(config.role);
35864
+ this.status.set('connecting');
35385
35865
  try {
35386
- const wsModule = 'y-websocket';
35387
- const [Y, yws] = await Promise.all([
35388
- import('yjs'),
35389
- import(/* @vite-ignore */ wsModule),
35390
- ]);
35391
- const doc = new Y.Doc();
35392
- this.ydoc = doc;
35393
- this.yFactories = {
35394
- createMap: () => new Y.Map(),
35395
- createArray: () => new Y.Array(),
35396
- createText: () => new Y.Text(),
35397
- };
35398
- const wsProvider = new yws.WebsocketProvider(config.serverUrl, config.roomId, doc, config.authToken ? { params: { token: config.authToken } } : undefined);
35399
- this.provider = wsProvider;
35400
- this.awareness = wsProvider.awareness;
35866
+ const bundle = config.transport === 'webrtc'
35867
+ ? await createWebrtcBundle(config)
35868
+ : await createWebsocketBundle(config);
35869
+ this.ydoc = bundle.doc;
35870
+ this.yFactories = bundle.factories;
35871
+ this.provider = bundle.provider;
35872
+ this.awareness = bundle.awareness;
35401
35873
  this.selfId = this.awareness.clientID ?? -1;
35402
- this._publishAwareness();
35874
+ this.localPresence = new LocalPresencePublisher(this.awareness, {
35875
+ userName: config.userName,
35876
+ userColor: config.userColor ?? DEFAULT_CURSOR_COLOR,
35877
+ userAvatar: config.userAvatar,
35878
+ role: config.role,
35879
+ });
35880
+ this.localPresence.publish();
35403
35881
  this.awareness.on('change', this.refreshPresence);
35404
35882
  this.awareness.on('update', this.refreshPresence);
35405
- wsProvider.on('status', (payload) => {
35406
- this.connected.set(payload.status === 'connected');
35407
- });
35408
- // Observe remote slide changes
35409
- this.unobserveSlides = observeYDocSlides(doc, () => {
35410
- if (this.applyingRemote || !this.ydoc) {
35411
- return;
35412
- }
35413
- const remote = readSlidesFromYDoc(this.ydoc);
35414
- if (remote.length === 0) {
35415
- return;
35416
- }
35417
- this.applyingRemote = true;
35418
- this.onRemoteSlides?.(remote);
35419
- this.applyingRemote = false;
35420
- this._scheduleWriteBack();
35421
- });
35883
+ this.wireStatus(config);
35884
+ this.unobserveSlides = observeYDocSlides(this.ydoc, (_events, transaction) => this.onRemoteChange(transaction));
35422
35885
  this.active.set(true);
35423
35886
  this.refreshPresence();
35424
35887
  }
35425
35888
  catch {
35426
35889
  this.disconnect();
35890
+ this.status.set('error');
35427
35891
  }
35428
35892
  }
35429
- disconnect() {
35430
- if (this.writeBackTimer !== null) {
35431
- clearTimeout(this.writeBackTimer);
35432
- this.writeBackTimer = null;
35893
+ /** Reconnect using the configuration from the most recent {@link connect}. */
35894
+ async retry() {
35895
+ if (this.lastConfig) {
35896
+ await this.connect(this.lastConfig, this.lastOptions);
35897
+ }
35898
+ }
35899
+ /** Wire the provider status events + (websocket-only) connection timeout. */
35900
+ wireStatus(config) {
35901
+ const provider = this.provider;
35902
+ if (!provider) {
35903
+ return;
35904
+ }
35905
+ if (config.transport === 'webrtc') {
35906
+ // P2P: no server round-trip to wait on. Treat "created" as connected,
35907
+ // and reflect explicit disconnect events.
35908
+ this.status.set('connected');
35909
+ provider.on('status', (payload) => {
35910
+ if (payload.connected === false && this.active()) {
35911
+ this.status.set('disconnected');
35912
+ }
35913
+ else if (payload.connected === true) {
35914
+ this.status.set('connected');
35915
+ }
35916
+ });
35917
+ return;
35918
+ }
35919
+ provider.on('status', (payload) => {
35920
+ if (payload.status === 'connected') {
35921
+ this.clearConnectTimer();
35922
+ this.status.set('connected');
35923
+ }
35924
+ else if (payload.status === 'disconnected' && this.active()) {
35925
+ this.status.set('disconnected');
35926
+ }
35927
+ });
35928
+ if (provider.wsconnected) {
35929
+ this.status.set('connected');
35930
+ return;
35931
+ }
35932
+ this.connectTimer = setTimeout(() => {
35933
+ this.connectTimer = null;
35934
+ if (this.status() !== 'connected') {
35935
+ this.disconnect();
35936
+ this.status.set('error');
35937
+ }
35938
+ }, CONNECTION_TIMEOUT_MS);
35939
+ }
35940
+ /** Handle a remote Y.Doc change, skipping our own local-origin transactions. */
35941
+ onRemoteChange(transaction) {
35942
+ if (transaction?.origin === LOCAL_SYNC_ORIGIN || this.applyingRemote || !this.ydoc) {
35943
+ return;
35944
+ }
35945
+ const remote = readSlidesFromYDoc(this.ydoc);
35946
+ if (remote.length === 0) {
35947
+ return;
35433
35948
  }
35949
+ // Suppress the echo: record what we just applied so the subsequent local
35950
+ // broadcast (driven by the editor signal) is a no-op.
35951
+ this.lastSynced = JSON.stringify(remote);
35952
+ this.applyingRemote = true;
35953
+ this.onRemoteSlides?.(remote);
35954
+ this.applyingRemote = false;
35955
+ this.scheduleWriteBack();
35956
+ }
35957
+ disconnect() {
35958
+ this.clearConnectTimer();
35959
+ this.writeBack.cancel();
35434
35960
  this.unobserveSlides?.();
35435
35961
  this.unobserveSlides = null;
35436
35962
  this.awareness?.off?.('change', this.refreshPresence);
@@ -35441,19 +35967,36 @@ class CollaborationService {
35441
35967
  this.provider = null;
35442
35968
  this.ydoc = null;
35443
35969
  this.awareness = null;
35970
+ this.localPresence = null;
35444
35971
  this.selfId = -1;
35445
35972
  this.applyingRemote = false;
35446
35973
  this.yFactories = null;
35447
35974
  this.lastSynced = '';
35448
35975
  this.onRemoteSlides = null;
35449
35976
  this.currentConfig = null;
35450
- this.connected.set(false);
35977
+ this.status.set('disconnected');
35451
35978
  this.active.set(false);
35979
+ this.activeRole.set(undefined);
35452
35980
  this.presence.set([]);
35981
+ this.followedClientId.set(null);
35982
+ }
35983
+ /**
35984
+ * Broadcast the local slide set to peers, reconciling only what changed into
35985
+ * the pptx:slides Y.Array. An empty deck is never written (so a late-joiner
35986
+ * that has not yet received the doc cannot clobber it), and an unchanged deck
35987
+ * is skipped.
35988
+ */
35989
+ /**
35990
+ * Record the current local deck as the sync baseline so the first (unchanged)
35991
+ * broadcast after connecting is suppressed. Call right after {@link connect}
35992
+ * for a joiner whose local deck is a placeholder awaiting remote sync, so it
35993
+ * never overwrites the shared document before receiving it.
35994
+ */
35995
+ seedBaseline(slides) {
35996
+ this.lastSynced = JSON.stringify(slides);
35453
35997
  }
35454
- /** Broadcast the local slide set to peers via the pptx:slides Y.Array. */
35455
35998
  broadcastSlides(slides) {
35456
- if (!this.ydoc || !this.yFactories || this.applyingRemote) {
35999
+ if (!this.ydoc || !this.yFactories || this.applyingRemote || slides.length === 0) {
35457
36000
  return;
35458
36001
  }
35459
36002
  const s = JSON.stringify(slides);
@@ -35461,68 +36004,31 @@ class CollaborationService {
35461
36004
  return;
35462
36005
  }
35463
36006
  this.lastSynced = s;
35464
- writeSlidesToYDoc([...slides], this.ydoc, this.yFactories);
35465
- this._scheduleWriteBack();
36007
+ reconcileSlidesInYDoc([...slides], this.ydoc, this.yFactories, LOCAL_SYNC_ORIGIN);
36008
+ this.scheduleWriteBack();
35466
36009
  }
35467
- setCursor(x, y, activeSlideIndex = 0) {
35468
- if (!this.awareness) {
35469
- return;
35470
- }
35471
- this.awareness.setLocalStateField('cursor', { x, y });
35472
- this._publishAwareness(activeSlideIndex, x, y);
36010
+ setCursor(x, y, activeSlideIndex) {
36011
+ this.localPresence?.setCursor(x, y, activeSlideIndex);
35473
36012
  }
35474
- setSelection(selectedElementId, activeSlideIndex = 0) {
35475
- if (!this.awareness) {
35476
- return;
35477
- }
35478
- this._publishAwareness(activeSlideIndex, undefined, undefined, selectedElementId);
35479
- }
35480
- _publishAwareness(activeSlideIndex = 0, cursorX = 0, cursorY = 0, selectedElementId) {
35481
- this.awareness?.setLocalStateField('presence', {
35482
- userName: this.userName,
35483
- userColor: this.userColor,
35484
- userAvatar: this.userAvatar,
35485
- role: this.role,
35486
- activeSlideIndex,
35487
- cursorX,
35488
- cursorY,
35489
- selectedElementId,
35490
- lastUpdated: new Date().toISOString(),
35491
- });
35492
- this.awareness?.setLocalStateField('user', { name: this.userName, color: this.userColor });
36013
+ setSelection(selectedElementId, activeSlideIndex) {
36014
+ this.localPresence?.setSelection(selectedElementId, activeSlideIndex);
35493
36015
  }
35494
- _scheduleWriteBack() {
35495
- const config = this.currentConfig;
35496
- if (!config?.onWriteBack || config.role !== 'owner' || !this.ydoc) {
35497
- return;
35498
- }
35499
- if (this.writeBackTimer !== null) {
35500
- clearTimeout(this.writeBackTimer);
36016
+ /** Publish the local active-slide index (drives follow-along). */
36017
+ setActiveSlide(index) {
36018
+ this.localPresence?.setActiveSlide(index);
36019
+ }
36020
+ /** Follow the given peer's active slide, or `null` to stop following. */
36021
+ followUser(clientId) {
36022
+ this.followedClientId.set(clientId);
36023
+ }
36024
+ clearConnectTimer() {
36025
+ if (this.connectTimer !== null) {
36026
+ clearTimeout(this.connectTimer);
36027
+ this.connectTimer = null;
35501
36028
  }
35502
- const ms = config.writeBackDebounceMs ?? WRITE_BACK_DEBOUNCE_MS;
35503
- this.writeBackTimer = setTimeout(async () => {
35504
- this.writeBackTimer = null;
35505
- if (!this.ydoc || !config.onWriteBack) {
35506
- return;
35507
- }
35508
- const sourceBytes = this.getSourceBytes?.();
35509
- if (!sourceBytes) {
35510
- return;
35511
- }
35512
- try {
35513
- const { PptxHandler } = await import('pptx-viewer-core');
35514
- const handler = new PptxHandler();
35515
- await handler.load(sourceBytes.buffer);
35516
- // Merge the editor's separated template (master/layout) elements back
35517
- // into the broadcast (template-free) slides so template edits persist.
35518
- const slides = buildSaveSlides(readSlidesFromYDoc(this.ydoc), this.getTemplateElements?.() ?? {});
35519
- const bytes = await handler.save(slides);
35520
- config.onWriteBack(bytes);
35521
- }
35522
- catch {
35523
- /* non-fatal */
35524
- }
35525
- }, ms);
36029
+ }
36030
+ scheduleWriteBack() {
36031
+ this.writeBack.schedule(this.currentConfig, this.ydoc, this.getSourceBytes, this.getTemplateElements);
35526
36032
  }
35527
36033
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: CollaborationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
35528
36034
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: CollaborationService });
@@ -36388,6 +36894,16 @@ class EditorStateService {
36388
36894
  this.dirty.set(true);
36389
36895
  this.syncHistory();
36390
36896
  }
36897
+ /**
36898
+ * Apply a remote collaborator's slide set (already template-free, as broadcast
36899
+ * over the CRDT) to the editable deck. Selection, history, and this peer's own
36900
+ * separated template store are left untouched; remote edits are not local undo
36901
+ * steps.
36902
+ */
36903
+ applyRemoteSlides(slides) {
36904
+ this.slides.set(this.clone(slides));
36905
+ this.dirty.set(true);
36906
+ }
36391
36907
  // ── Snapshot (deck + template store) ─────────────────────────────────────
36392
36908
  /** Capture the current deck + template store as one undo/redo snapshot. */
36393
36909
  captureSnapshot() {
@@ -39728,6 +40244,145 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
39728
40244
  args: ['document:keydown', ['$event']]
39729
40245
  }] } });
39730
40246
 
40247
+ /**
40248
+ * follow-mode-bar.component.ts: Angular port of the Vue `FollowModeBar.vue`.
40249
+ *
40250
+ * Selector: `pptx-follow-mode-bar`
40251
+ *
40252
+ * Lists the active remote peers and lets the local user follow one of them
40253
+ * (mirroring that peer's active slide) or stop following. Purely presentational:
40254
+ * the host supplies the reactive presence list and the currently-followed
40255
+ * clientId (from `CollaborationService`) and reacts to the `follow` event to
40256
+ * drive `followUser(clientId | null)`. Each peer chip shows an initials avatar
40257
+ * in the peer's colour; the followed peer is highlighted with a "Stop" affordance.
40258
+ */
40259
+ class FollowModeBarComponent {
40260
+ /** Active remote collaborators (excludes self). */
40261
+ presences = input([], /* @ts-ignore */
40262
+ ...(ngDevMode ? [{ debugName: "presences" }] : /* istanbul ignore next */ []));
40263
+ /** The clientId currently being followed, or null. */
40264
+ followedClientId = input(null, /* @ts-ignore */
40265
+ ...(ngDevMode ? [{ debugName: "followedClientId" }] : /* istanbul ignore next */ []));
40266
+ /** Follow the given peer, or `null` to stop following. */
40267
+ follow = output();
40268
+ chips = computed(() => {
40269
+ const followed = this.followedClientId();
40270
+ return this.presences().map((peer) => ({
40271
+ clientId: peer.clientId,
40272
+ userName: peer.userName,
40273
+ color: peer.userColor,
40274
+ initials: initialsOf(peer.userName),
40275
+ following: peer.clientId === followed,
40276
+ }));
40277
+ }, /* @ts-ignore */
40278
+ ...(ngDevMode ? [{ debugName: "chips" }] : /* istanbul ignore next */ []));
40279
+ followedName = computed(() => {
40280
+ const id = this.followedClientId();
40281
+ if (id === null) {
40282
+ return null;
40283
+ }
40284
+ return this.presences().find((p) => p.clientId === id)?.userName ?? null;
40285
+ }, /* @ts-ignore */
40286
+ ...(ngDevMode ? [{ debugName: "followedName" }] : /* istanbul ignore next */ []));
40287
+ toggle(clientId) {
40288
+ this.follow.emit(this.followedClientId() === clientId ? null : clientId);
40289
+ }
40290
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: FollowModeBarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
40291
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: FollowModeBarComponent, isStandalone: true, selector: "pptx-follow-mode-bar", inputs: { presences: { classPropertyName: "presences", publicName: "presences", isSignal: true, isRequired: false, transformFunction: null }, followedClientId: { classPropertyName: "followedClientId", publicName: "followedClientId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { follow: "follow" }, ngImport: i0, template: `
40292
+ @if (chips().length > 0) {
40293
+ <div class="pptx-ng-follow-bar" data-export-ignore="true">
40294
+ <span class="pptx-ng-follow-status">
40295
+ @if (followedName(); as name) {
40296
+ {{ 'pptx.followMode.following' | translate }}
40297
+ <strong>{{ name }}</strong>
40298
+ <button
40299
+ type="button"
40300
+ class="pptx-ng-follow-stop"
40301
+ [title]="'pptx.followMode.stopFollowing' | translate"
40302
+ (click)="follow.emit(null)"
40303
+ >
40304
+ {{ 'pptx.followMode.stop' | translate }}
40305
+ </button>
40306
+ } @else {
40307
+ {{ 'pptx.followMode.followCollaborator' | translate }}
40308
+ }
40309
+ </span>
40310
+ <ul class="pptx-ng-follow-list">
40311
+ @for (peer of chips(); track peer.clientId) {
40312
+ <li>
40313
+ <button
40314
+ type="button"
40315
+ class="pptx-ng-follow-peer"
40316
+ [class.is-following]="peer.following"
40317
+ [attr.data-client-id]="peer.clientId"
40318
+ [attr.aria-pressed]="peer.following"
40319
+ (click)="toggle(peer.clientId)"
40320
+ >
40321
+ <span class="pptx-ng-follow-avatar" [style.background-color]="peer.color">
40322
+ {{ peer.initials }}
40323
+ </span>
40324
+ <span class="pptx-ng-follow-name">{{ peer.userName }}</span>
40325
+ </button>
40326
+ </li>
40327
+ }
40328
+ </ul>
40329
+ </div>
40330
+ }
40331
+ `, isInline: true, styles: [":host{display:block}.pptx-ng-follow-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;border-radius:.5rem;background:var(--pptx-card, rgba(17, 24, 39, .95));padding:.375rem .625rem;font-size:.75rem;color:var(--pptx-foreground, #f3f4f6)}.pptx-ng-follow-status{display:inline-flex;align-items:center;gap:.375rem;white-space:nowrap;color:var(--pptx-muted-foreground, #9ca3af)}.pptx-ng-follow-stop{cursor:pointer;border-radius:.375rem;border:1px solid var(--pptx-border, #374151);background:transparent;padding:.125rem .5rem;font-size:11px;color:var(--pptx-foreground, #f3f4f6)}.pptx-ng-follow-list{display:flex;align-items:center;gap:.375rem;margin:0;padding:0;list-style:none}.pptx-ng-follow-peer{display:inline-flex;cursor:pointer;align-items:center;gap:.375rem;border-radius:9999px;border:1px solid transparent;background:var(--pptx-muted, rgba(55, 65, 81, .6));padding:.125rem .5rem .125rem .125rem;color:var(--pptx-foreground, #f3f4f6)}.pptx-ng-follow-peer.is-following{border-color:var(--pptx-primary, #6366f1);background:color-mix(in srgb,var(--pptx-primary, #6366f1) 30%,transparent)}.pptx-ng-follow-avatar{display:inline-flex;height:22px;width:22px;align-items:center;justify-content:center;border-radius:9999px;font-size:10px;font-weight:600;line-height:1;color:#fff}.pptx-ng-follow-name{max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n"], dependencies: [{ kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
40332
+ }
40333
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: FollowModeBarComponent, decorators: [{
40334
+ type: Component,
40335
+ args: [{ selector: 'pptx-follow-mode-bar', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [TranslatePipe], template: `
40336
+ @if (chips().length > 0) {
40337
+ <div class="pptx-ng-follow-bar" data-export-ignore="true">
40338
+ <span class="pptx-ng-follow-status">
40339
+ @if (followedName(); as name) {
40340
+ {{ 'pptx.followMode.following' | translate }}
40341
+ <strong>{{ name }}</strong>
40342
+ <button
40343
+ type="button"
40344
+ class="pptx-ng-follow-stop"
40345
+ [title]="'pptx.followMode.stopFollowing' | translate"
40346
+ (click)="follow.emit(null)"
40347
+ >
40348
+ {{ 'pptx.followMode.stop' | translate }}
40349
+ </button>
40350
+ } @else {
40351
+ {{ 'pptx.followMode.followCollaborator' | translate }}
40352
+ }
40353
+ </span>
40354
+ <ul class="pptx-ng-follow-list">
40355
+ @for (peer of chips(); track peer.clientId) {
40356
+ <li>
40357
+ <button
40358
+ type="button"
40359
+ class="pptx-ng-follow-peer"
40360
+ [class.is-following]="peer.following"
40361
+ [attr.data-client-id]="peer.clientId"
40362
+ [attr.aria-pressed]="peer.following"
40363
+ (click)="toggle(peer.clientId)"
40364
+ >
40365
+ <span class="pptx-ng-follow-avatar" [style.background-color]="peer.color">
40366
+ {{ peer.initials }}
40367
+ </span>
40368
+ <span class="pptx-ng-follow-name">{{ peer.userName }}</span>
40369
+ </button>
40370
+ </li>
40371
+ }
40372
+ </ul>
40373
+ </div>
40374
+ }
40375
+ `, styles: [":host{display:block}.pptx-ng-follow-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;border-radius:.5rem;background:var(--pptx-card, rgba(17, 24, 39, .95));padding:.375rem .625rem;font-size:.75rem;color:var(--pptx-foreground, #f3f4f6)}.pptx-ng-follow-status{display:inline-flex;align-items:center;gap:.375rem;white-space:nowrap;color:var(--pptx-muted-foreground, #9ca3af)}.pptx-ng-follow-stop{cursor:pointer;border-radius:.375rem;border:1px solid var(--pptx-border, #374151);background:transparent;padding:.125rem .5rem;font-size:11px;color:var(--pptx-foreground, #f3f4f6)}.pptx-ng-follow-list{display:flex;align-items:center;gap:.375rem;margin:0;padding:0;list-style:none}.pptx-ng-follow-peer{display:inline-flex;cursor:pointer;align-items:center;gap:.375rem;border-radius:9999px;border:1px solid transparent;background:var(--pptx-muted, rgba(55, 65, 81, .6));padding:.125rem .5rem .125rem .125rem;color:var(--pptx-foreground, #f3f4f6)}.pptx-ng-follow-peer.is-following{border-color:var(--pptx-primary, #6366f1);background:color-mix(in srgb,var(--pptx-primary, #6366f1) 30%,transparent)}.pptx-ng-follow-avatar{display:inline-flex;height:22px;width:22px;align-items:center;justify-content:center;border-radius:9999px;font-size:10px;font-weight:600;line-height:1;color:#fff}.pptx-ng-follow-name{max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n"] }]
40376
+ }], propDecorators: { presences: [{ type: i0.Input, args: [{ isSignal: true, alias: "presences", required: false }] }], followedClientId: [{ type: i0.Input, args: [{ isSignal: true, alias: "followedClientId", required: false }] }], follow: [{ type: i0.Output, args: ["follow"] }] } });
40377
+ /** First-letter / two-char initials for the avatar chip. */
40378
+ function initialsOf(name) {
40379
+ const parts = name.trim().split(/\s+/u);
40380
+ if (parts.length >= 2 && parts[0] && parts[parts.length - 1]) {
40381
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
40382
+ }
40383
+ return name.slice(0, 2).toUpperCase() || '?';
40384
+ }
40385
+
39731
40386
  /**
39732
40387
  * hyperlink-dialog.component.ts: Set or clear an element's click hyperlink.
39733
40388
  *
@@ -64622,6 +65277,112 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
64622
65277
  `, styles: [".pptx-ng-props-form{display:flex;flex-direction:column;gap:.75rem}.pptx-ng-props-field{display:flex;flex-direction:column;gap:.375rem}.pptx-ng-props-label{font-size:.75rem;font-weight:500;color:var(--pptx-foreground, #e5e5e5)}.pptx-ng-props-input{width:100%;padding:.375rem .75rem;border-radius:.375rem;border:1px solid var(--pptx-border, #2a2a2a);background:var(--pptx-background, #111);color:var(--pptx-foreground, #e5e5e5);font-size:.8125rem}.pptx-ng-props-input:focus{outline:none;border-color:var(--pptx-primary, #6366f1);box-shadow:0 0 0 1px var(--pptx-primary, #6366f1)}.pptx-ng-props-meta{display:flex;flex-direction:column;gap:.375rem;padding-top:.5rem;border-top:1px solid var(--pptx-border, #2a2a2a)}.pptx-ng-props-meta-row{display:flex;justify-content:space-between;font-size:.75rem}.pptx-ng-props-meta-label{color:var(--pptx-muted-foreground, #9a9a9a)}.pptx-ng-props-meta-value{color:var(--pptx-foreground, #e5e5e5)}.pptx-ng-props-btn{padding:.375rem .75rem;border:none;border-radius:.375rem;background:var(--pptx-muted, #2a2a2a);color:var(--pptx-foreground, #e5e5e5);font-size:.75rem;cursor:pointer}.pptx-ng-props-btn-primary{background:var(--pptx-primary, #6366f1);color:var(--pptx-primary-foreground, #fff)}\n"] }]
64623
65278
  }], ctorParameters: () => [], propDecorators: { open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], properties: [{ type: i0.Input, args: [{ isSignal: true, alias: "properties", required: true }] }], save: [{ type: i0.Output, args: ["save"] }], close: [{ type: i0.Output, args: ["close"] }] } });
64624
65279
 
65280
+ /**
65281
+ * remote-selection-overlay.component.ts: Angular port of the Vue
65282
+ * `RemoteSelectionOverlay.vue` / React `RemoteSelectionOverlay.tsx`.
65283
+ *
65284
+ * Selector: `pptx-remote-selection-overlay`
65285
+ *
65286
+ * Draws a coloured rectangle around each element a remote collaborator has
65287
+ * selected, labelled with that peer's name in their colour (Google-Slides-style
65288
+ * presence). Purely presentational: the host supplies the reactive presence
65289
+ * list (from `CollaborationService.presence`), the elements on the active slide,
65290
+ * the active slide index, and the current `zoom`. Only peers whose
65291
+ * `activeSlideIndex` matches are drawn, and only for a selected id that resolves
65292
+ * to an element on the slide.
65293
+ *
65294
+ * Element geometry is in unscaled slide coordinates (px); this component
65295
+ * multiplies by `zoom` so it can mount inside the scaled slide stage. It sets
65296
+ * `pointer-events: none` so it never intercepts canvas input.
65297
+ */
65298
+ class RemoteSelectionOverlayComponent {
65299
+ /** Remote collaborators' presence (cursor + selection + active slide). */
65300
+ presences = input([], /* @ts-ignore */
65301
+ ...(ngDevMode ? [{ debugName: "presences" }] : /* istanbul ignore next */ []));
65302
+ /** Elements on the active slide (used to resolve selected ids -> geometry). */
65303
+ elements = input([], /* @ts-ignore */
65304
+ ...(ngDevMode ? [{ debugName: "elements" }] : /* istanbul ignore next */ []));
65305
+ /** The current slide index: only peers on this slide are drawn. */
65306
+ activeSlideIndex = input(0, /* @ts-ignore */
65307
+ ...(ngDevMode ? [{ debugName: "activeSlideIndex" }] : /* istanbul ignore next */ []));
65308
+ /** Current canvas zoom factor; geometry scales by this. */
65309
+ zoom = input(1, /* @ts-ignore */
65310
+ ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
65311
+ elementMap = computed(() => {
65312
+ const map = new Map();
65313
+ for (const el of this.elements()) {
65314
+ map.set(el.id, el);
65315
+ }
65316
+ return map;
65317
+ }, /* @ts-ignore */
65318
+ ...(ngDevMode ? [{ debugName: "elementMap" }] : /* istanbul ignore next */ []));
65319
+ boxes = computed(() => {
65320
+ const slide = this.activeSlideIndex();
65321
+ const z = this.zoom();
65322
+ const lookup = this.elementMap();
65323
+ const result = [];
65324
+ for (const peer of this.presences()) {
65325
+ if (peer.activeSlideIndex !== slide || !peer.selectedElementId) {
65326
+ continue;
65327
+ }
65328
+ const el = lookup.get(peer.selectedElementId);
65329
+ if (!el) {
65330
+ continue;
65331
+ }
65332
+ result.push({
65333
+ key: `${peer.clientId}-${peer.selectedElementId}`,
65334
+ label: formatCursorLabel(peer.userName, MAX_LABEL_CHARS),
65335
+ color: peer.userColor,
65336
+ transform: `translate(${el.x * z}px, ${el.y * z}px)`,
65337
+ width: el.width * z,
65338
+ height: el.height * z,
65339
+ });
65340
+ }
65341
+ return result;
65342
+ }, /* @ts-ignore */
65343
+ ...(ngDevMode ? [{ debugName: "boxes" }] : /* istanbul ignore next */ []));
65344
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: RemoteSelectionOverlayComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
65345
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: RemoteSelectionOverlayComponent, isStandalone: true, selector: "pptx-remote-selection-overlay", inputs: { presences: { classPropertyName: "presences", publicName: "presences", isSignal: true, isRequired: false, transformFunction: null }, elements: { classPropertyName: "elements", publicName: "elements", isSignal: true, isRequired: false, transformFunction: null }, activeSlideIndex: { classPropertyName: "activeSlideIndex", publicName: "activeSlideIndex", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
65346
+ <div aria-hidden="true" data-export-ignore="true">
65347
+ @for (box of boxes(); track box.key) {
65348
+ <div
65349
+ class="pptx-ng-remote-selection"
65350
+ [attr.data-element-id]="box.key"
65351
+ [style.transform]="box.transform"
65352
+ [style.width.px]="box.width"
65353
+ [style.height.px]="box.height"
65354
+ [style.color]="box.color"
65355
+ >
65356
+ <span class="pptx-ng-remote-selection-label" [style.background-color]="box.color">
65357
+ {{ box.label }}
65358
+ </span>
65359
+ </div>
65360
+ }
65361
+ </div>
65362
+ `, isInline: true, styles: [":host{position:absolute;inset:0;pointer-events:none;overflow:visible;z-index:9997}.pptx-ng-remote-selection{position:absolute;top:0;left:0;box-sizing:border-box;border:2px solid currentcolor;border-radius:2px;pointer-events:none;will-change:transform;transition:transform 90ms linear}.pptx-ng-remote-selection-label{position:absolute;top:-18px;left:-2px;max-width:150px;padding:1px 5px;border-radius:3px;color:#fff;font-family:system-ui,sans-serif;font-size:9px;font-weight:500;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
65363
+ }
65364
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: RemoteSelectionOverlayComponent, decorators: [{
65365
+ type: Component,
65366
+ args: [{ selector: 'pptx-remote-selection-overlay', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: `
65367
+ <div aria-hidden="true" data-export-ignore="true">
65368
+ @for (box of boxes(); track box.key) {
65369
+ <div
65370
+ class="pptx-ng-remote-selection"
65371
+ [attr.data-element-id]="box.key"
65372
+ [style.transform]="box.transform"
65373
+ [style.width.px]="box.width"
65374
+ [style.height.px]="box.height"
65375
+ [style.color]="box.color"
65376
+ >
65377
+ <span class="pptx-ng-remote-selection-label" [style.background-color]="box.color">
65378
+ {{ box.label }}
65379
+ </span>
65380
+ </div>
65381
+ }
65382
+ </div>
65383
+ `, styles: [":host{position:absolute;inset:0;pointer-events:none;overflow:visible;z-index:9997}.pptx-ng-remote-selection{position:absolute;top:0;left:0;box-sizing:border-box;border:2px solid currentcolor;border-radius:2px;pointer-events:none;will-change:transform;transition:transform 90ms linear}.pptx-ng-remote-selection-label{position:absolute;top:-18px;left:-2px;max-width:150px;padding:1px 5px;border-radius:3px;color:#fff;font-family:system-ui,sans-serif;font-size:9px;font-weight:500;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n"] }]
65384
+ }], propDecorators: { presences: [{ type: i0.Input, args: [{ isSignal: true, alias: "presences", required: false }] }], elements: [{ type: i0.Input, args: [{ isSignal: true, alias: "elements", required: false }] }], activeSlideIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeSlideIndex", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }] } });
65385
+
64625
65386
  /**
64626
65387
  * ribbon-animations-section.component.ts: the Animations ribbon tab (preview, the
64627
65388
  * Add Animation entrance/emphasis/exit gallery, Remove Animation, Animation
@@ -69658,24 +70419,29 @@ function seedShareFields(defaults) {
69658
70419
  serverUrl: defaults?.serverUrl ?? '',
69659
70420
  };
69660
70421
  }
69661
- /** Whether all three required fields are non-blank (after trimming). */
70422
+ /**
70423
+ * Whether the required fields are non-blank (after trimming). The server URL is
70424
+ * optional: a blank server selects the serverless (webrtc) peer-to-peer
70425
+ * transport, so only the room id and display name are required.
70426
+ */
69662
70427
  function canStartShare(fields) {
69663
- return (fields.roomId.trim().length > 0 &&
69664
- fields.userName.trim().length > 0 &&
69665
- fields.serverUrl.trim().length > 0);
70428
+ return fields.roomId.trim().length > 0 && fields.userName.trim().length > 0;
69666
70429
  }
69667
70430
  /**
69668
70431
  * Assemble a {@link CollaborationConfig} from the (trimmed) form fields, or
69669
- * `null` when the form is incomplete.
70432
+ * `null` when the form is incomplete. A blank server URL yields
70433
+ * `transport: 'webrtc'`; otherwise the default websocket transport is used.
69670
70434
  */
69671
70435
  function buildCollaborationConfig(fields) {
69672
70436
  if (!canStartShare(fields)) {
69673
70437
  return null;
69674
70438
  }
70439
+ const serverUrl = fields.serverUrl.trim();
69675
70440
  return {
69676
70441
  roomId: fields.roomId.trim(),
69677
70442
  userName: fields.userName.trim(),
69678
- serverUrl: fields.serverUrl.trim(),
70443
+ serverUrl,
70444
+ transport: resolveTransportForServerUrl(serverUrl),
69679
70445
  };
69680
70446
  }
69681
70447
  /**
@@ -69687,7 +70453,11 @@ function buildShareUrl(roomId, serverUrl, location) {
69687
70453
  return roomId;
69688
70454
  }
69689
70455
  const room = encodeURIComponent(roomId);
69690
- const server = encodeURIComponent(serverUrl);
70456
+ const trimmed = serverUrl.trim();
70457
+ if (trimmed.length === 0) {
70458
+ return `${location.origin}${location.pathname}?room=${room}&transport=webrtc`;
70459
+ }
70460
+ const server = encodeURIComponent(trimmed);
69691
70461
  return `${location.origin}${location.pathname}?room=${room}&server=${server}`;
69692
70462
  }
69693
70463
 
@@ -69723,6 +70493,9 @@ class ShareDialogComponent {
69723
70493
  /** Shareable join link surfaced while the session is active. */
69724
70494
  shareUrl = input('', /* @ts-ignore */
69725
70495
  ...(ngDevMode ? [{ debugName: "shareUrl" }] : /* istanbul ignore next */ []));
70496
+ /** Whether the active session is peer-to-peer (serverless webrtc). */
70497
+ p2p = input(false, /* @ts-ignore */
70498
+ ...(ngDevMode ? [{ debugName: "p2p" }] : /* istanbul ignore next */ []));
69726
70499
  /** Fired with the assembled config when the user starts sharing. */
69727
70500
  start = output();
69728
70501
  /** Fired when the user stops an active session. */
@@ -69743,6 +70516,9 @@ class ShareDialogComponent {
69743
70516
  serverUrl: this.serverUrl(),
69744
70517
  }), /* @ts-ignore */
69745
70518
  ...(ngDevMode ? [{ debugName: "canStart" }] : /* istanbul ignore next */ []));
70519
+ /** Blank server URL selects the serverless peer-to-peer (webrtc) transport. */
70520
+ isP2p = computed(() => this.serverUrl().trim().length === 0, /* @ts-ignore */
70521
+ ...(ngDevMode ? [{ debugName: "isP2p" }] : /* istanbul ignore next */ []));
69746
70522
  canCopy = computed(() => canUseClipboard(typeof navigator !== 'undefined' ? navigator : undefined), /* @ts-ignore */
69747
70523
  ...(ngDevMode ? [{ debugName: "canCopy" }] : /* istanbul ignore next */ []));
69748
70524
  constructor() {
@@ -69789,7 +70565,7 @@ class ShareDialogComponent {
69789
70565
  this.stop.emit();
69790
70566
  }
69791
70567
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: ShareDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
69792
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: ShareDialogComponent, isStandalone: true, selector: "pptx-share-dialog", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, defaults: { classPropertyName: "defaults", publicName: "defaults", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, connected: { classPropertyName: "connected", publicName: "connected", isSignal: true, isRequired: false, transformFunction: null }, userCount: { classPropertyName: "userCount", publicName: "userCount", isSignal: true, isRequired: false, transformFunction: null }, shareUrl: { classPropertyName: "shareUrl", publicName: "shareUrl", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { start: "start", stop: "stop", close: "close" }, ngImport: i0, template: `
70568
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: ShareDialogComponent, isStandalone: true, selector: "pptx-share-dialog", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, defaults: { classPropertyName: "defaults", publicName: "defaults", isSignal: true, isRequired: false, transformFunction: null }, active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: false, transformFunction: null }, connected: { classPropertyName: "connected", publicName: "connected", isSignal: true, isRequired: false, transformFunction: null }, userCount: { classPropertyName: "userCount", publicName: "userCount", isSignal: true, isRequired: false, transformFunction: null }, shareUrl: { classPropertyName: "shareUrl", publicName: "shareUrl", isSignal: true, isRequired: false, transformFunction: null }, p2p: { classPropertyName: "p2p", publicName: "p2p", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { start: "start", stop: "stop", close: "close" }, ngImport: i0, template: `
69793
70569
  <pptx-modal-dialog
69794
70570
  [open]="open()"
69795
70571
  [title]="(active() ? 'pptx.share.activeTitle' : 'pptx.toolbar.share') | translate"
@@ -69816,6 +70592,14 @@ class ShareDialogComponent {
69816
70592
  </span>
69817
70593
  </div>
69818
70594
 
70595
+ @if (p2p()) {
70596
+ <div class="pptx-ng-share-status-row">
70597
+ <span class="pptx-ng-share-count" style="margin-left: 0">
70598
+ {{ 'pptx.share.p2pServerValue' | translate }}
70599
+ </span>
70600
+ </div>
70601
+ }
70602
+
69819
70603
  @if (shareUrl()) {
69820
70604
  <div class="pptx-ng-share-field">
69821
70605
  <label class="pptx-ng-share-label">{{
@@ -69892,6 +70676,9 @@ class ShareDialogComponent {
69892
70676
  [value]="serverUrl()"
69893
70677
  (input)="serverUrl.set(asValue($event))"
69894
70678
  />
70679
+ @if (isP2p()) {
70680
+ <p class="pptx-ng-share-hint">{{ 'pptx.share.p2pHint' | translate }}</p>
70681
+ }
69895
70682
  </div>
69896
70683
  </div>
69897
70684
  }
@@ -69943,6 +70730,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
69943
70730
  </span>
69944
70731
  </div>
69945
70732
 
70733
+ @if (p2p()) {
70734
+ <div class="pptx-ng-share-status-row">
70735
+ <span class="pptx-ng-share-count" style="margin-left: 0">
70736
+ {{ 'pptx.share.p2pServerValue' | translate }}
70737
+ </span>
70738
+ </div>
70739
+ }
70740
+
69946
70741
  @if (shareUrl()) {
69947
70742
  <div class="pptx-ng-share-field">
69948
70743
  <label class="pptx-ng-share-label">{{
@@ -70019,6 +70814,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
70019
70814
  [value]="serverUrl()"
70020
70815
  (input)="serverUrl.set(asValue($event))"
70021
70816
  />
70817
+ @if (isP2p()) {
70818
+ <p class="pptx-ng-share-hint">{{ 'pptx.share.p2pHint' | translate }}</p>
70819
+ }
70022
70820
  </div>
70023
70821
  </div>
70024
70822
  }
@@ -70040,7 +70838,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
70040
70838
  </div>
70041
70839
  </pptx-modal-dialog>
70042
70840
  `, styles: [".pptx-ng-share-form,.pptx-ng-share-active{display:flex;flex-direction:column;gap:1rem}.pptx-ng-share-desc{margin:0;font-size:.8125rem;color:var(--pptx-muted-foreground, #9a9a9a)}.pptx-ng-share-field{display:flex;flex-direction:column;gap:.375rem}.pptx-ng-share-label{font-size:.75rem;font-weight:500;color:var(--pptx-foreground, #e5e5e5)}.pptx-ng-share-input{width:100%;padding:.375rem .75rem;border-radius:.375rem;border:1px solid var(--pptx-border, #2a2a2a);background:var(--pptx-background, #111);color:var(--pptx-foreground, #e5e5e5);font-size:.8125rem}.pptx-ng-share-input:focus{outline:none;border-color:var(--pptx-primary, #6366f1);box-shadow:0 0 0 1px var(--pptx-primary, #6366f1)}.pptx-ng-share-btn{padding:.375rem .75rem;border:none;border-radius:.375rem;background:var(--pptx-muted, #2a2a2a);color:var(--pptx-foreground, #e5e5e5);font-size:.75rem;cursor:pointer}.pptx-ng-share-btn-primary{background:var(--pptx-primary, #6366f1);color:var(--pptx-primary-foreground, #fff)}.pptx-ng-share-btn-primary:disabled{opacity:.4;cursor:not-allowed}.pptx-ng-share-stop{width:100%;padding:.5rem .75rem;border:1px solid rgba(239,68,68,.3);border-radius:.375rem;background:#ef44441a;color:#f87171;font-size:.75rem;font-weight:500;cursor:pointer}.pptx-ng-share-stop:hover{background:#ef444433}.pptx-ng-share-status-row{display:flex;align-items:center;gap:.5rem;font-size:.8125rem}.pptx-ng-share-status-dot{width:.5rem;height:.5rem;border-radius:9999px;background:var(--pptx-muted-foreground, #9a9a9a)}.pptx-ng-share-status-dot.is-on{background:#22c55e}.pptx-ng-share-status-text{font-weight:500;color:var(--pptx-foreground, #e5e5e5)}.pptx-ng-share-count{margin-left:auto;color:var(--pptx-muted-foreground, #9a9a9a)}.pptx-ng-share-link-row{display:flex;align-items:center;gap:.5rem}.pptx-ng-share-hint{margin:0;font-size:.6875rem;color:var(--pptx-muted-foreground, #9a9a9a)}\n"] }]
70043
- }], ctorParameters: () => [], propDecorators: { open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], defaults: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaults", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], connected: [{ type: i0.Input, args: [{ isSignal: true, alias: "connected", required: false }] }], userCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "userCount", required: false }] }], shareUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "shareUrl", required: false }] }], start: [{ type: i0.Output, args: ["start"] }], stop: [{ type: i0.Output, args: ["stop"] }], close: [{ type: i0.Output, args: ["close"] }] } });
70841
+ }], ctorParameters: () => [], propDecorators: { open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], defaults: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaults", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], connected: [{ type: i0.Input, args: [{ isSignal: true, alias: "connected", required: false }] }], userCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "userCount", required: false }] }], shareUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "shareUrl", required: false }] }], p2p: [{ type: i0.Input, args: [{ isSignal: true, alias: "p2p", required: false }] }], start: [{ type: i0.Output, args: ["start"] }], stop: [{ type: i0.Output, args: ["stop"] }], close: [{ type: i0.Output, args: ["close"] }] } });
70044
70842
 
70045
70843
  /**
70046
70844
  * signatures-helpers.ts: Pure functions for digital-signature status
@@ -71562,6 +72360,34 @@ class ViewerCollaborationSessionService {
71562
72360
  : '';
71563
72361
  }, /* @ts-ignore */
71564
72362
  ...(ngDevMode ? [{ debugName: "broadcastViewerUrl" }] : /* istanbul ignore next */ []));
72363
+ /** Whether the active session is serverless (peer-to-peer / webrtc). */
72364
+ activeSessionP2p = computed(() => (this.activeSession()?.serverUrl ?? '').trim().length === 0 && this.collab.active(), /* @ts-ignore */
72365
+ ...(ngDevMode ? [{ debugName: "activeSessionP2p" }] : /* istanbul ignore next */ []));
72366
+ /**
72367
+ * Assemble the {@link ConnectOptions} shared by every connect call site: apply
72368
+ * remote slides to the editor, size the presence bounds to the canvas, and
72369
+ * expose the source bytes + separated template elements for write-back.
72370
+ */
72371
+ connectOptions() {
72372
+ const host = this.requireHost();
72373
+ const size = host.canvasSize();
72374
+ return {
72375
+ onRemoteSlides: (slides) => host.applyRemoteSlides(slides),
72376
+ canvasWidth: size.width,
72377
+ canvasHeight: size.height,
72378
+ getSourceBytes: () => host.getSourceBytes(),
72379
+ getTemplateElements: () => host.getTemplateElements(),
72380
+ };
72381
+ }
72382
+ /**
72383
+ * Connect and immediately seed the sync baseline with the current deck, so a
72384
+ * joiner whose deck is still a placeholder never broadcasts (and overwrites)
72385
+ * the shared document before the first remote sync arrives.
72386
+ */
72387
+ connectWithBaseline(config) {
72388
+ void this.collab.connect(config, this.connectOptions());
72389
+ this.collab.seedBaseline(this.requireHost().currentSlides());
72390
+ }
71565
72391
  /**
71566
72392
  * Seed values for the Share dialog: the host-supplied `shareDefaults`, with
71567
72393
  * `userName` falling back to `authorName` (then "You") so the local user's
@@ -71583,9 +72409,7 @@ class ViewerCollaborationSessionService {
71583
72409
  syncHostConfig(config) {
71584
72410
  if (config) {
71585
72411
  this.activeSession.set({ roomId: config.roomId, serverUrl: config.serverUrl });
71586
- void this.collab.connect(config, {
71587
- getTemplateElements: () => this.requireHost().getTemplateElements(),
71588
- });
72412
+ this.connectWithBaseline(config);
71589
72413
  }
71590
72414
  else {
71591
72415
  this.collab.disconnect();
@@ -71605,9 +72429,7 @@ class ViewerCollaborationSessionService {
71605
72429
  roomId: collaboratorConfig.roomId,
71606
72430
  serverUrl: collaboratorConfig.serverUrl,
71607
72431
  });
71608
- void this.collab.connect(collaboratorConfig, {
71609
- getTemplateElements: () => host.getTemplateElements(),
71610
- });
72432
+ this.connectWithBaseline(collaboratorConfig);
71611
72433
  host.emitStart(collaboratorConfig);
71612
72434
  }
71613
72435
  onShareStop() {
@@ -71621,13 +72443,12 @@ class ViewerCollaborationSessionService {
71621
72443
  const collabConfig = {
71622
72444
  roomId: config.roomId,
71623
72445
  serverUrl: config.serverUrl,
72446
+ transport: config.transport,
71624
72447
  userName: host.authorName() ?? 'Presenter',
71625
72448
  role: 'owner',
71626
72449
  };
71627
72450
  this.activeSession.set({ roomId: config.roomId, serverUrl: config.serverUrl });
71628
- void this.collab.connect(collabConfig, {
71629
- getTemplateElements: () => host.getTemplateElements(),
71630
- });
72451
+ this.connectWithBaseline(collabConfig);
71631
72452
  host.emitStart(collabConfig);
71632
72453
  }
71633
72454
  onBroadcastStop() {
@@ -76033,6 +76854,14 @@ class PowerPointViewerComponent {
76033
76854
  ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
76034
76855
  zoomPercent = computed(() => Math.round(this.zoom() * 100), /* @ts-ignore */
76035
76856
  ...(ngDevMode ? [{ debugName: "zoomPercent" }] : /* istanbul ignore next */ []));
76857
+ /**
76858
+ * Remote cursors filtered to the slide the local user is viewing, so peers'
76859
+ * cursors only appear on the shared slide (mirrors React/Vue).
76860
+ */
76861
+ collabCursors = computed(() => presenceToCursors(this.collab.presence(), this.activeSlideIndex()), /* @ts-ignore */
76862
+ ...(ngDevMode ? [{ debugName: "collabCursors" }] : /* istanbul ignore next */ []));
76863
+ /** Timestamp of the last cursor broadcast (throttle gate). */
76864
+ lastCursorBroadcast = 0;
76036
76865
  /** Fullscreen presentation-mode overlay visibility. */
76037
76866
  presenting = signal(false, /* @ts-ignore */
76038
76867
  ...(ngDevMode ? [{ debugName: "presenting" }] : /* istanbul ignore next */ []));
@@ -76297,6 +77126,47 @@ class PowerPointViewerComponent {
76297
77126
  effect(() => {
76298
77127
  this.session.syncHostConfig(this.collaboration());
76299
77128
  });
77129
+ // Push local slide edits into the shared Y.Doc (reconcile-based; the
77130
+ // service guards against echoing remote-applied changes and against
77131
+ // clobbering with an empty deck). A broadcast `viewer` never writes, so a
77132
+ // follow-along joiner cannot overwrite the presenter's deck.
77133
+ effect(() => {
77134
+ const slides = this.editor.slides();
77135
+ if (this.collab.active() && this.collab.activeRole() !== 'viewer') {
77136
+ this.collab.broadcastSlides(slides);
77137
+ }
77138
+ });
77139
+ // Publish the local selection so peers can draw remote selection boxes.
77140
+ effect(() => {
77141
+ const ids = this.editor.selectedIds();
77142
+ if (this.collab.active()) {
77143
+ this.collab.setSelection(ids[0], this.activeSlideIndex());
77144
+ }
77145
+ });
77146
+ // Publish the local active slide so followers navigate with us.
77147
+ effect(() => {
77148
+ const index = this.activeSlideIndex();
77149
+ if (this.collab.active()) {
77150
+ this.collab.setActiveSlide(index);
77151
+ }
77152
+ });
77153
+ // Follow mode: mirror the followed peer's active slide.
77154
+ effect(() => {
77155
+ const target = this.collab.followedSlideIndex();
77156
+ if (target !== null) {
77157
+ this.goTo(target);
77158
+ }
77159
+ });
77160
+ // Broadcast auto-follow: a `viewer` tracks the broadcaster (owner) peer.
77161
+ effect(() => {
77162
+ if (this.collab.activeRole() !== 'viewer') {
77163
+ return;
77164
+ }
77165
+ const target = this.collab.broadcasterSlideIndex();
77166
+ if (target !== null) {
77167
+ this.goTo(target);
77168
+ }
77169
+ });
76300
77170
  // Hand the export/print orchestrator the live navigation signal + deck
76301
77171
  // accessors + stage resolver so it can flip the stage and capture slides.
76302
77172
  this.xport.bind({
@@ -76318,6 +77188,10 @@ class PowerPointViewerComponent {
76318
77188
  authorName: () => this.authorName(),
76319
77189
  shareDefaults: () => this.shareDefaults(),
76320
77190
  getTemplateElements: () => this.editor.templateElementsBySlideId(),
77191
+ applyRemoteSlides: (slides) => this.editor.applyRemoteSlides(slides),
77192
+ canvasSize: () => this.loader.canvasSize(),
77193
+ getSourceBytes: () => this.currentSourceBytes(),
77194
+ currentSlides: () => this.editor.slides(),
76321
77195
  emitStart: (config) => this.startCollaboration.emit(config),
76322
77196
  emitStop: () => this.stopCollaboration.emit(),
76323
77197
  });
@@ -76373,6 +77247,40 @@ class PowerPointViewerComponent {
76373
77247
  goNext() {
76374
77248
  this.goTo(this.activeSlideIndex() + 1);
76375
77249
  }
77250
+ /**
77251
+ * Publish the local cursor while the pointer moves over the canvas. Throttled
77252
+ * to {@link BROADCAST_THROTTLE_MS}; coordinates are mapped from client space
77253
+ * into unscaled slide space (dividing by zoom, matching the cursor overlay)
77254
+ * and clamped to the canvas bounds.
77255
+ */
77256
+ onCollabPointerMove(event) {
77257
+ if (!this.collab.active()) {
77258
+ return;
77259
+ }
77260
+ const now = Date.now();
77261
+ if (now - this.lastCursorBroadcast < BROADCAST_THROTTLE_MS) {
77262
+ return;
77263
+ }
77264
+ this.lastCursorBroadcast = now;
77265
+ const host = this.mainEl()?.nativeElement;
77266
+ if (!host) {
77267
+ return;
77268
+ }
77269
+ const rect = host.getBoundingClientRect();
77270
+ const zoom = this.zoom() || 1;
77271
+ const size = this.loader.canvasSize();
77272
+ const x = clampCursorPosition((event.clientX - rect.left) / zoom, 0, size.width);
77273
+ const y = clampCursorPosition((event.clientY - rect.top) / zoom, 0, size.height);
77274
+ this.collab.setCursor(x, y, this.activeSlideIndex());
77275
+ }
77276
+ /** The loaded source `.pptx` bytes (for elected-writer write-back), if any. */
77277
+ currentSourceBytes() {
77278
+ const content = this.contentOverride() ?? this.content();
77279
+ if (!content) {
77280
+ return null;
77281
+ }
77282
+ return content instanceof Uint8Array ? content : new Uint8Array(content);
77283
+ }
76376
77284
  // ── Theme gallery (Design tab) ─────────────────────────────────────────────
76377
77285
  /**
76378
77286
  * Apply a built-in theme preset to the whole deck.
@@ -77010,7 +77918,7 @@ class PowerPointViewerComponent {
77010
77918
  </nav>
77011
77919
  }
77012
77920
 
77013
- <main class="pptx-ng-main" #mainEl>
77921
+ <main class="pptx-ng-main" #mainEl (pointermove)="onCollabPointerMove($event)">
77014
77922
  <pptx-slide-canvas
77015
77923
  [slide]="activeSlide()"
77016
77924
  [canvasSize]="loader.canvasSize()"
@@ -77048,7 +77956,22 @@ class PowerPointViewerComponent {
77048
77956
  (tableChange)="onTableChange($event)"
77049
77957
  />
77050
77958
  @if (collab.connected()) {
77051
- <pptx-collaboration-cursors [cursors]="collab.cursors()" [zoom]="zoom()" />
77959
+ <pptx-collaboration-cursors [cursors]="collabCursors()" [zoom]="zoom()" />
77960
+ <pptx-remote-selection-overlay
77961
+ [presences]="collab.presence()"
77962
+ [elements]="activeSlide()?.elements ?? []"
77963
+ [activeSlideIndex]="activeSlideIndex()"
77964
+ [zoom]="zoom()"
77965
+ />
77966
+ }
77967
+ @if (collab.active() && collab.presence().length > 0) {
77968
+ <div class="pptx-ng-collab-follow">
77969
+ <pptx-follow-mode-bar
77970
+ [presences]="collab.presence()"
77971
+ [followedClientId]="collab.followedClientId()"
77972
+ (follow)="collab.followUser($event)"
77973
+ />
77974
+ </div>
77052
77975
  }
77053
77976
  @if (showNotes() && !mobile.isMobile()) {
77054
77977
  <aside class="pptx-ng-notes" [attr.aria-label]="'pptx.notes.speakerNotes' | translate">
@@ -77314,6 +78237,7 @@ class PowerPointViewerComponent {
77314
78237
  [connected]="collab.connected()"
77315
78238
  [userCount]="collab.connectedCount()"
77316
78239
  [shareUrl]="session.shareUrl()"
78240
+ [p2p]="session.activeSessionP2p()"
77317
78241
  [defaults]="session.shareDialogDefaults()"
77318
78242
  (start)="session.onShareStart($event)"
77319
78243
  (stop)="session.onShareStop()"
@@ -77326,6 +78250,7 @@ class PowerPointViewerComponent {
77326
78250
  [connected]="collab.connected()"
77327
78251
  [viewerCount]="collab.presence().length"
77328
78252
  [viewerUrl]="session.broadcastViewerUrl()"
78253
+ [p2p]="session.activeSessionP2p()"
77329
78254
  [defaults]="{ serverUrl: shareDefaults()?.serverUrl }"
77330
78255
  (start)="session.onBroadcastStart($event)"
77331
78256
  (stop)="session.onBroadcastStop()"
@@ -77433,7 +78358,7 @@ class PowerPointViewerComponent {
77433
78358
  />
77434
78359
  }
77435
78360
  </div>
77436
- `, isInline: true, dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: SlideCanvasComponent, selector: "pptx-slide-canvas", inputs: ["slide", "canvasSize", "mediaDataUrls", "zoom", "editable", "showGrid", "showRulers", "showGuides", "snapToGrid", "snapToGuides", "autoFit", "interactive", "selectedIds", "editingId", "editTemplateMode", "templateElements", "drawTool", "drawColor", "drawWidth"], outputs: ["elementSelect", "backgroundClick", "transformStart", "transformUpdate", "contextMenu", "textEditStart", "textCommit", "textCancel", "textFormat", "rotateUpdate", "marqueeSelect", "inkStrokeComplete", "eraserHit", "cellCommit", "tableChange"] }, { kind: "component", type: PresentationOverlayComponent, selector: "pptx-presentation-overlay", inputs: ["slides", "canvasSize", "mediaDataUrls", "startIndex"], outputs: ["indexChange", "closed", "annotationsExit"] }, { kind: "component", type: PresenterViewComponent, selector: "pptx-presenter-view", inputs: ["slides", "currentSlideIndex", "canvasSize", "templateElements", "mediaDataUrls", "presentationStartTime", "isAudienceWindowOpen"], outputs: ["movePresentationSlide", "exit", "openAudienceWindow", "closeAudienceWindow"] }, { kind: "component", type: MobilePresenterViewComponent, selector: "pptx-mobile-presenter-view", inputs: ["slides", "currentSlideIndex", "canvasSize", "templateElements", "mediaDataUrls", "presentationStartTime"], outputs: ["movePresentationSlide", "exit"] }, { kind: "component", type: SlideSorterOverlayComponent, selector: "pptx-slide-sorter-overlay", inputs: ["slides", "canvasSize", "mediaDataUrls", "activeIndex"], outputs: ["select", "closed"] }, { kind: "component", type: FindBarComponent, selector: "pptx-find-bar", inputs: ["slides"], outputs: ["navigate", "closed"] }, { kind: "component", type: FindReplaceBarComponent, selector: "pptx-find-replace-bar", inputs: ["matchCount", "matchIndex"], outputs: ["find", "navigate", "replaceOne", "replaceAll", "close"] }, { kind: "component", type: InspectorPanelComponent, selector: "pptx-inspector-panel", inputs: ["element", "slideIndex"] }, { kind: "component", type: SlidesPanelComponent, selector: "pptx-slides-panel", inputs: ["canvasSize", "mediaDataUrls", "activeIndex"], outputs: ["select"] }, { kind: "component", type: StatusBarComponent, selector: "pptx-status-bar", inputs: ["slideIndex", "slideCount", "canEdit", "dirty", "notesOpen", "zoomPercent", "sorterActive", "presenting"], outputs: ["toggleNotes", "normalView", "openSorter", "slideShow", "zoomIn", "zoomOut", "zoomReset"] }, { kind: "component", type: EditorContextMenuComponent, selector: "pptx-editor-context-menu", inputs: ["x", "y", "slideIndex"], outputs: ["closed"] }, { kind: "component", type: ExportProgressModalComponent, selector: "pptx-export-progress-modal", inputs: ["open", "title", "progress", "statusMessage"], outputs: ["cancel"] }, { kind: "component", type: CommentsPanelComponent, selector: "pptx-comments-panel", inputs: ["comments", "authorName"], outputs: ["add", "remove", "resolve"] }, { kind: "component", type: SignaturesPanelComponent, selector: "pptx-signatures-panel", inputs: ["signatures"] }, { kind: "component", type: AccessibilityPanelComponent, selector: "pptx-accessibility-panel", inputs: ["issues"], outputs: ["selectSlide"] }, { kind: "component", type: CollaborationCursorsComponent, selector: "pptx-collaboration-cursors", inputs: ["cursors", "zoom"] }, { kind: "component", type: PropertiesDialogComponent, selector: "pptx-properties-dialog", inputs: ["open", "properties"], outputs: ["save", "close"] }, { kind: "component", type: HyperlinkDialogComponent, selector: "pptx-hyperlink-dialog", inputs: ["open", "element"], outputs: ["save", "close"] }, { kind: "component", type: PrintDialogComponent, selector: "pptx-print-dialog", inputs: ["slides", "activeSlideIndex", "defaultSlidesPerPage", "defaultFrameSlides"], outputs: ["print", "cancel"] }, { kind: "component", type: ShareDialogComponent, selector: "pptx-share-dialog", inputs: ["open", "defaults", "active", "connected", "userCount", "shareUrl"], outputs: ["start", "stop", "close"] }, { kind: "component", type: BroadcastDialogComponent, selector: "pptx-broadcast-dialog", inputs: ["open", "defaults", "active", "connected", "viewerCount", "viewerUrl"], outputs: ["start", "stop", "close"] }, { kind: "component", type: MobileBottomBarComponent, selector: "pptx-mobile-bottom-bar", inputs: ["slideCount", "commentCount", "activeSheet"], outputs: ["openSlides", "insert", "openFormat", "openComments", "notes"] }, { kind: "component", type: MobileMenuSheetComponent, selector: "pptx-mobile-menu-sheet", inputs: ["open", "slideCount", "exporting", "showNotes", "canEdit"], outputs: ["closed", "openFind", "openSorter", "toggleNotes", "insertText", "present", "openFile", "savePptx", "exportPng", "exportPdf", "exportGif", "exportVideo", "print"] }, { kind: "component", type: MobileSlidesSheetComponent, selector: "pptx-mobile-slides-sheet", inputs: ["open", "slides", "canvasSize", "mediaDataUrls", "activeIndex"], outputs: ["closed", "jumpToSlide"] }, { kind: "component", type: MobileToolbarComponent, selector: "pptx-mobile-toolbar", inputs: ["canUndo", "canRedo", "canPresent", "canEdit", "menuOpen"], outputs: ["toggleMenu", "undo", "redo", "save", "present"] }, { kind: "component", type: NotesPanelComponent, selector: "pptx-notes-panel", inputs: ["slide"], outputs: ["update"] }, { kind: "component", type: RibbonComponent, selector: "pptx-ribbon", inputs: ["slideIndex", "slideCount", "canEdit", "selectedElement", "zoomPercent", "formatPainterActive", "canActivateFormatPainter", "exporting", "showGrid", "showRulers", "showGuides", "snapToGrid", "eyedropperActive", "themeGalleryOpen", "sidebarCollapsed", "inspectorOpen", "commentsOpen", "commentCount", "findOpen", "collabConnected", "connectedCount"], outputs: ["prev", "next", "zoomIn", "zoomOut", "zoomReset", "find", "present", "presenter", "share", "broadcast", "openFile", "save", "toggleSidebar", "signatures", "info", "print", "comments", "a11y", "link", "openSorter", "toggleNotes", "toggleFormatPainter", "exportPng", "exportPdf", "exportGif", "exportVideo", "replace", "toggleInspector", "drawToolChange", "toggleThemeGallery", "toggleGrid", "toggleRulers", "toggleGuides", "toggleSelectionPane", "openCustomShows", "toggleSnapToGrid", "toggleEyedropper", "openSmartArtDialog", "openEquationDialog", "openSetUpSlideShow", "openCompare", "openPassword", "openFontEmbedding", "openVersionHistory", "openShortcuts"] }, { kind: "component", type: ThemeGalleryComponent, selector: "pptx-theme-gallery", inputs: ["open", "activeName"], outputs: ["applyTheme", "close"] }, { kind: "component", type: SelectionPaneComponent, selector: "pptx-selection-pane", inputs: ["elements", "selectedIds"], outputs: ["selectElement", "bringForward", "sendBackward", "toggleHidden"] }, { kind: "component", type: CustomShowsComponent, selector: "pptx-custom-shows", inputs: ["open", "slides", "customShows", "activeCustomShowId"], outputs: ["create", "remove", "update", "setActive", "close"] }, { kind: "component", type: InsertSmartArtDialogComponent, selector: "pptx-insert-smart-art-dialog", inputs: ["open"], outputs: ["close", "insert"] }, { kind: "component", type: ViewerExtraDialogsComponent, selector: "pptx-viewer-extra-dialogs", inputs: ["activeSlideIndex", "selectedElementId", "filePath", "customShows"], outputs: ["restoreContent"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
78361
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: SlideCanvasComponent, selector: "pptx-slide-canvas", inputs: ["slide", "canvasSize", "mediaDataUrls", "zoom", "editable", "showGrid", "showRulers", "showGuides", "snapToGrid", "snapToGuides", "autoFit", "interactive", "selectedIds", "editingId", "editTemplateMode", "templateElements", "drawTool", "drawColor", "drawWidth"], outputs: ["elementSelect", "backgroundClick", "transformStart", "transformUpdate", "contextMenu", "textEditStart", "textCommit", "textCancel", "textFormat", "rotateUpdate", "marqueeSelect", "inkStrokeComplete", "eraserHit", "cellCommit", "tableChange"] }, { kind: "component", type: PresentationOverlayComponent, selector: "pptx-presentation-overlay", inputs: ["slides", "canvasSize", "mediaDataUrls", "startIndex"], outputs: ["indexChange", "closed", "annotationsExit"] }, { kind: "component", type: PresenterViewComponent, selector: "pptx-presenter-view", inputs: ["slides", "currentSlideIndex", "canvasSize", "templateElements", "mediaDataUrls", "presentationStartTime", "isAudienceWindowOpen"], outputs: ["movePresentationSlide", "exit", "openAudienceWindow", "closeAudienceWindow"] }, { kind: "component", type: MobilePresenterViewComponent, selector: "pptx-mobile-presenter-view", inputs: ["slides", "currentSlideIndex", "canvasSize", "templateElements", "mediaDataUrls", "presentationStartTime"], outputs: ["movePresentationSlide", "exit"] }, { kind: "component", type: SlideSorterOverlayComponent, selector: "pptx-slide-sorter-overlay", inputs: ["slides", "canvasSize", "mediaDataUrls", "activeIndex"], outputs: ["select", "closed"] }, { kind: "component", type: FindBarComponent, selector: "pptx-find-bar", inputs: ["slides"], outputs: ["navigate", "closed"] }, { kind: "component", type: FindReplaceBarComponent, selector: "pptx-find-replace-bar", inputs: ["matchCount", "matchIndex"], outputs: ["find", "navigate", "replaceOne", "replaceAll", "close"] }, { kind: "component", type: InspectorPanelComponent, selector: "pptx-inspector-panel", inputs: ["element", "slideIndex"] }, { kind: "component", type: SlidesPanelComponent, selector: "pptx-slides-panel", inputs: ["canvasSize", "mediaDataUrls", "activeIndex"], outputs: ["select"] }, { kind: "component", type: StatusBarComponent, selector: "pptx-status-bar", inputs: ["slideIndex", "slideCount", "canEdit", "dirty", "notesOpen", "zoomPercent", "sorterActive", "presenting"], outputs: ["toggleNotes", "normalView", "openSorter", "slideShow", "zoomIn", "zoomOut", "zoomReset"] }, { kind: "component", type: EditorContextMenuComponent, selector: "pptx-editor-context-menu", inputs: ["x", "y", "slideIndex"], outputs: ["closed"] }, { kind: "component", type: ExportProgressModalComponent, selector: "pptx-export-progress-modal", inputs: ["open", "title", "progress", "statusMessage"], outputs: ["cancel"] }, { kind: "component", type: CommentsPanelComponent, selector: "pptx-comments-panel", inputs: ["comments", "authorName"], outputs: ["add", "remove", "resolve"] }, { kind: "component", type: SignaturesPanelComponent, selector: "pptx-signatures-panel", inputs: ["signatures"] }, { kind: "component", type: AccessibilityPanelComponent, selector: "pptx-accessibility-panel", inputs: ["issues"], outputs: ["selectSlide"] }, { kind: "component", type: CollaborationCursorsComponent, selector: "pptx-collaboration-cursors", inputs: ["cursors", "zoom"] }, { kind: "component", type: RemoteSelectionOverlayComponent, selector: "pptx-remote-selection-overlay", inputs: ["presences", "elements", "activeSlideIndex", "zoom"] }, { kind: "component", type: FollowModeBarComponent, selector: "pptx-follow-mode-bar", inputs: ["presences", "followedClientId"], outputs: ["follow"] }, { kind: "component", type: PropertiesDialogComponent, selector: "pptx-properties-dialog", inputs: ["open", "properties"], outputs: ["save", "close"] }, { kind: "component", type: HyperlinkDialogComponent, selector: "pptx-hyperlink-dialog", inputs: ["open", "element"], outputs: ["save", "close"] }, { kind: "component", type: PrintDialogComponent, selector: "pptx-print-dialog", inputs: ["slides", "activeSlideIndex", "defaultSlidesPerPage", "defaultFrameSlides"], outputs: ["print", "cancel"] }, { kind: "component", type: ShareDialogComponent, selector: "pptx-share-dialog", inputs: ["open", "defaults", "active", "connected", "userCount", "shareUrl", "p2p"], outputs: ["start", "stop", "close"] }, { kind: "component", type: BroadcastDialogComponent, selector: "pptx-broadcast-dialog", inputs: ["open", "defaults", "active", "connected", "viewerCount", "viewerUrl", "p2p"], outputs: ["start", "stop", "close"] }, { kind: "component", type: MobileBottomBarComponent, selector: "pptx-mobile-bottom-bar", inputs: ["slideCount", "commentCount", "activeSheet"], outputs: ["openSlides", "insert", "openFormat", "openComments", "notes"] }, { kind: "component", type: MobileMenuSheetComponent, selector: "pptx-mobile-menu-sheet", inputs: ["open", "slideCount", "exporting", "showNotes", "canEdit"], outputs: ["closed", "openFind", "openSorter", "toggleNotes", "insertText", "present", "openFile", "savePptx", "exportPng", "exportPdf", "exportGif", "exportVideo", "print"] }, { kind: "component", type: MobileSlidesSheetComponent, selector: "pptx-mobile-slides-sheet", inputs: ["open", "slides", "canvasSize", "mediaDataUrls", "activeIndex"], outputs: ["closed", "jumpToSlide"] }, { kind: "component", type: MobileToolbarComponent, selector: "pptx-mobile-toolbar", inputs: ["canUndo", "canRedo", "canPresent", "canEdit", "menuOpen"], outputs: ["toggleMenu", "undo", "redo", "save", "present"] }, { kind: "component", type: NotesPanelComponent, selector: "pptx-notes-panel", inputs: ["slide"], outputs: ["update"] }, { kind: "component", type: RibbonComponent, selector: "pptx-ribbon", inputs: ["slideIndex", "slideCount", "canEdit", "selectedElement", "zoomPercent", "formatPainterActive", "canActivateFormatPainter", "exporting", "showGrid", "showRulers", "showGuides", "snapToGrid", "eyedropperActive", "themeGalleryOpen", "sidebarCollapsed", "inspectorOpen", "commentsOpen", "commentCount", "findOpen", "collabConnected", "connectedCount"], outputs: ["prev", "next", "zoomIn", "zoomOut", "zoomReset", "find", "present", "presenter", "share", "broadcast", "openFile", "save", "toggleSidebar", "signatures", "info", "print", "comments", "a11y", "link", "openSorter", "toggleNotes", "toggleFormatPainter", "exportPng", "exportPdf", "exportGif", "exportVideo", "replace", "toggleInspector", "drawToolChange", "toggleThemeGallery", "toggleGrid", "toggleRulers", "toggleGuides", "toggleSelectionPane", "openCustomShows", "toggleSnapToGrid", "toggleEyedropper", "openSmartArtDialog", "openEquationDialog", "openSetUpSlideShow", "openCompare", "openPassword", "openFontEmbedding", "openVersionHistory", "openShortcuts"] }, { kind: "component", type: ThemeGalleryComponent, selector: "pptx-theme-gallery", inputs: ["open", "activeName"], outputs: ["applyTheme", "close"] }, { kind: "component", type: SelectionPaneComponent, selector: "pptx-selection-pane", inputs: ["elements", "selectedIds"], outputs: ["selectElement", "bringForward", "sendBackward", "toggleHidden"] }, { kind: "component", type: CustomShowsComponent, selector: "pptx-custom-shows", inputs: ["open", "slides", "customShows", "activeCustomShowId"], outputs: ["create", "remove", "update", "setActive", "close"] }, { kind: "component", type: InsertSmartArtDialogComponent, selector: "pptx-insert-smart-art-dialog", inputs: ["open"], outputs: ["close", "insert"] }, { kind: "component", type: ViewerExtraDialogsComponent, selector: "pptx-viewer-extra-dialogs", inputs: ["activeSlideIndex", "selectedElementId", "filePath", "customShows"], outputs: ["restoreContent"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
77437
78362
  }
77438
78363
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: PowerPointViewerComponent, decorators: [{
77439
78364
  type: Component,
@@ -77483,6 +78408,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77483
78408
  SignaturesPanelComponent,
77484
78409
  AccessibilityPanelComponent,
77485
78410
  CollaborationCursorsComponent,
78411
+ RemoteSelectionOverlayComponent,
78412
+ FollowModeBarComponent,
77486
78413
  PropertiesDialogComponent,
77487
78414
  HyperlinkDialogComponent,
77488
78415
  PrintDialogComponent,
@@ -77627,7 +78554,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77627
78554
  </nav>
77628
78555
  }
77629
78556
 
77630
- <main class="pptx-ng-main" #mainEl>
78557
+ <main class="pptx-ng-main" #mainEl (pointermove)="onCollabPointerMove($event)">
77631
78558
  <pptx-slide-canvas
77632
78559
  [slide]="activeSlide()"
77633
78560
  [canvasSize]="loader.canvasSize()"
@@ -77665,7 +78592,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77665
78592
  (tableChange)="onTableChange($event)"
77666
78593
  />
77667
78594
  @if (collab.connected()) {
77668
- <pptx-collaboration-cursors [cursors]="collab.cursors()" [zoom]="zoom()" />
78595
+ <pptx-collaboration-cursors [cursors]="collabCursors()" [zoom]="zoom()" />
78596
+ <pptx-remote-selection-overlay
78597
+ [presences]="collab.presence()"
78598
+ [elements]="activeSlide()?.elements ?? []"
78599
+ [activeSlideIndex]="activeSlideIndex()"
78600
+ [zoom]="zoom()"
78601
+ />
78602
+ }
78603
+ @if (collab.active() && collab.presence().length > 0) {
78604
+ <div class="pptx-ng-collab-follow">
78605
+ <pptx-follow-mode-bar
78606
+ [presences]="collab.presence()"
78607
+ [followedClientId]="collab.followedClientId()"
78608
+ (follow)="collab.followUser($event)"
78609
+ />
78610
+ </div>
77669
78611
  }
77670
78612
  @if (showNotes() && !mobile.isMobile()) {
77671
78613
  <aside class="pptx-ng-notes" [attr.aria-label]="'pptx.notes.speakerNotes' | translate">
@@ -77931,6 +78873,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77931
78873
  [connected]="collab.connected()"
77932
78874
  [userCount]="collab.connectedCount()"
77933
78875
  [shareUrl]="session.shareUrl()"
78876
+ [p2p]="session.activeSessionP2p()"
77934
78877
  [defaults]="session.shareDialogDefaults()"
77935
78878
  (start)="session.onShareStart($event)"
77936
78879
  (stop)="session.onShareStop()"
@@ -77943,6 +78886,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77943
78886
  [connected]="collab.connected()"
77944
78887
  [viewerCount]="collab.presence().length"
77945
78888
  [viewerUrl]="session.broadcastViewerUrl()"
78889
+ [p2p]="session.activeSessionP2p()"
77946
78890
  [defaults]="{ serverUrl: shareDefaults()?.serverUrl }"
77947
78891
  (start)="session.onBroadcastStart($event)"
77948
78892
  (stop)="session.onBroadcastStop()"
@@ -78479,6 +79423,9 @@ const translationsEn = {
78479
79423
  'pptx.autosave.justNow': 'just now',
78480
79424
  'pptx.autosave.oneMinAgo': '1 min ago',
78481
79425
  'pptx.autosave.minutesAgo': '{{count}} min ago',
79426
+ 'pptx.autosave.saveFailed': 'Save failed',
79427
+ 'pptx.autosave.savedShort': 'Saved',
79428
+ 'pptx.autosave.allChangesSaved': 'All changes saved',
78482
79429
  // Toolbar
78483
79430
  'pptx.toolbar.toggleSlidesPanel': 'Toggle slides panel',
78484
79431
  'pptx.toolbar.undo': 'Undo',
@@ -78597,7 +79544,17 @@ const translationsEn = {
78597
79544
  'pptx.collaboration.youLabel': '{{name}} (you)',
78598
79545
  'pptx.collaboration.usersConnected': '{{count}} user(s) connected',
78599
79546
  'pptx.collaboration.moreUsers': '{{count}} more user(s)',
79547
+ 'pptx.collaboration.onePersonHere': '1 person here',
79548
+ 'pptx.collaboration.peopleHere': '{{count}} people here',
78600
79549
  'pptx.collaboration.retry': 'Retry',
79550
+ 'pptx.collaboration.retryConnection': 'Retry connection',
79551
+ // Follow mode
79552
+ 'pptx.followMode.following': 'Following',
79553
+ 'pptx.followMode.stop': 'Stop',
79554
+ 'pptx.followMode.stopFollowing': 'Stop following',
79555
+ 'pptx.followMode.followCollaborator': 'Follow a collaborator',
79556
+ 'pptx.followMode.followUser': 'Follow {{name}}',
79557
+ 'pptx.followMode.stopFollowingUser': 'Stop following {{name}}',
78601
79558
  // Share dialog
78602
79559
  'pptx.share.title': 'Share Presentation',
78603
79560
  'pptx.share.collaborationActive': 'Collaboration Active',