pptx-angular-viewer 1.1.57 → 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;
35433
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;
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
@@ -66140,7 +66901,7 @@ class RibbonFontControlsComponent {
66140
66901
  patchTextStyle(this.editor, this.slideIndex(), this.selectedElement(), patch);
66141
66902
  }
66142
66903
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: RibbonFontControlsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
66143
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: RibbonFontControlsComponent, isStandalone: true, selector: "pptx-ribbon-font-controls", inputs: { slideIndex: { classPropertyName: "slideIndex", publicName: "slideIndex", isSignal: true, isRequired: false, transformFunction: null }, selectedElement: { classPropertyName: "selectedElement", publicName: "selectedElement", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
66904
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: RibbonFontControlsComponent, isStandalone: true, selector: "pptx-ribbon-font-controls", inputs: { slideIndex: { classPropertyName: "slideIndex", publicName: "slideIndex", isSignal: true, isRequired: false, transformFunction: null }, selectedElement: { classPropertyName: "selectedElement", publicName: "selectedElement", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "contents" }, ngImport: i0, template: `
66144
66905
  <div class="flex items-center gap-1">
66145
66906
  <select
66146
66907
  class="pptx-rb-select w-28"
@@ -66274,6 +67035,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
66274
67035
  selector: 'pptx-ribbon-font-controls',
66275
67036
  standalone: true,
66276
67037
  changeDetection: ChangeDetectionStrategy.OnPush,
67038
+ host: { class: 'contents' },
66277
67039
  imports: [NgClass, TranslatePipe, RibbonColorPopoverComponent],
66278
67040
  template: `
66279
67041
  <div class="flex items-center gap-1">
@@ -66747,10 +67509,12 @@ class RibbonHomeSectionComponent {
66747
67509
  <span class="pptx-rb-sep"></span>
66748
67510
  <!-- Paragraph -->
66749
67511
  <div class="flex flex-col items-center gap-0.5">
66750
- <pptx-ribbon-paragraph-controls
66751
- [slideIndex]="slideIndex()"
66752
- [selectedElement]="selectedElement()"
66753
- />
67512
+ <div class="flex items-center gap-1">
67513
+ <pptx-ribbon-paragraph-controls
67514
+ [slideIndex]="slideIndex()"
67515
+ [selectedElement]="selectedElement()"
67516
+ />
67517
+ </div>
66754
67518
  <span class="text-[9px] leading-none text-muted-foreground">
66755
67519
  {{ 'pptx.ribbon.paragraph' | translate }}
66756
67520
  </span>
@@ -66853,10 +67617,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
66853
67617
  <span class="pptx-rb-sep"></span>
66854
67618
  <!-- Paragraph -->
66855
67619
  <div class="flex flex-col items-center gap-0.5">
66856
- <pptx-ribbon-paragraph-controls
66857
- [slideIndex]="slideIndex()"
66858
- [selectedElement]="selectedElement()"
66859
- />
67620
+ <div class="flex items-center gap-1">
67621
+ <pptx-ribbon-paragraph-controls
67622
+ [slideIndex]="slideIndex()"
67623
+ [selectedElement]="selectedElement()"
67624
+ />
67625
+ </div>
66860
67626
  <span class="text-[9px] leading-none text-muted-foreground">
66861
67627
  {{ 'pptx.ribbon.paragraph' | translate }}
66862
67628
  </span>
@@ -67023,7 +67789,7 @@ class RibbonInsertFieldsComponent {
67023
67789
  return this.previewDate().toLocaleString();
67024
67790
  }
67025
67791
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: RibbonInsertFieldsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
67026
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: RibbonInsertFieldsComponent, isStandalone: true, selector: "pptx-ribbon-insert-fields", inputs: { slideIndex: { classPropertyName: "slideIndex", publicName: "slideIndex", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
67792
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: RibbonInsertFieldsComponent, isStandalone: true, selector: "pptx-ribbon-insert-fields", inputs: { slideIndex: { classPropertyName: "slideIndex", publicName: "slideIndex", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "contents" }, ngImport: i0, template: `
67027
67793
  <!-- Action Buttons dropdown (hover-reveal, mirrors React/Vue) -->
67028
67794
  <div class="group relative">
67029
67795
  <button
@@ -67182,6 +67948,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
67182
67948
  selector: 'pptx-ribbon-insert-fields',
67183
67949
  standalone: true,
67184
67950
  changeDetection: ChangeDetectionStrategy.OnPush,
67951
+ host: { class: 'contents' },
67185
67952
  imports: [TranslatePipe],
67186
67953
  template: `
67187
67954
  <!-- Action Buttons dropdown (hover-reveal, mirrors React/Vue) -->
@@ -69652,24 +70419,29 @@ function seedShareFields(defaults) {
69652
70419
  serverUrl: defaults?.serverUrl ?? '',
69653
70420
  };
69654
70421
  }
69655
- /** 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
+ */
69656
70427
  function canStartShare(fields) {
69657
- return (fields.roomId.trim().length > 0 &&
69658
- fields.userName.trim().length > 0 &&
69659
- fields.serverUrl.trim().length > 0);
70428
+ return fields.roomId.trim().length > 0 && fields.userName.trim().length > 0;
69660
70429
  }
69661
70430
  /**
69662
70431
  * Assemble a {@link CollaborationConfig} from the (trimmed) form fields, or
69663
- * `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.
69664
70434
  */
69665
70435
  function buildCollaborationConfig(fields) {
69666
70436
  if (!canStartShare(fields)) {
69667
70437
  return null;
69668
70438
  }
70439
+ const serverUrl = fields.serverUrl.trim();
69669
70440
  return {
69670
70441
  roomId: fields.roomId.trim(),
69671
70442
  userName: fields.userName.trim(),
69672
- serverUrl: fields.serverUrl.trim(),
70443
+ serverUrl,
70444
+ transport: resolveTransportForServerUrl(serverUrl),
69673
70445
  };
69674
70446
  }
69675
70447
  /**
@@ -69681,7 +70453,11 @@ function buildShareUrl(roomId, serverUrl, location) {
69681
70453
  return roomId;
69682
70454
  }
69683
70455
  const room = encodeURIComponent(roomId);
69684
- 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);
69685
70461
  return `${location.origin}${location.pathname}?room=${room}&server=${server}`;
69686
70462
  }
69687
70463
 
@@ -69717,6 +70493,9 @@ class ShareDialogComponent {
69717
70493
  /** Shareable join link surfaced while the session is active. */
69718
70494
  shareUrl = input('', /* @ts-ignore */
69719
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 */ []));
69720
70499
  /** Fired with the assembled config when the user starts sharing. */
69721
70500
  start = output();
69722
70501
  /** Fired when the user stops an active session. */
@@ -69737,6 +70516,9 @@ class ShareDialogComponent {
69737
70516
  serverUrl: this.serverUrl(),
69738
70517
  }), /* @ts-ignore */
69739
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 */ []));
69740
70522
  canCopy = computed(() => canUseClipboard(typeof navigator !== 'undefined' ? navigator : undefined), /* @ts-ignore */
69741
70523
  ...(ngDevMode ? [{ debugName: "canCopy" }] : /* istanbul ignore next */ []));
69742
70524
  constructor() {
@@ -69783,7 +70565,7 @@ class ShareDialogComponent {
69783
70565
  this.stop.emit();
69784
70566
  }
69785
70567
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: ShareDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
69786
- 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: `
69787
70569
  <pptx-modal-dialog
69788
70570
  [open]="open()"
69789
70571
  [title]="(active() ? 'pptx.share.activeTitle' : 'pptx.toolbar.share') | translate"
@@ -69810,6 +70592,14 @@ class ShareDialogComponent {
69810
70592
  </span>
69811
70593
  </div>
69812
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
+
69813
70603
  @if (shareUrl()) {
69814
70604
  <div class="pptx-ng-share-field">
69815
70605
  <label class="pptx-ng-share-label">{{
@@ -69886,6 +70676,9 @@ class ShareDialogComponent {
69886
70676
  [value]="serverUrl()"
69887
70677
  (input)="serverUrl.set(asValue($event))"
69888
70678
  />
70679
+ @if (isP2p()) {
70680
+ <p class="pptx-ng-share-hint">{{ 'pptx.share.p2pHint' | translate }}</p>
70681
+ }
69889
70682
  </div>
69890
70683
  </div>
69891
70684
  }
@@ -69937,6 +70730,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
69937
70730
  </span>
69938
70731
  </div>
69939
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
+
69940
70741
  @if (shareUrl()) {
69941
70742
  <div class="pptx-ng-share-field">
69942
70743
  <label class="pptx-ng-share-label">{{
@@ -70013,6 +70814,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
70013
70814
  [value]="serverUrl()"
70014
70815
  (input)="serverUrl.set(asValue($event))"
70015
70816
  />
70817
+ @if (isP2p()) {
70818
+ <p class="pptx-ng-share-hint">{{ 'pptx.share.p2pHint' | translate }}</p>
70819
+ }
70016
70820
  </div>
70017
70821
  </div>
70018
70822
  }
@@ -70034,7 +70838,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
70034
70838
  </div>
70035
70839
  </pptx-modal-dialog>
70036
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"] }]
70037
- }], 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"] }] } });
70038
70842
 
70039
70843
  /**
70040
70844
  * signatures-helpers.ts: Pure functions for digital-signature status
@@ -71556,6 +72360,34 @@ class ViewerCollaborationSessionService {
71556
72360
  : '';
71557
72361
  }, /* @ts-ignore */
71558
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
+ }
71559
72391
  /**
71560
72392
  * Seed values for the Share dialog: the host-supplied `shareDefaults`, with
71561
72393
  * `userName` falling back to `authorName` (then "You") so the local user's
@@ -71577,9 +72409,7 @@ class ViewerCollaborationSessionService {
71577
72409
  syncHostConfig(config) {
71578
72410
  if (config) {
71579
72411
  this.activeSession.set({ roomId: config.roomId, serverUrl: config.serverUrl });
71580
- void this.collab.connect(config, {
71581
- getTemplateElements: () => this.requireHost().getTemplateElements(),
71582
- });
72412
+ this.connectWithBaseline(config);
71583
72413
  }
71584
72414
  else {
71585
72415
  this.collab.disconnect();
@@ -71599,9 +72429,7 @@ class ViewerCollaborationSessionService {
71599
72429
  roomId: collaboratorConfig.roomId,
71600
72430
  serverUrl: collaboratorConfig.serverUrl,
71601
72431
  });
71602
- void this.collab.connect(collaboratorConfig, {
71603
- getTemplateElements: () => host.getTemplateElements(),
71604
- });
72432
+ this.connectWithBaseline(collaboratorConfig);
71605
72433
  host.emitStart(collaboratorConfig);
71606
72434
  }
71607
72435
  onShareStop() {
@@ -71615,13 +72443,12 @@ class ViewerCollaborationSessionService {
71615
72443
  const collabConfig = {
71616
72444
  roomId: config.roomId,
71617
72445
  serverUrl: config.serverUrl,
72446
+ transport: config.transport,
71618
72447
  userName: host.authorName() ?? 'Presenter',
71619
72448
  role: 'owner',
71620
72449
  };
71621
72450
  this.activeSession.set({ roomId: config.roomId, serverUrl: config.serverUrl });
71622
- void this.collab.connect(collabConfig, {
71623
- getTemplateElements: () => host.getTemplateElements(),
71624
- });
72451
+ this.connectWithBaseline(collabConfig);
71625
72452
  host.emitStart(collabConfig);
71626
72453
  }
71627
72454
  onBroadcastStop() {
@@ -76027,6 +76854,14 @@ class PowerPointViewerComponent {
76027
76854
  ...(ngDevMode ? [{ debugName: "zoom" }] : /* istanbul ignore next */ []));
76028
76855
  zoomPercent = computed(() => Math.round(this.zoom() * 100), /* @ts-ignore */
76029
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;
76030
76865
  /** Fullscreen presentation-mode overlay visibility. */
76031
76866
  presenting = signal(false, /* @ts-ignore */
76032
76867
  ...(ngDevMode ? [{ debugName: "presenting" }] : /* istanbul ignore next */ []));
@@ -76291,6 +77126,47 @@ class PowerPointViewerComponent {
76291
77126
  effect(() => {
76292
77127
  this.session.syncHostConfig(this.collaboration());
76293
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
+ });
76294
77170
  // Hand the export/print orchestrator the live navigation signal + deck
76295
77171
  // accessors + stage resolver so it can flip the stage and capture slides.
76296
77172
  this.xport.bind({
@@ -76312,6 +77188,10 @@ class PowerPointViewerComponent {
76312
77188
  authorName: () => this.authorName(),
76313
77189
  shareDefaults: () => this.shareDefaults(),
76314
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(),
76315
77195
  emitStart: (config) => this.startCollaboration.emit(config),
76316
77196
  emitStop: () => this.stopCollaboration.emit(),
76317
77197
  });
@@ -76367,6 +77247,40 @@ class PowerPointViewerComponent {
76367
77247
  goNext() {
76368
77248
  this.goTo(this.activeSlideIndex() + 1);
76369
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
+ }
76370
77284
  // ── Theme gallery (Design tab) ─────────────────────────────────────────────
76371
77285
  /**
76372
77286
  * Apply a built-in theme preset to the whole deck.
@@ -77004,7 +77918,7 @@ class PowerPointViewerComponent {
77004
77918
  </nav>
77005
77919
  }
77006
77920
 
77007
- <main class="pptx-ng-main" #mainEl>
77921
+ <main class="pptx-ng-main" #mainEl (pointermove)="onCollabPointerMove($event)">
77008
77922
  <pptx-slide-canvas
77009
77923
  [slide]="activeSlide()"
77010
77924
  [canvasSize]="loader.canvasSize()"
@@ -77042,7 +77956,22 @@ class PowerPointViewerComponent {
77042
77956
  (tableChange)="onTableChange($event)"
77043
77957
  />
77044
77958
  @if (collab.connected()) {
77045
- <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>
77046
77975
  }
77047
77976
  @if (showNotes() && !mobile.isMobile()) {
77048
77977
  <aside class="pptx-ng-notes" [attr.aria-label]="'pptx.notes.speakerNotes' | translate">
@@ -77308,6 +78237,7 @@ class PowerPointViewerComponent {
77308
78237
  [connected]="collab.connected()"
77309
78238
  [userCount]="collab.connectedCount()"
77310
78239
  [shareUrl]="session.shareUrl()"
78240
+ [p2p]="session.activeSessionP2p()"
77311
78241
  [defaults]="session.shareDialogDefaults()"
77312
78242
  (start)="session.onShareStart($event)"
77313
78243
  (stop)="session.onShareStop()"
@@ -77320,6 +78250,7 @@ class PowerPointViewerComponent {
77320
78250
  [connected]="collab.connected()"
77321
78251
  [viewerCount]="collab.presence().length"
77322
78252
  [viewerUrl]="session.broadcastViewerUrl()"
78253
+ [p2p]="session.activeSessionP2p()"
77323
78254
  [defaults]="{ serverUrl: shareDefaults()?.serverUrl }"
77324
78255
  (start)="session.onBroadcastStart($event)"
77325
78256
  (stop)="session.onBroadcastStop()"
@@ -77427,7 +78358,7 @@ class PowerPointViewerComponent {
77427
78358
  />
77428
78359
  }
77429
78360
  </div>
77430
- `, 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 });
77431
78362
  }
