Skip to content

Commit 6b2d1ea

Browse files
authored
SD-58 (part 2) - Editable ranges (#1618)
* feat: added permStart/permEnd translators and schema * chore: removed logs * feat: allow editing sections with permStart/permEnd * feat: added highlight for permStart/permEnd * fix: show permStart/permEnd highlights in other document modes * fix: edge cases * refactor: removed unused class * refactor: removed unused code * fix: overlay styles * refactor: removed unused code * chore: change permission island background color * feat: allow users in permStart/permEnd * fix: infinite loop * chore: small code tweaks * chore: added docs * chore: added docs * refactor: rename functions * chore: rename test * chore: simplified code * fix: tests * fix: tests * chore: removed unused fns
1 parent d2a098e commit 6b2d1ea

8 files changed

Lines changed: 783 additions & 22 deletions

File tree

packages/layout-engine/painters/dom/src/styles.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const lineStyles = (lineHeight: number): Partial<CSSStyleDeclaration> =>
8787
// provides defense-in-depth against any remaining sub-pixel rendering
8888
// differences between measurement and display.
8989
overflow: 'visible',
90+
zIndex: '10',
9091
});
9192

9293
const PRINT_STYLES = `
@@ -422,6 +423,7 @@ const SDT_CONTAINER_STYLES = `
422423
border: 1px solid #629be7;
423424
position: relative;
424425
display: inline;
426+
z-index: 10;
425427
}
426428
427429
/* Hover effect for inline structured content */

packages/super-editor/src/assets/styles/layout/global.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ a {
2424
animation: superdoc-caret-blink 1.2s steps(2, start) infinite;
2525
}
2626

