Skip to content

Commit bbcbca3

Browse files
committed
Implement mutable stylesheet CSSOM for Monaco
1 parent 4108056 commit bbcbca3

5 files changed

Lines changed: 391 additions & 2 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#pragma once
2+
#include <string_view>
3+
4+
namespace webscene_native {
5+
// The pinned WebScene exposes a stable native HTMLStyleElement.sheet object,
6+
// but no rule mutation methods. Its textContent setter *does* replace that
7+
// owner's parsed native rules, recascade, and invalidate layout. Bridge the
8+
// dynamic style-rule operations used by Code OSS to that existing renderer.
9+
// This is a bounded adapter, not a complete CSSOM implementation: constructed
10+
// sheets, imported-sheet inspection and nested rule mutation remain unsupported.
11+
// Semantics: https://www.w3.org/TR/cssom-1/
12+
inline constexpr std::string_view cssCompatibilityScript = R"JS(
13+
// SCENETECH_CSS_COMPATIBILITY_V1
14+
(() => {
15+
'use strict';
16+
if (typeof globalThis.__webSceneAugmentStyleSheet === 'function') return;
17+
const sheets = new WeakMap();
18+
const exception = (message, name) => { throw new DOMException(message, name); };
19+
const text = value => {
20+
if (typeof value === 'symbol') throw new TypeError('A CSS rule cannot be a Symbol');
21+
return String(value);
22+
};
23+
// Split only at top-level CSS rule boundaries. Quoted strings, escaped
24+
// delimiters, comments, URLs, attribute selectors and nested blocks are kept
25+
// intact. The actual declarations/selectors are interpreted by native CSS.
26+
const splitRules = source => {
27+
const rules = [];
28+
let start = -1, open = -1, braces = 0, parentheses = 0, brackets = 0;
29+
let quote = '', comment = false;
30+
for (let index = 0; index < source.length; index++) {
31+
const character = source[index], next = source[index + 1];
32+
if (comment) {
33+
if (character === '*' && next === '/') { comment = false; index++; }
34+
continue;
35+
}
36+
if (quote) {
37+
if (character === '\\') index++;
38+
else if (character === quote) quote = '';
39+
continue;
40+
}
41+
if (character === '/' && next === '*') { comment = true; index++; continue; }
42+
if (start < 0) {
43+
if (/\s/.test(character)) continue;
44+
start = index;
45+
}
46+
if (character === '\\') { index++; continue; }
47+
if (character === '"' || character === "'") { quote = character; continue; }
48+
if (character === '(') parentheses++;
49+
if (character === ')') parentheses--;
50+
if (character === '[') brackets++;
51+
if (character === ']') brackets--;
52+
if (parentheses < 0 || brackets < 0) exception('Unbalanced CSS delimiters', 'SyntaxError');
53+
if (parentheses || brackets) continue;
54+
if (character === '{') { if (braces++ === 0) open = index; }
55+
if (character === '}' && --braces < 0) exception('Unbalanced CSS block', 'SyntaxError');
56+
if ((character === '}' && braces === 0) || (character === ';' && braces === 0)) {
57+
const cssText = source.slice(start, index + 1).trim();
58+
const selectorText = source[start] === '@' || open < 0 ? undefined : source.slice(start, open).trim();
59+
if (open < 0 && source[start] !== '@') exception('A CSS style rule requires a block', 'SyntaxError');
60+
rules.push({ cssText, selectorText, body: open < 0 ? undefined : source.slice(open + 1, index) });
61+
start = -1; open = -1;
62+
}
63+
}
64+
if (start >= 0 || braces || parentheses || brackets || quote || comment)
65+
exception('An incomplete CSS rule is unsupported', 'SyntaxError');
66+
return rules;
67+
};
68+
const stateFor = sheet => {
69+
const state = sheets.get(sheet);
70+
if (!state) throw new TypeError('Illegal stylesheet receiver');
71+
synchronize(state);
72+
return state;
73+
};
74+
const publish = state => {
75+
// Native textContent replacement is the rendering operation. Do not merely
76+
// update JavaScript records: every insertion/deletion reaches the cascade.
77+
const source = state.rules.map(rule => rule.cssText).join('\n');
78+
state.owner.textContent = source;
79+
state.source = source;
80+
};
81+
const makeRule = (state, parsed) => {
82+
let cssText = parsed.cssText;
83+
let parent = state.sheet;
84+
let style;
85+
const rule = {};
86+
Object.defineProperties(rule, {
87+
cssText: { enumerable: true, get: () => cssText },
88+
parentStyleSheet: { enumerable: true, get: () => parent },
89+
parentRule: { enumerable: true, value: null },
90+
detach: { value: () => { parent = null; } }
91+
});
92+
if (parsed.selectorText !== undefined) {
93+
Object.defineProperties(rule, {
94+
type: { enumerable: true, value: 1 },
95+
selectorText: { enumerable: true, value: parsed.selectorText },
96+
style: { enumerable: true, get() {
97+
if (style) return style;
98+
// Reuse WebScene's real CSSStyleDeclaration parser/property methods.
99+
// The scratch element remains detached; only the owning style element
100+
// is published when callers mutate this declaration.
101+
const declaration = state.owner.ownerDocument.createElement('span').style;
102+
declaration.cssText = parsed.body;
103+
const commit = () => {
104+
synchronize(state);
105+
cssText = parsed.selectorText + ' {' + declaration.cssText + '}';
106+
if (parent && state.rules.includes(rule)) publish(state);
107+
};
108+
style = new Proxy(declaration, {
109+
get(target, key) {
110+
const value = Reflect.get(target, key, target);
111+
if (typeof value !== 'function') return value;
112+
return (...args) => {
113+
const result = Reflect.apply(value, target, args);
114+
if (key === 'setProperty' || key === 'removeProperty') commit();
115+
return result;
116+
};
117+
},
118+
set(target, key, value) {
119+
const result = Reflect.set(target, key, value, target);
120+
if (result) commit();
121+
return result;
122+
}
123+
});
124+
return style;
125+
} }
126+
});
127+
}
128+
return rule;
129+
};
130+
const synchronize = state => {
131+
const source = state.owner.textContent || '';
132+
if (source === state.source) return;
133+
const parsed = splitRules(source);
134+
for (const rule of state.rules) rule.detach();
135+
state.rules = parsed.map(rule => makeRule(state, rule));
136+
state.source = source;
137+
};
138+
const augment = sheet => {
139+
if (!sheet || typeof sheet.insertRule === 'function') return sheet;
140+
const state = { sheet, owner: sheet.ownerNode, source: undefined, rules: [] };
141+
const list = new Proxy({}, {
142+
get(_target, key) {
143+
synchronize(state);
144+
if (key === 'length') return state.rules.length;
145+
if (key === 'item') return index => { synchronize(state); return state.rules[Number(index) >>> 0] || null; };
146+
if (key === Symbol.iterator) return function* () {
147+
for (let index = 0; index < state.rules.length; index++) { synchronize(state); yield state.rules[index]; }
148+
};
149+
if (typeof key === 'string' && /^(0|[1-9][0-9]*)$/.test(key)) return state.rules[Number(key)];
150+
},
151+
set() { return false; },
152+
deleteProperty() { return false; },
153+
defineProperty() { return false; }
154+
});
155+
sheets.set(sheet, state);
156+
Object.defineProperties(sheet, {
157+
cssRules: { configurable: true, enumerable: true, get() { stateFor(this); return list; } },
158+
insertRule: { configurable: true, writable: true, value(rule, index = 0) {
159+
if (arguments.length === 0) throw new TypeError('A CSS rule is required');
160+
const current = stateFor(this);
161+
index = Number(index) >>> 0;
162+
if (index > current.rules.length) exception('Rule index is out of bounds', 'IndexSizeError');
163+
const parsed = splitRules(text(rule));
164+
if (parsed.length !== 1) exception('Exactly one CSS rule is required', 'SyntaxError');
165+
// Imported sheets cannot be represented by the native owner-text bridge.
166+
if (/^@(import|namespace)\b/i.test(parsed[0].cssText))
167+
exception('Imported and namespace rules require native CSSOM support', 'NotSupportedError');
168+
current.rules.splice(index, 0, makeRule(current, parsed[0]));
169+
publish(current);
170+
return index;
171+
} },
172+
deleteRule: { configurable: true, writable: true, value(index) {
173+
if (arguments.length === 0) throw new TypeError('A rule index is required');
174+
const current = stateFor(this);
175+
index = Number(index) >>> 0;
176+
if (index >= current.rules.length) exception('Rule index is out of bounds', 'IndexSizeError');
177+
current.rules.splice(index, 1)[0].detach();
178+
publish(current);
179+
} }
180+
});
181+
return sheet;
182+
};
183+
Object.defineProperty(globalThis, '__webSceneAugmentStyleSheet', {
184+
value: augment, configurable: true
185+
});
186+
})();
187+
)JS";
188+
}

