@atom-js-org/runtime 0.5.1-alpha.0 → 0.5.3-alpha.0

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.
package/index.d.ts CHANGED
@@ -61,6 +61,7 @@ export class BrowserWindow extends EventEmitter {
61
61
  hide(): void;
62
62
  isVisible(): boolean;
63
63
  focus(): void;
64
+ startDrag(): void;
64
65
  blur(): void;
65
66
  close(): void;
66
67
  destroy(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atom-js-org/runtime",
3
- "version": "0.5.1-alpha.0",
3
+ "version": "0.5.3-alpha.0",
4
4
  "description": "Electron-like desktop runtime powered by the operating system WebView.",
5
5
  "main": "src/index.cjs",
6
6
  "types": "index.d.ts",
@@ -39,6 +39,252 @@ function generateBridgeScript({ websocketUrl, preloadCode = '' }) {
39
39
  }
40
40
  }
41
41
 
42
+ function normalizeAppRegion(value) {
43
+ const normalized = String(value || '').trim().toLowerCase();
44
+ return normalized === 'drag' || normalized === 'no-drag' ? normalized : '';
45
+ }
46
+
47
+ function appRegionForElement(element) {
48
+ if (!(element instanceof Element)) return '';
49
+ if (element.hasAttribute('data-atom-no-drag')) return 'no-drag';
50
+ if (element.hasAttribute('data-atom-drag-region')) return 'drag';
51
+
52
+ const attribute = normalizeAppRegion(
53
+ element.getAttribute('data-atom-app-region') || element.getAttribute('data-app-region')
54
+ );
55
+ if (attribute) return attribute;
56
+
57
+ const inline = normalizeAppRegion(
58
+ element.style && (
59
+ element.style.getPropertyValue('-webkit-app-region') ||
60
+ element.style.getPropertyValue('app-region') ||
61
+ element.style.getPropertyValue('--atom-app-region')
62
+ )
63
+ );
64
+ if (inline) return inline;
65
+
66
+ try {
67
+ const computed = getComputedStyle(element);
68
+ return normalizeAppRegion(
69
+ computed.getPropertyValue('-webkit-app-region') ||
70
+ computed.getPropertyValue('app-region') ||
71
+ computed.getPropertyValue('--atom-app-region')
72
+ );
73
+ } catch {
74
+ return '';
75
+ }
76
+ }
77
+
78
+ function collectStylesheetAppRegions() {
79
+ const regions = new Map();
80
+
81
+ function visitRules(rules) {
82
+ for (const rule of rules || []) {
83
+ if (rule && rule.cssRules) {
84
+ visitRules(rule.cssRules);
85
+ continue;
86
+ }
87
+ if (!rule || !rule.selectorText || !rule.style) continue;
88
+
89
+ const match = String(rule.cssText || '').match(
90
+ /(?:-webkit-app-region|app-region)\s*:\s*(drag|no-drag)/i
91
+ );
92
+ const region = normalizeAppRegion(
93
+ rule.style.getPropertyValue('-webkit-app-region') ||
94
+ rule.style.getPropertyValue('app-region') ||
95
+ rule.style.getPropertyValue('--atom-app-region') ||
96
+ (match && match[1])
97
+ );
98
+ if (!region) continue;
99
+
100
+ try {
101
+ for (const element of document.querySelectorAll(rule.selectorText)) {
102
+ regions.set(element, region);
103
+ }
104
+ } catch {}
105
+ }
106
+ }
107
+
108
+ for (const sheet of document.styleSheets || []) {
109
+ try {
110
+ visitRules(sheet.cssRules);
111
+ } catch {}
112
+ }
113
+
114
+ return regions;
115
+ }
116
+
117
+ function composedParent(element) {
118
+ if (!(element instanceof Element)) return null;
119
+ if (element.assignedSlot) return element.assignedSlot;
120
+ if (element.parentElement) return element.parentElement;
121
+ const root = element.getRootNode && element.getRootNode();
122
+ return root && root.host instanceof Element ? root.host : null;
123
+ }
124
+
125
+ function isInteractiveElement(element) {
126
+ return element instanceof Element && element.matches(
127
+ 'button, input, textarea, select, option, a[href], [contenteditable=""], [contenteditable="true"], [role="button"]'
128
+ );
129
+ }
130
+
131
+ function hasDraggableAncestor(element) {
132
+ let current = composedParent(element);
133
+ while (current) {
134
+ const region = appRegionForElement(current);
135
+ if (region === 'no-drag') return false;
136
+ if (region === 'drag') return true;
137
+ current = composedParent(current);
138
+ }
139
+ return false;
140
+ }
141
+
142
+ const observedDragRoots = new WeakSet();
143
+ let dragRegionObserver = null;
144
+ let dragRegionFrame = 0;
145
+ let lastDragRegionPayload = '';
146
+
147
+ function observeDragRoot(root) {
148
+ if (!dragRegionObserver || !root || observedDragRoots.has(root)) return;
149
+ observedDragRoots.add(root);
150
+ dragRegionObserver.observe(root, {
151
+ attributes: true,
152
+ childList: true,
153
+ subtree: true,
154
+ attributeFilter: [
155
+ 'class',
156
+ 'style',
157
+ 'hidden',
158
+ 'data-atom-drag-region',
159
+ 'data-atom-no-drag',
160
+ 'data-atom-app-region',
161
+ 'data-app-region'
162
+ ]
163
+ });
164
+ }
165
+
166
+ function walkRegionElements(root, visitor) {
167
+ const stack = [];
168
+ if (root && root.documentElement instanceof Element) {
169
+ stack.push(root.documentElement);
170
+ } else if (root && root.children) {
171
+ for (let index = root.children.length - 1; index >= 0; index -= 1) {
172
+ stack.push(root.children[index]);
173
+ }
174
+ }
175
+
176
+ while (stack.length > 0) {
177
+ const element = stack.pop();
178
+ if (!(element instanceof Element)) continue;
179
+ visitor(element);
180
+
181
+ if (element.shadowRoot) {
182
+ observeDragRoot(element.shadowRoot);
183
+ for (let index = element.shadowRoot.children.length - 1; index >= 0; index -= 1) {
184
+ stack.push(element.shadowRoot.children[index]);
185
+ }
186
+ }
187
+
188
+ for (let index = element.children.length - 1; index >= 0; index -= 1) {
189
+ stack.push(element.children[index]);
190
+ }
191
+ }
192
+ }
193
+
194
+ function roundedRegionNumber(value) {
195
+ return Math.round(Number(value) * 4) / 4;
196
+ }
197
+
198
+ function collectWindowDragRegions() {
199
+ const regions = [];
200
+ const stylesheetRegions = collectStylesheetAppRegions();
201
+ const viewportWidth = Math.max(0, Number(window.innerWidth) || 0);
202
+ const viewportHeight = Math.max(0, Number(window.innerHeight) || 0);
203
+
204
+ walkRegionElements(document, (element) => {
205
+ let region = appRegionForElement(element) || stylesheetRegions.get(element) || '';
206
+ if (!region && isInteractiveElement(element) && hasDraggableAncestor(element)) {
207
+ region = 'no-drag';
208
+ }
209
+ if (!region) return;
210
+
211
+ let style;
212
+ try {
213
+ style = getComputedStyle(element);
214
+ } catch {
215
+ return;
216
+ }
217
+ if (style.display === 'none' || style.visibility === 'hidden') return;
218
+
219
+ const rect = element.getBoundingClientRect();
220
+ const left = Math.max(0, Math.min(viewportWidth, rect.left));
221
+ const top = Math.max(0, Math.min(viewportHeight, rect.top));
222
+ const right = Math.max(0, Math.min(viewportWidth, rect.right));
223
+ const bottom = Math.max(0, Math.min(viewportHeight, rect.bottom));
224
+ const width = right - left;
225
+ const height = bottom - top;
226
+ if (width <= 0 || height <= 0) return;
227
+
228
+ regions.push({
229
+ x: roundedRegionNumber(left),
230
+ y: roundedRegionNumber(top),
231
+ width: roundedRegionNumber(width),
232
+ height: roundedRegionNumber(height),
233
+ draggable: region === 'drag'
234
+ });
235
+ });
236
+
237
+ return {
238
+ type: 'system',
239
+ command: 'set-window-drag-regions',
240
+ viewport: {
241
+ width: roundedRegionNumber(viewportWidth),
242
+ height: roundedRegionNumber(viewportHeight)
243
+ },
244
+ deviceScaleFactor: Number(window.devicePixelRatio) || 1,
245
+ regions
246
+ };
247
+ }
248
+
249
+ function publishWindowDragRegions() {
250
+ dragRegionFrame = 0;
251
+ const payload = collectWindowDragRegions();
252
+ const serialized = JSON.stringify(payload);
253
+ if (serialized === lastDragRegionPayload) return;
254
+ lastDragRegionPayload = serialized;
255
+ sendNow(payload);
256
+ }
257
+
258
+ function scheduleWindowDragRegionUpdate() {
259
+ if (dragRegionFrame) return;
260
+ dragRegionFrame = requestAnimationFrame(publishWindowDragRegions);
261
+ }
262
+
263
+ function activateWindowDragRegions() {
264
+ observeDragRoot(document.documentElement);
265
+ scheduleWindowDragRegionUpdate();
266
+
267
+ if (typeof ResizeObserver === 'function') {
268
+ const resizeObserver = new ResizeObserver(scheduleWindowDragRegionUpdate);
269
+ resizeObserver.observe(document.documentElement);
270
+ if (document.body) resizeObserver.observe(document.body);
271
+ }
272
+ }
273
+
274
+ dragRegionObserver = new MutationObserver(scheduleWindowDragRegionUpdate);
275
+ window.addEventListener('resize', scheduleWindowDragRegionUpdate, { passive: true });
276
+ window.addEventListener('scroll', scheduleWindowDragRegionUpdate, { capture: true, passive: true });
277
+ window.addEventListener('transitionend', scheduleWindowDragRegionUpdate, true);
278
+ window.addEventListener('animationend', scheduleWindowDragRegionUpdate, true);
279
+ window.addEventListener('load', scheduleWindowDragRegionUpdate, { once: true });
280
+ document.addEventListener('load', scheduleWindowDragRegionUpdate, true);
281
+
282
+ if (document.readyState === 'loading') {
283
+ document.addEventListener('DOMContentLoaded', activateWindowDragRegions, { once: true });
284
+ } else {
285
+ activateWindowDragRegions();
286
+ }
287
+
42
288
  function makeEvent(channel) {
43
289
  return Object.freeze({
44
290
  channel,
@@ -163,6 +163,15 @@ class BridgeServer {
163
163
  return;
164
164
  }
165
165
 
166
+ if (message.type === 'system') {
167
+ if (message.command === 'start-window-drag') {
168
+ win._startNativeDrag();
169
+ } else if (message.command === 'set-window-drag-regions') {
170
+ win._setNativeDragRegions(message.regions, message.viewport);
171
+ }
172
+ return;
173
+ }
174
+
166
175
  if (message.type === 'send') {
167
176
  const event = createIpcEvent(win);
168
177
  ipcMain.emit(message.channel, event, ...(Array.isArray(message.args) ? message.args : []));
@@ -219,6 +219,44 @@ class BrowserWindow extends EventEmitter {
219
219
  return false;
220
220
  }
221
221
 
222
+ _startNativeDrag() {
223
+ if (this._destroyed) return false;
224
+ return this._sendHostCommand({ command: 'start-drag' });
225
+ }
226
+
227
+ _setNativeDragRegions(regions, viewport) {
228
+ if (this._destroyed) return false;
229
+
230
+ const normalizedRegions = [];
231
+ for (const region of Array.isArray(regions) ? regions.slice(0, 4096) : []) {
232
+ const x = Number(region && region.x);
233
+ const y = Number(region && region.y);
234
+ const width = Number(region && region.width);
235
+ const height = Number(region && region.height);
236
+ if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) continue;
237
+ normalizedRegions.push({
238
+ x,
239
+ y,
240
+ width,
241
+ height,
242
+ draggable: region.draggable === true
243
+ });
244
+ }
245
+
246
+ const viewportWidth = Number(viewport && viewport.width);
247
+ const viewportHeight = Number(viewport && viewport.height);
248
+ const normalizedViewport = {
249
+ width: Number.isFinite(viewportWidth) && viewportWidth > 0 ? viewportWidth : this._bounds.width,
250
+ height: Number.isFinite(viewportHeight) && viewportHeight > 0 ? viewportHeight : this._bounds.height
251
+ };
252
+
253
+ return this._sendHostCommand({
254
+ command: 'set-drag-regions',
255
+ regions: normalizedRegions,
256
+ viewport: normalizedViewport
257
+ });
258
+ }
259
+
222
260
  _markRendererReady(details) {
223
261
  this._notifyDidFinishLoad(details && details.href ? details.href : this._currentUrl, 'bridge');
224
262
  }
@@ -240,6 +278,16 @@ class BrowserWindow extends EventEmitter {
240
278
  this.emit('blur');
241
279
  return;
242
280
  }
281
+ if (event.type === 'bounds-changed') {
282
+ const bounds = event.bounds && typeof event.bounds === 'object' ? event.bounds : {};
283
+ for (const key of ['x', 'y', 'width', 'height']) {
284
+ const value = Number(bounds[key]);
285
+ if (Number.isFinite(value)) this._bounds[key] = value;
286
+ }
287
+ if (event.reason === 'move') this.emit('move');
288
+ if (event.reason === 'resize') this.emit('resize');
289
+ return;
290
+ }
243
291
  if (event.type === 'minimize') {
244
292
  this._minimized = true;
245
293
  this.emit('minimize');
@@ -334,6 +382,12 @@ class BrowserWindow extends EventEmitter {
334
382
  }
335
383
  }
336
384
 
385
+ startDrag() {
386
+ if (!this._startNativeDrag()) {
387
+ console.warn('[AtomJS] Native window dragging is not supported by the current platform host.');
388
+ }
389
+ }
390
+
337
391
  blur() {}
338
392
 
339
393
  close() {
@@ -70,6 +70,51 @@ static NSColor *AtomJSColor(NSString *hex) {
70
70
  return [NSColor colorWithSRGBRed:red green:green blue:blue alpha:alpha];
71
71
  }
72
72
 
73
+ static NSRect AtomJSVirtualDesktopFrame(void) {
74
+ NSArray<NSScreen *> *screens = [NSScreen screens];
75
+ if (screens.count == 0) {
76
+ NSScreen *mainScreen = [NSScreen mainScreen];
77
+ return mainScreen ? mainScreen.frame : NSZeroRect;
78
+ }
79
+
80
+ NSRect frame = screens.firstObject.frame;
81
+ for (NSUInteger index = 1; index < screens.count; index += 1) {
82
+ NSScreen *screen = screens[index];
83
+ frame = NSUnionRect(frame, screen.frame);
84
+ }
85
+ return frame;
86
+ }
87
+
88
+ static NSRect AtomJSCocoaFrameFromAtomBounds(NSDictionary *bounds, NSRect fallback) {
89
+ NSRect virtualDesktop = AtomJSVirtualDesktopFrame();
90
+ CGFloat width = MAX(1.0, [AtomJSNumber(bounds[@"width"], @(fallback.size.width)) doubleValue]);
91
+ CGFloat height = MAX(1.0, [AtomJSNumber(bounds[@"height"], @(fallback.size.height)) doubleValue]);
92
+ CGFloat defaultX = fallback.origin.x - NSMinX(virtualDesktop);
93
+ CGFloat defaultY = NSMaxY(virtualDesktop) - NSMaxY(fallback);
94
+ CGFloat atomX = [AtomJSNumber(bounds[@"x"], @(defaultX)) doubleValue];
95
+ CGFloat atomY = [AtomJSNumber(bounds[@"y"], @(defaultY)) doubleValue];
96
+
97
+ return NSMakeRect(
98
+ NSMinX(virtualDesktop) + atomX,
99
+ NSMaxY(virtualDesktop) - atomY - height,
100
+ width,
101
+ height
102
+ );
103
+ }
104
+
105
+ static NSDictionary *AtomJSAtomBoundsFromWindow(NSWindow *window) {
106
+ if (!window) return @{ @"x": @0, @"y": @0, @"width": @0, @"height": @0 };
107
+
108
+ NSRect virtualDesktop = AtomJSVirtualDesktopFrame();
109
+ NSRect frame = window.frame;
110
+ return @{
111
+ @"x": @(frame.origin.x - NSMinX(virtualDesktop)),
112
+ @"y": @(NSMaxY(virtualDesktop) - NSMaxY(frame)),
113
+ @"width": @(frame.size.width),
114
+ @"height": @(frame.size.height)
115
+ };
116
+ }
117
+
73
118
  static void AtomJSConfigureTransparentWebView(WKWebView *webView) {
74
119
  if (!webView) return;
75
120
 
@@ -132,14 +177,129 @@ static void AtomJSConfigureApplicationIdentity(NSApplication *application) {
132
177
  application.applicationIconImage = icon ?: AtomJSDefaultApplicationIcon();
133
178
  }
134
179
 
180
+ @interface AtomJSDraggableContentView : NSView
181
+ @property(nonatomic, strong) WKWebView *webView;
182
+ @property(nonatomic, copy) NSArray<NSDictionary *> *dragRegions;
183
+ @property(nonatomic, assign) NSSize viewportSize;
184
+ - (instancetype)initWithFrame:(NSRect)frame webView:(WKWebView *)webView;
185
+ - (void)updateDragRegions:(NSArray *)regions viewport:(NSDictionary *)viewport;
186
+ @end
187
+
188
+ @implementation AtomJSDraggableContentView
189
+
190
+ - (instancetype)initWithFrame:(NSRect)frame webView:(WKWebView *)webView {
191
+ self = [super initWithFrame:frame];
192
+ if (!self) return nil;
193
+
194
+ _webView = webView;
195
+ _dragRegions = @[];
196
+ _viewportSize = frame.size;
197
+ self.autoresizesSubviews = YES;
198
+ webView.frame = self.bounds;
199
+ webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
200
+ [self addSubview:webView];
201
+ return self;
202
+ }
203
+
204
+ - (BOOL)isFlipped {
205
+ return YES;
206
+ }
207
+
208
+ - (void)layout {
209
+ [super layout];
210
+ self.webView.frame = self.bounds;
211
+ }
212
+
213
+ - (void)updateDragRegions:(NSArray *)regions viewport:(NSDictionary *)viewport {
214
+ NSMutableArray<NSDictionary *> *normalized = [NSMutableArray array];
215
+ for (id value in [regions isKindOfClass:[NSArray class]] ? regions : @[]) {
216
+ if (![value isKindOfClass:[NSDictionary class]]) continue;
217
+ NSDictionary *region = value;
218
+ NSNumber *x = AtomJSNumber(region[@"x"], nil);
219
+ NSNumber *y = AtomJSNumber(region[@"y"], nil);
220
+ NSNumber *width = AtomJSNumber(region[@"width"], nil);
221
+ NSNumber *height = AtomJSNumber(region[@"height"], nil);
222
+ if (!x || !y || !width || !height || width.doubleValue <= 0 || height.doubleValue <= 0) continue;
223
+
224
+ [normalized addObject:@{
225
+ @"x": x,
226
+ @"y": y,
227
+ @"width": width,
228
+ @"height": height,
229
+ @"draggable": @(AtomJSBoolean(region[@"draggable"], NO))
230
+ }];
231
+ if (normalized.count >= 4096) break;
232
+ }
233
+
234
+ CGFloat viewportWidth = [AtomJSNumber(viewport[@"width"], @(self.bounds.size.width)) doubleValue];
235
+ CGFloat viewportHeight = [AtomJSNumber(viewport[@"height"], @(self.bounds.size.height)) doubleValue];
236
+ self.viewportSize = NSMakeSize(
237
+ viewportWidth > 0 ? viewportWidth : self.bounds.size.width,
238
+ viewportHeight > 0 ? viewportHeight : self.bounds.size.height
239
+ );
240
+ self.dragRegions = normalized;
241
+ }
242
+
243
+ - (BOOL)isDraggablePoint:(NSPoint)point {
244
+ CGFloat scaleX = self.viewportSize.width > 0 ? self.bounds.size.width / self.viewportSize.width : 1.0;
245
+ CGFloat scaleY = self.viewportSize.height > 0 ? self.bounds.size.height / self.viewportSize.height : 1.0;
246
+ BOOL draggable = NO;
247
+
248
+ for (NSDictionary *region in self.dragRegions) {
249
+ NSRect rect = NSMakeRect(
250
+ [AtomJSNumber(region[@"x"], @0) doubleValue] * scaleX,
251
+ [AtomJSNumber(region[@"y"], @0) doubleValue] * scaleY,
252
+ [AtomJSNumber(region[@"width"], @0) doubleValue] * scaleX,
253
+ [AtomJSNumber(region[@"height"], @0) doubleValue] * scaleY
254
+ );
255
+ if (!NSPointInRect(point, rect)) continue;
256
+ if (!AtomJSBoolean(region[@"draggable"], NO)) return NO;
257
+ draggable = YES;
258
+ }
259
+
260
+ return draggable;
261
+ }
262
+
263
+ - (NSView *)hitTest:(NSPoint)point {
264
+ if ([self isDraggablePoint:point]) return self;
265
+ return [super hitTest:point];
266
+ }
267
+
268
+ - (BOOL)acceptsFirstMouse:(NSEvent *)event {
269
+ return YES;
270
+ }
271
+
272
+ - (void)mouseDown:(NSEvent *)event {
273
+ if (!self.window || event.buttonNumber != 0) {
274
+ [super mouseDown:event];
275
+ return;
276
+ }
277
+
278
+ if (event.clickCount == 2) {
279
+ NSButton *zoomButton = [self.window standardWindowButton:NSWindowZoomButton];
280
+ if (zoomButton.enabled) {
281
+ [self.window zoom:nil];
282
+ return;
283
+ }
284
+ }
285
+
286
+ [self.window performWindowDragWithEvent:event];
287
+ }
288
+
289
+ @end
290
+
135
291
  @interface AtomJSWindowController : NSObject <NSWindowDelegate, WKNavigationDelegate>
136
292
  @property(nonatomic, strong) NSNumber *windowId;
137
293
  @property(nonatomic, strong) NSWindow *window;
138
294
  @property(nonatomic, strong) WKWebView *webView;
295
+ @property(nonatomic, strong) AtomJSDraggableContentView *contentView;
139
296
  @property(nonatomic, weak) AtomJSWindowController *parentController;
140
297
  @property(nonatomic, assign) BOOL modal;
141
298
  - (instancetype)initWithWindowId:(NSNumber *)windowId config:(NSDictionary *)config;
142
299
  - (void)navigate:(NSString *)urlString;
300
+ - (void)beginWindowDrag;
301
+ - (void)updateDragRegions:(NSArray *)regions viewport:(NSDictionary *)viewport;
302
+ - (void)emitBoundsChangedWithReason:(NSString *)reason;
143
303
  @end
144
304
 
145
305
  @implementation AtomJSWindowController
@@ -175,6 +335,8 @@ static void AtomJSConfigureApplicationIdentity(NSApplication *application) {
175
335
  defer:NO];
176
336
  _window.releasedWhenClosed = NO;
177
337
  _window.delegate = self;
338
+ _window.movable = YES;
339
+ _window.movableByWindowBackground = NO;
178
340
  _window.title = AtomJSString(config[@"title"], atomAppName);
179
341
  _window.backgroundColor = AtomJSColor(config[@"backgroundColor"]);
180
342
  _window.tabbingMode = NSWindowTabbingModeDisallowed;
@@ -209,7 +371,13 @@ static void AtomJSConfigureApplicationIdentity(NSApplication *application) {
209
371
  }
210
372
 
211
373
  if ([config[@"x"] isKindOfClass:[NSNumber class]] && [config[@"y"] isKindOfClass:[NSNumber class]]) {
212
- [_window setFrameOrigin:NSMakePoint([config[@"x"] doubleValue], [config[@"y"] doubleValue])];
374
+ NSDictionary *initialBounds = @{
375
+ @"x": config[@"x"],
376
+ @"y": config[@"y"],
377
+ @"width": @(_window.frame.size.width),
378
+ @"height": @(_window.frame.size.height)
379
+ };
380
+ [_window setFrame:AtomJSCocoaFrameFromAtomBounds(initialBounds, _window.frame) display:NO animate:NO];
213
381
  } else if (AtomJSBoolean(config[@"center"], YES)) {
214
382
  [_window center];
215
383
  }
@@ -231,7 +399,11 @@ static void AtomJSConfigureApplicationIdentity(NSApplication *application) {
231
399
  _webView.navigationDelegate = self;
232
400
  _webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
233
401
  if (AtomJSBoolean(config[@"transparent"], NO)) AtomJSConfigureTransparentWebView(_webView);
234
- _window.contentView = _webView;
402
+
403
+ _contentView = [[AtomJSDraggableContentView alloc]
404
+ initWithFrame:NSMakeRect(0, 0, width, height)
405
+ webView:_webView];
406
+ _window.contentView = _contentView;
235
407
 
236
408
  NSNumber *parentWindowId = AtomJSNumber(config[@"parentWindowId"], nil);
237
409
  _parentController = parentWindowId ? atomWindows[parentWindowId] : nil;
@@ -270,6 +442,33 @@ static void AtomJSConfigureApplicationIdentity(NSApplication *application) {
270
442
  return self;
271
443
  }
272
444
 
445
+ - (void)beginWindowDrag {
446
+ NSEvent *event = [NSApp currentEvent];
447
+ if (
448
+ !self.window ||
449
+ !event ||
450
+ event.type != NSEventTypeLeftMouseDown ||
451
+ event.window != self.window
452
+ ) {
453
+ return;
454
+ }
455
+
456
+ [self.window performWindowDragWithEvent:event];
457
+ }
458
+
459
+ - (void)updateDragRegions:(NSArray *)regions viewport:(NSDictionary *)viewport {
460
+ [self.contentView updateDragRegions:regions viewport:viewport];
461
+ }
462
+
463
+ - (void)emitBoundsChangedWithReason:(NSString *)reason {
464
+ AtomJSEmit(@{
465
+ @"type": @"bounds-changed",
466
+ @"windowId": self.windowId,
467
+ @"reason": reason ?: @"unknown",
468
+ @"bounds": AtomJSAtomBoundsFromWindow(self.window)
469
+ });
470
+ }
471
+
273
472
  - (void)navigate:(NSString *)urlString {
274
473
  NSURL *url = [NSURL URLWithString:urlString ?: @"about:blank"];
275
474
  if (!url) {
@@ -361,6 +560,14 @@ static void AtomJSConfigureApplicationIdentity(NSApplication *application) {
361
560
  });
362
561
  }
363
562
 
563
+ - (void)windowDidMove:(NSNotification *)notification {
564
+ [self emitBoundsChangedWithReason:@"move"];
565
+ }
566
+
567
+ - (void)windowDidResize:(NSNotification *)notification {
568
+ [self emitBoundsChangedWithReason:@"resize"];
569
+ }
570
+
364
571
  @end
365
572
 
366
573
  static AtomJSWindowController *AtomJSController(NSDictionary *message) {
@@ -518,6 +725,7 @@ static void AtomJSHandleMessage(NSDictionary *message) {
518
725
  @"type": @"created",
519
726
  @"windowId": windowId
520
727
  });
728
+ [controller emitBoundsChangedWithReason:@"create"];
521
729
  return;
522
730
  }
523
731
 
@@ -561,6 +769,12 @@ static void AtomJSHandleMessage(NSDictionary *message) {
561
769
  [controller.window makeKeyAndOrderFront:nil];
562
770
  [controller.window orderFrontRegardless];
563
771
  [NSApp activateIgnoringOtherApps:YES];
772
+ } else if ([command isEqualToString:@"start-drag"]) {
773
+ [controller beginWindowDrag];
774
+ } else if ([command isEqualToString:@"set-drag-regions"]) {
775
+ NSArray *regions = [message[@"regions"] isKindOfClass:[NSArray class]] ? message[@"regions"] : @[];
776
+ NSDictionary *viewport = [message[@"viewport"] isKindOfClass:[NSDictionary class]] ? message[@"viewport"] : @{};
777
+ [controller updateDragRegions:regions viewport:viewport];
564
778
  } else if ([command isEqualToString:@"close"] || [command isEqualToString:@"destroy"]) {
565
779
  [controller.window close];
566
780
  } else if ([command isEqualToString:@"set-title"]) {
@@ -579,13 +793,12 @@ static void AtomJSHandleMessage(NSDictionary *message) {
579
793
  BOOL active = (controller.window.styleMask & NSWindowStyleMaskFullScreen) == NSWindowStyleMaskFullScreen;
580
794
  if (requested != active) [controller.window toggleFullScreen:nil];
581
795
  } else if ([command isEqualToString:@"set-bounds"]) {
582
- NSDictionary *bounds = [message[@"bounds"] isKindOfClass:[NSDictionary class]] ? message[@"bounds"] : @{};
583
- NSRect frame = controller.window.frame;
584
- if ([bounds[@"x"] isKindOfClass:[NSNumber class]]) frame.origin.x = [bounds[@"x"] doubleValue];
585
- if ([bounds[@"y"] isKindOfClass:[NSNumber class]]) frame.origin.y = [bounds[@"y"] doubleValue];
586
- if ([bounds[@"width"] isKindOfClass:[NSNumber class]]) frame.size.width = MAX(1.0, [bounds[@"width"] doubleValue]);
587
- if ([bounds[@"height"] isKindOfClass:[NSNumber class]]) frame.size.height = MAX(1.0, [bounds[@"height"] doubleValue]);
588
- [controller.window setFrame:frame display:YES animate:NO];
796
+ NSDictionary *requested = [message[@"bounds"] isKindOfClass:[NSDictionary class]] ? message[@"bounds"] : @{};
797
+ NSMutableDictionary *bounds = [AtomJSAtomBoundsFromWindow(controller.window) mutableCopy];
798
+ for (NSString *key in @[ @"x", @"y", @"width", @"height" ]) {
799
+ if ([requested[key] isKindOfClass:[NSNumber class]]) bounds[key] = requested[key];
800
+ }
801
+ [controller.window setFrame:AtomJSCocoaFrameFromAtomBounds(bounds, controller.window.frame) display:YES animate:NO];
589
802
  } else if ([command isEqualToString:@"set-always-on-top"]) {
590
803
  controller.window.level = AtomJSBoolean(message[@"value"], NO) ? NSFloatingWindowLevel : NSNormalWindowLevel;
591
804
  } else if ([command isEqualToString:@"set-opacity"]) {