27+
.presentation-editor__permission-overlay {
28+
pointer-events: none;
29+
}
30+
31+
.presentation-editor__permission-highlight {
32+
border-radius: 2px;
33+
background-color: rgba(199, 200, 217, 0.55);
34+
}
35+
2736
@keyframes superdoc-caret-blink {
2837
0%,
2938
45% {

packages/super-editor/src/core/PresentationEditor.ts

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,7 @@ export class PresentationEditor extends EventEmitter {
580580
#viewportHost: HTMLElement;
581581
#painterHost: HTMLElement;
582582
#selectionOverlay: HTMLElement;
583+
#permissionOverlay: HTMLElement | null = null;
583584
#hiddenHost: HTMLElement;
584585
#layoutOptions: LayoutEngineOptions;
585586
#layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() };
@@ -763,6 +764,17 @@ export class PresentationEditor extends EventEmitter {
763764
});
764765
this.#domIndexObserverManager.setup();
765766
this.#selectionSync.on('render', () => this.#updateSelection());
767+
this.#selectionSync.on('render', () => this.#updatePermissionOverlay());
768+
769+
this.#permissionOverlay = doc.createElement('div');
770+
this.#permissionOverlay.className = 'presentation-editor__permission-overlay';
771+
Object.assign(this.#permissionOverlay.style, {
772+
position: 'absolute',
773+
inset: '0',
774+
pointerEvents: 'none',
775+
zIndex: '5',
776+
});
777+
this.#viewportHost.appendChild(this.#permissionOverlay);
766778

767779
// Create dual-layer overlay structure
768780
// Container holds both remote (below) and local (above) layers
@@ -862,9 +874,9 @@ export class PresentationEditor extends EventEmitter {
862874
const normalizedEditorProps = {
863875
...(editorOptions.editorProps ?? {}),
864876
editable: () => {
865-
// Hidden editor respects documentMode for plugin compatibility
866-
// but remains visually/interactively inert (handled by hidden container CSS)
867-
return this.#documentMode !== 'viewing';
877+
// Hidden editor respects documentMode for plugin compatibility,
878+
// but permission ranges may temporarily re-enable editing.
879+
return !this.#isViewLocked();
868880
},
869881
};
870882
try {
@@ -1358,6 +1370,7 @@ export class PresentationEditor extends EventEmitter {
13581370
this.#pendingDocChange = true;
13591371
this.#scheduleRerender();
13601372
}
1373+
this.#updatePermissionOverlay();
13611374
}
13621375

13631376
#syncDocumentModeClass() {
@@ -3083,7 +3096,7 @@ export class PresentationEditor extends EventEmitter {
30833096
win as Window,
30843097
this.#visibleHost,
30853098
() => this.#getActiveDomTarget(),
3086-
() => this.#documentMode !== 'viewing',
3099+
() => !this.#isViewLocked(),
30873100
);
30883101
this.#inputBridge.bind();
30893102
}
@@ -4257,7 +4270,7 @@ export class PresentationEditor extends EventEmitter {
42574270
this.#dragUsedPageNotMountedFallback = false;
42584271
return;
42594272
}
4260-
if (this.#session.mode !== 'body' || this.#documentMode === 'viewing') {
4273+
if (this.#session.mode !== 'body' || this.#isViewLocked()) {
42614274
this.#dragLastPointer = null;
42624275
this.#dragLastRawHit = null;
42634276
this.#dragUsedPageNotMountedFallback = false;
@@ -4665,6 +4678,7 @@ export class PresentationEditor extends EventEmitter {
46654678
this.#epochMapper.onLayoutComplete(layoutEpoch);
46664679
this.#selectionSync.onLayoutComplete(layoutEpoch);
46674680
layoutCompleted = true;
4681+
this.#updatePermissionOverlay();
46684682

46694683
// Reset error state on successful layout
46704684
this.#layoutError = null;
@@ -4773,7 +4787,7 @@ export class PresentationEditor extends EventEmitter {
47734787
}
47744788

47754789
// In viewing mode, don't render caret or selection highlights
4776-
if (this.#documentMode === 'viewing') {
4790+
if (this.#isViewLocked()) {
47774791
try {
47784792
this.#localSelectionLayer.innerHTML = '';
47794793
} catch (error) {
@@ -4877,6 +4891,87 @@ export class PresentationEditor extends EventEmitter {
48774891
}
48784892
}
48794893

4894+
/**
4895+
* Updates the permission overlay (w:permStart/w:permEnd) to match the current editor permission ranges.
4896+
*
4897+
* This method is called after layout completes to ensure permission overlay
4898+
* is based on stable permission ranges data.
4899+
*/
4900+
#updatePermissionOverlay() {
4901+
const overlay = this.#permissionOverlay;
4902+
if (!overlay) {
4903+
return;
4904+
}
4905+
4906+
if (this.#session.mode !== 'body') {
4907+
overlay.innerHTML = '';
4908+
return;
4909+
}
4910+
4911+
const permissionStorage = (this.#editor as Editor & { storage?: Record<string, any> })?.storage?.permissionRanges;
4912+
const ranges: Array<{ from: number; to: number }> = permissionStorage?.ranges ?? [];
4913+
const shouldRender = ranges.length > 0;
4914+
4915+
if (!shouldRender) {
4916+
overlay.innerHTML = '';
4917+
return;
4918+
}
4919+
4920+
const layout = this.#layoutState.layout;
4921+
if (!layout) {
4922+
overlay.innerHTML = '';
4923+
return;
4924+
}
4925+
4926+
const docEpoch = this.#epochMapper.getCurrentEpoch();
4927+
// The visible layout DOM does not match the current document state.
4928+
// Avoid rendering a "best effort" permission overlay that would drift.
4929+
if (this.#layoutEpoch < docEpoch) {
4930+
return;
4931+
}
4932+
4933+
const pageHeight = this.#getBodyPageHeight();
4934+
const pageGap = layout.pageGap ?? this.#getEffectivePageGap();
4935+
const fragment = overlay.ownerDocument?.createDocumentFragment();
4936+
if (!fragment) {
4937+
overlay.innerHTML = '';
4938+
return;
4939+
}
4940+
4941+
ranges.forEach(({ from, to }) => {
4942+
const rects = this.#computeSelectionRectsFromDom(from, to);
4943+
if (!rects?.length) {
4944+
return;
4945+
}
4946+
rects.forEach((rect) => {
4947+
const pageLocalY = rect.y - rect.pageIndex * (pageHeight + pageGap);
4948+
const coords = this.#convertPageLocalToOverlayCoords(rect.pageIndex, rect.x, pageLocalY);
4949+
if (!coords) {
4950+
return;
4951+
}
4952+
const highlight = overlay.ownerDocument?.createElement('div');
4953+
if (!highlight) {
4954+
return;
4955+
}
4956+
highlight.className = 'presentation-editor__permission-highlight';
4957+
Object.assign(highlight.style, {
4958+
position: 'absolute',
4959+
left: `${coords.x}px`,
4960+
top: `${coords.y}px`,
4961+
width: `${Math.max(1, rect.width)}px`,
4962+
height: `${Math.max(1, rect.height)}px`,
4963+
borderRadius: '2px',
4964+
pointerEvents: 'none',
4965+
zIndex: 1,
4966+
});
4967+
fragment.appendChild(highlight);
4968+
});
4969+
});
4970+
4971+
overlay.innerHTML = '';
4972+
overlay.appendChild(fragment);
4973+
}
4974+
48804975
#resolveLayoutOptions(blocks: FlowBlock[] | undefined, sectionMetadata: SectionMetadata[]) {
48814976
const defaults = this.#computeDefaultLayoutDefaults();
48824977
const firstSection = blocks?.find(
@@ -5873,7 +5968,7 @@ export class PresentationEditor extends EventEmitter {
58735968
}
58745969

58755970
#validateHeaderFooterEditPermission(): { allowed: boolean; reason?: string } {
5876-
if (this.#documentMode === 'viewing') {
5971+
if (this.#isViewLocked()) {
58775972
return { allowed: false, reason: 'documentMode' };
58785973
}
58795974
if (!this.#editor.isEditable) {
@@ -6905,6 +7000,19 @@ export class PresentationEditor extends EventEmitter {
69057000
this.#errorBannerMessage = null;
69067001
}
69077002

7003+
/**
7004+
* Determines whether the current viewing mode should block edits.
7005+
* When documentMode is viewing but the active editor has been toggled
7006+
* back to editable (e.g. permission ranges), we treat the view as editable.
7007+
*/
7008+
#isViewLocked(): boolean {
7009+
if (this.#documentMode !== 'viewing') return false;
7010+
const hasPermissionOverride = !!(this.#editor as Editor & { storage?: Record<string, any> })?.storage
7011+
?.permissionRanges?.hasAllowedRanges;
7012+
if (hasPermissionOverride) return false;
7013+
return this.#documentMode === 'viewing';
7014+
}
7015+
69087016
/**
69097017
* Applies vertical alignment and font scaling to layout DOM elements for subscript/superscript rendering.
69107018
*

packages/super-editor/src/core/header-footer/EditorOverlayManager.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,6 @@ export class EditorOverlayManager {
9494
/** Full-width border line element (MS Word style) */
9595
#borderLine: HTMLElement | null = null;
9696

97-
/** Dimming overlay element (for dimming body content during editing) */
98-
#dimmingOverlay: HTMLElement | null = null;
99-
10097
/**
10198
* Creates a new EditorOverlayManager instance.
10299
*
@@ -466,18 +463,6 @@ export class EditorOverlayManager {
466463
}
467464
}
468465

469-
/**
470-
* Hides and removes the dimming overlay.
471-
* @internal Reserved for future implementation of body dimming during header/footer editing.
472-
*/
473-
// eslint-disable-next-line no-unused-private-class-members
474-
#hideDimmingOverlay(): void {
475-
if (this.#dimmingOverlay) {
476-
this.#dimmingOverlay.remove();
477-
this.#dimmingOverlay = null;
478-
}
479-
}
480-
481466
/**
482467
* Shows a full-width border line at the bottom of the header or top of the footer.
483468
* This creates the MS Word style visual indicator spanning edge-to-edge of the page.

packages/super-editor/src/extensions/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,13 @@ import { LinkedStyles } from './linked-styles/linked-styles.js';
6666
import { Search } from './search/index.js';
6767
import { NodeResizer } from './noderesizer/index.js';
6868
import { CustomSelection } from './custom-selection/index.js';
69+
import { PermissionRanges } from './permission-ranges/index.js';
6970

7071
// Permissions
7172
import { PermStart } from './perm-start/index.js';
7273
import { PermEnd } from './perm-end/index.js';
7374

75+
7476
// Helpers
7577
import { trackChangesHelpers } from './track-changes/index.js';
7678

@@ -186,6 +188,7 @@ const getStarterExtensions = () => {
186188
ShapeGroup,
187189
PermStart,
188190
PermEnd,
191+
PermissionRanges,
189192
PassthroughInline,
190193
PassthroughBlock,
191194
];
@@ -257,4 +260,5 @@ export {
257260
ShapeGroup,
258261
PassthroughInline,
259262
PassthroughBlock,
263+
PermissionRanges,
260264
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { PermissionRanges } from './permission-ranges.js';

0 commit comments

Comments
 (0)