‎experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#include "webscene_embed_fallback.h"
2424
#include "webscene_performance_timeline_compatibility.h"
2525
#include "webscene_file_reader_compatibility.h"
26+
#include "webscene_stylesheet_cssom_compatibility.h"
2627

2728
#include "webscene_native_dom.h"
2829
#include "webscene_native_style_defaults.h"

‎experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@
344344
const auto key = wrapper_key(node);
345345
const auto known = stylesheet_wrappers.find(key);
346346
if (known != stylesheet_wrappers.end() && !known->second.IsEmpty()) {
347-
return known->second.Get(isolate);
347+
return augment_style_sheet(known->second.Get(isolate));
348348
}
349349
const auto local_context = isolate->GetCurrentContext();
350350
auto result = v8::Object::New(isolate);
@@ -353,7 +353,30 @@
353353
js_string(isolate, "ownerNode"),
354354
wrap_node(node)).Check();
355355
stylesheet_wrappers.emplace(key, v8::Global<v8::Object>(isolate, result));
356-
return result;
356+
return augment_style_sheet(result);
357+
}
358+
359+
v8::Local<v8::Object> augment_style_sheet(v8::Local<v8::Object> sheet)
360+
{
361+
const auto local_context = isolate->GetCurrentContext();
362+
const auto global = local_context->Global();
363+
v8::Local<v8::Value> raw_augment;
364+
if (!global->Get(
365+
local_context,
366+
js_string(isolate, "__webSceneAugmentStyleSheet"))
367+
.ToLocal(&raw_augment)
368+
|| !raw_augment->IsFunction()) {
369+
return sheet;
370+
}
371+
v8::Local<v8::Value> arguments[]{sheet};
372+
v8::Local<v8::Value> augmented;
373+
if (!raw_augment.As<v8::Function>()
374+
->Call(local_context, global, 1, arguments)
375+
.ToLocal(&augmented)
376+
|| !augmented->IsObject()) {
377+
return sheet;
378+
}
379+
return augmented.As<v8::Object>();
357380
}
358381

359382
static void get_style_sheet(

‎experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_web_api_compatibility.inc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
constexpr std::array scripts{
44
std::pair{"Performance Timeline", browserCompatibilityScript},
55
std::pair{"FileReader", fileReaderCompatibilityScript},
6+
std::pair{"stylesheet CSSOM", cssCompatibilityScript},
67
};
78
for (const auto& [name, source] : scripts) {
89
v8::TryCatch try_catch(isolate);

0 commit comments

Comments
 (0)