77432
78363
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: PowerPointViewerComponent, decorators: [{
77433
78364
  type: Component,
@@ -77477,6 +78408,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77477
78408
  SignaturesPanelComponent,
77478
78409
  AccessibilityPanelComponent,
77479
78410
  CollaborationCursorsComponent,
78411
+ RemoteSelectionOverlayComponent,
78412
+ FollowModeBarComponent,
77480
78413
  PropertiesDialogComponent,
77481
78414
  HyperlinkDialogComponent,
77482
78415
  PrintDialogComponent,
@@ -77621,7 +78554,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77621
78554
  </nav>
77622
78555
  }
77623
78556
 
77624
- <main class="pptx-ng-main" #mainEl>
78557
+ <main class="pptx-ng-main" #mainEl (pointermove)="onCollabPointerMove($event)">
77625
78558
  <pptx-slide-canvas
77626
78559
  [slide]="activeSlide()"
77627
78560
  [canvasSize]="loader.canvasSize()"
@@ -77659,7 +78592,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77659
78592
  (tableChange)="onTableChange($event)"
77660
78593
  />
77661
78594
  @if (collab.connected()) {
77662
- <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>
77663
78611
  }
77664
78612
  @if (showNotes() && !mobile.isMobile()) {
77665
78613
  <aside class="pptx-ng-notes" [attr.aria-label]="'pptx.notes.speakerNotes' | translate">
@@ -77925,6 +78873,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77925
78873
  [connected]="collab.connected()"
77926
78874
  [userCount]="collab.connectedCount()"
77927
78875
  [shareUrl]="session.shareUrl()"
78876
+ [p2p]="session.activeSessionP2p()"
77928
78877
  [defaults]="session.shareDialogDefaults()"
77929
78878
  (start)="session.onShareStart($event)"
77930
78879
  (stop)="session.onShareStop()"
@@ -77937,6 +78886,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImpor
77937
78886
  [connected]="collab.connected()"
77938
78887
  [viewerCount]="collab.presence().length"
77939
78888
  [viewerUrl]="session.broadcastViewerUrl()"
78889
+ [p2p]="session.activeSessionP2p()"
77940
78890
  [defaults]="{ serverUrl: shareDefaults()?.serverUrl }"
77941
78891
  (start)="session.onBroadcastStart($event)"
77942
78892
  (stop)="session.onBroadcastStop()"
@@ -78473,6 +79423,9 @@ const translationsEn = {
78473
79423
  'pptx.autosave.justNow': 'just now',
78474
79424
  'pptx.autosave.oneMinAgo': '1 min ago',
78475
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',
78476
79429
  // Toolbar
78477
79430
  'pptx.toolbar.toggleSlidesPanel': 'Toggle slides panel',
78478
79431
  'pptx.toolbar.undo': 'Undo',
@@ -78591,7 +79544,17 @@ const translationsEn = {
78591
79544
  'pptx.collaboration.youLabel': '{{name}} (you)',
78592
79545
  'pptx.collaboration.usersConnected': '{{count}} user(s) connected',
78593
79546
  'pptx.collaboration.moreUsers': '{{count}} more user(s)',
79547
+ 'pptx.collaboration.onePersonHere': '1 person here',
79548
+ 'pptx.collaboration.peopleHere': '{{count}} people here',
78594
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}}',
78595
79558
  // Share dialog
78596
79559
  'pptx.share.title': 'Share Presentation',
78597
79560
  'pptx.share.collaborationActive': 'Collaboration Active',