-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathhighlight.ts
252 lines (236 loc) · 9.5 KB
/
highlight.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import { isDebugging, log } from "./modifyGUI";
const highlightArea = document.querySelector<HTMLElement>('#highlight-area')!;
const styleProperties = [
'border-radius', 'border-top-width',
'border-right-width', 'border-bottom-width', 'border-left-width',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left'
];
let selections: Map<HTMLElement, Highlight> = new Map;
export function highlightHtml(iframeDoc: IframeDoc, ids?: (number | string)[]) {
// If ids is not specified, update previous highlights to the corresponding
// element sizes.
if (!ids) {
selections.forEach(highlight => {
const styles = getHighlightStyles(highlight.target!);
rehighlight(highlight, styles);
});
return;
}
const newSelections: Map<HTMLElement, Highlight> = new Map;
const sel = ids.map(id => getIdAttribute(id)).join()
const highlights = (
(sel.length ? [...iframeDoc.querySelectorAll<HTMLElement>(sel)] : [])
).filter(el => el !== iframeDoc.body && iframeDoc.body.contains(el));
let hasScrolled = false;
log(highlights, 'Highlighting selectors ' + sel + '...');
highlights.forEach(target => {
const styles = getHighlightStyles(target);
if (selections.has(target)) {
const highlight = selections.get(target)!;
rehighlight(highlight, styles);
selections.delete(target);
newSelections.set(target, highlight);
return;
}
const { highlightStyles, horizontalStyles, verticalStyles } = styles;
const highlight = addHighlightPart(highlightArea, '', highlightStyles);
addHighlightPart(highlight, '-horizontal', horizontalStyles);
addHighlightPart(highlight, '-vertical', verticalStyles);
newSelections.set(target, highlight);
highlight.target = target;
!hasScrolled && (hasScrolled = scrollToView(target, highlightStyles));
});
selections.forEach(highlight => {
delete highlight.target;
highlightArea.removeChild(highlight);
});
selections.clear();
selections = newSelections;
// If the highlighted elements' parents are all unscrollable, manually add
// appearing effect for newly selected elements.
!hasScrolled && Scroller.highlight();
}
export function highlightCss(ids: string[], fileRel: string) {
const sel = 'iframe[showing=true]';
const iframe = document.querySelector<HTMLIFrameElement>(sel);
if (!iframe) return;
const iframeDoc = iframe.contentDocument as IframeDoc;
const links = [...iframeDoc.querySelectorAll('link')];
if (links.some(link => (
link.href.startsWith(location.href) &&
link.href.slice(location.href.length) === fileRel
))) { highlightHtml(iframeDoc, ids); return }
const style = iframeDoc.querySelector('#' + generateStyleId(fileRel));
style && highlightHtml(iframeDoc, ids);
function generateStyleId(url: string) {
const encodedUrl = btoa(encodeURIComponent(url)).replace(/[+/=]/g, '_');
return 'lively-style-' + encodedUrl;
}
}
// If the id is already a selector, return it. Otherwise, generate an attribute
// selector for the id aka the position in the editor.
function getIdAttribute(id: number | string) {
if (typeof id === 'string') return id;
return '[lively-position="' + id + '"]';
}
function getHighlightStyles(target: HTMLElement) {
// TODO: getBoundingClientRect is not accurate for elements with transforms
// getBoundingClientRect get the sizes of the elemnent including paddings
// and borders.
const { width, height, left, top } = target.getBoundingClientRect();
const computedStyle = window.getComputedStyle(target);
const attributes: K = {};
styleProperties.forEach(attr => {
attributes[attr] = +computedStyle.getPropertyValue(attr).slice(0, -2);
});
const {
'border-radius' : r,
'border-top-width' : bt, 'border-bottom-width': bb,
'border-left-width': bl, 'border-right-width' : br,
'padding-top' : pt, 'padding-bottom': pb,
'padding-left': pl, 'padding-right' : pr,
'margin-top' : mt, 'margin-bottom' : mb,
'margin-left' : ml, 'margin-right' : mr
} = attributes;
return {
highlightStyles: {
width: width + ml + mr, height: height + mt + mb,
left: left - ml, top: top - mt, borderRadius: r
},
horizontalStyles: {
height: height - bt - bb - pt - pb + 2,
top: mt + pt + bt - 1
},
verticalStyles: {
width: width - bl - br - pl - pr + 2,
left: ml + pl + bl - 1
}
};
}
function rehighlight(highlight: Highlight, styles: K) {
const horizontalSel = '.highlight-horizontal';
const verticalSel = '.highlight-vertical';
const horizontal = highlight.querySelector<HTMLElement>(horizontalSel)!;
const vertical = highlight.querySelector<HTMLElement>(verticalSel)!;
const { highlightStyles, horizontalStyles, verticalStyles } = styles;
setHighlightPart(highlight, highlightStyles);
setHighlightPart(horizontal, horizontalStyles);
setHighlightPart(vertical, verticalStyles);
}
function setHighlightPart(el: HTMLElement, styles: K) {
const style = el.style as K;
for (const attr in styles) { style[attr] = styles[attr] + 'px' }
}
function addHighlightPart(el: HTMLElement, suffix: HighlightPart, styles: K) {
const div = document.createElement('div') as Highlight;
div.classList.add('highlight' + suffix);
setHighlightPart(div, styles);
el.appendChild(div);
return div;
}
// Recursively scroll element to the viewport of its parent.
function scrollToView(el: HTMLElement, bound: Bound): boolean {
if (isDebugging()) {
const outerHTML = el.outerHTML;
const content = outerHTML.slice(0, outerHTML.length - el.innerHTML.length);
const dummy = content.replace(/'.*?'|".*?"/g, x => 'x'.repeat(x.length));
const elementTag = content.slice(0, 1 + dummy.indexOf('>'));
log(`Focusing on HTML element ${elementTag}...`, 'info');
}
const { width, height, left, top } = bound;
const parent = el.parentElement!;
const isMainParent = parent.tagName.toUpperCase() === 'BODY';
const styles = window.getComputedStyle(parent);
const scrollX = styles.getPropertyValue('overflow-x');
const scrollY = styles.getPropertyValue('overflow-y');
const isScrollable = (
scrollX === 'auto' || scrollX === 'scroll' ||
scrollY === 'auto' || scrollY === 'scroll' ||
isMainParent && scrollX !== 'hidden' && scrollY !== 'hidden'
);
// If the parent node is unscrollable, scroll the element to the viewport of
// its grandparent node.
if (!isScrollable) return !isMainParent && scrollToView(parent, bound);
let vx = 0, vy = 0, vw, vh;
if (isMainParent) {
({ innerWidth: vw, innerHeight: vh } = window);
} else {
const { width, height, top, left } = parent.getBoundingClientRect();
scrollToView(parent, { width, height, top, left });
[vx, vy, vw, vh] = [left, top, width, height];
}
const dx = left - vx, dy = top - vy;
// If the element is larger than the viewport, either scroll the parent to
// see the top or bottom of the element.
if (width > vw) {
// The top edge of the element is below the viewport.
dx > 0 && Scroller.setDeltaX(parent, dx);
// The bottom edge of the element is above the viewport.
dx + width < vw && Scroller.setDeltaX(parent, dx + width - vw);
} else Scroller.setDeltaX(parent, dx + width / 2 - vw / 2);
if (height > vh) {
dy > 0 && Scroller.setDeltaY(parent, dy);
dy + height < vh && Scroller.setDeltaY(parent, dy + height - vh);
} else Scroller.setDeltaY(parent, dy + height / 2 - vh / 2);
return true;
}
// The job is to add dx/dy to the viewport's scroll position, so a large
// portion, i.e. maxSpeed, is added at first, then the scrolling slow down when
// awareDist is reached and stop when the remaining portion is small enough,
// i.e. below EPSION.
class Scroller {
EPSILON = 1e-1
maxSpeed = 50
awareDist = 100
ratio = this.maxSpeed / this.awareDist
element: HTMLElement | null
dx = 0
dy = 0
constructor(element: HTMLElement) { this.element = element }
callback() {
Scroller.scrollers.delete(this.element!);
this.element = null;
Scroller.highlight();
}
setTarget(targetDiff: number, axis: 'x' | 'y') {
axis === 'x' && (this.dx = targetDiff);
axis === 'y' && (this.dy = targetDiff);
this.scroll();
}
scroll() {
if (!this.isScrolling()) { this.callback(); return }
const { EPSILON, awareDist, ratio, element, dx, dy } = this;
const { abs, min, sign } = Math;
if (abs(dx) > EPSILON) {
const delta = sign(dx) * min(awareDist, abs(dx)) * ratio;
element!.scrollLeft += delta, this.dx -= delta;
}
if (abs(dy) > EPSILON) {
const delta = sign(dy) * min(awareDist, abs(dy)) * ratio;
element!.scrollTop += delta, this.dy -= delta;
}
requestAnimationFrame(this.scroll.bind(this));
}
isScrolling() {
const { EPSILON, dx: targetX, dy: targetY } = this, abs = Math.abs;
return abs(targetX) > EPSILON || abs(targetY) > EPSILON;
}
static scrollers: Map<HTMLElement, Scroller> = new Map
static setDeltaX(element: HTMLElement, targetDiff: number) {
this.setTarget(element, targetDiff, 'x');
}
static setDeltaY(element: HTMLElement, targetDiff: number) {
this.setTarget(element, targetDiff, 'y');
}
static setTarget(element: HTMLElement, targetDiff: number, axis: 'x' | 'y') {
let scroller = this.scrollers.get(element);
!scroller && this.scrollers.set(element, scroller = new this(element));
scroller.setTarget(targetDiff, axis);
}
// Add appearing effect for all previously scrolled elements.
static highlight() {
if (this.scrollers.size !== 0) return;
selections.forEach(el => el.classList.add('appear'));
}
}