Created
July 6, 2015 09:55
-
-
Save aslushnikov/9f00c07439ea49b62c0c to your computer and use it in GitHub Desktop.
CSS Layer v.2 edition 2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # CSS Layer v.2 | |
| The ultimate goals of the CSS design are: | |
| - support the **Layout Mode** modifications of CSS model | |
| - make *side-by-side matched styles of two nodes* user scenario possible | |
| - support multiple clients per css domain | |
| The work will result in subsystem with a clean API to fetch and edit CSS styles. | |
| The document consists of three big parts: | |
| 1. Backend - where we discuss changes in the CSS agent code and its protocol. | |
| 2. Frontend Overview - where we roughly sketch the front-end design | |
| 3. Frontend implementation details - where we dive into front-end objects and implementation details. | |
| ## Backend | |
| ### 1. CSSAgent events | |
| The `StyleSheetChanged` event was removed in favor of granular events. The total list of events: | |
| - StyleSheet-wise events | |
| - `StyleSheetAdded = { styleSheetId, StyleSheetHeaderPayload }` | |
| - `StyleSheetRemoved = { styleSheetId }` | |
| - *new* `StyleSheetTextUpdated = { styleSheetId }` - this happens whenever a non-model stylesheet text update happens to avoid a storm of model-wise change events. | |
| - Model-wise events, which happen in a result of model-wise editing: | |
| - `RuleSelectorChanged = { styleSheetId, oldRange, newText, selectorPayload }` | |
| - `StyleTextChanged = { styleSheetId, oldRange, newText, stylePayload }` | |
| - `MediaTextChanged = { styleSheetId, oldRange, newText, mediaPayload }` | |
| - `RuleRemoved = { styleSheetId, oldRange, newText }` | |
| - `RuleAdded = { styleSheetId, oldRange, newText, rulePayload }` | |
| - MediaQuery events: | |
| - `MediaQueryResultChanged` | |
| > **NOTE** `RuleSelectorChanged` event sends `selectorPayload` instead of `rulePayload` | |
| ### 2. StyleSheedID deduplication | |
| If two stylesheets have the same content and the same sourceURL, then they are given the same `styleSheetId`. | |
| > **FIXME** What if some stylesheets with the same contents and same sourceURL have modified CSSOM? | |
| ### 3. Editing operation | |
| `CSSAgent` supports three editing operations. | |
| - `setRuleSelector` | |
| - `setStyleText` | |
| - `setMediaText` | |
| These operations result in appropriate events sent from CSSAgent (e.g. `RuleSelectorChanged` is sent during the successful `setRuleSelector` operation). The operations themselves return `true` or `false` regarding of operation success/failure. | |
| ## Front-end High Level Overview | |
| There is one big class in the front-end: `CSSModel`. | |
| `CSSModel` listens to all backend events, deduplicates editable models, etc. | |
| > **Goal**: Clients of CSS Subsystem should never listen to any CSSAgent (a.k.a. backend) events. Instead, they rely on highlevel model events. | |
| `CSSModel` owns `CSSStyle`, `CSSSelectorList` and `CSSMedia` objects and updates them according to the various backend events. | |
| > **Example**: `CSSModel` should update style while handling `StyleTextChanged` event. | |
| There are three **editable** model classes: `CSSSelectorList`, `CSSStyle` and `CSSMedia`. All have a `setText` method and fire 3 events: | |
| - `Obsolete` - happens as the model gets invalid (e.g. was removed, or if the text range failed to rebaseline). | |
| - `RangeChanged` - whenever source range is changed. Useful for re-rendering anchors. | |
| - `ModelUpdated` - whenever model data gets updated. | |
| The `CSSRule` is a container which holds `CSSSelectorList` object and `CSSStyle` object. | |
| The `CSSRule` can fire a single event - `Obsolete`. Once either of its selector or style becomes obsolete, the rule becomes obsolete too. | |
| > **Note** `CSSRule` does not reference `CSSMedia` objects per se. This is a matched styles detail. | |
| > **Note** The User-Agent and CSSOM-modified rules do not belong to any `CSSStyleSheet`; they are a part of `MatchedStyles` object. | |
| > **Example** The rule will fire `Obsolete` once its parent `CSSStyleSheet` will handle `RuleRemoved` event and will remove it from its rules. | |
| The `CSSModel` also provides 5 fetching methods: | |
| - **[C]** `matchedStylesForNode` : `MatchedStyles` | |
| - **[C]** `computedStyleForNode` : `ComputedStyle` | |
| - **[C]** `inlineStyleForNode` : `InlineStyle` | |
| - **[C]** `platformFontsForNode` : `PlatformFonts` | |
| - **[C]** `allMediQueries` : `MediaList` | |
| *(The **[C]** means "caching", more on this later)* | |
| Every fetching method returns some specific model. Every model is capable of understanding when it gets unrelevant, and fires `Obsolete` event. | |
| > **Example**: `MatchedStyles` might fire `Obsolete` on almost any `CSSAgent` event, as well as some `DOM` events. | |
| Every fetching method does caching: it keeps returning the cached result once its not obsolete, and re-fetches the obsolete one. | |
| ## Front-end Implementation Details | |
| ### 1. Base Class: CSSObject | |
| `CSSObject` is a base class for all of those who can send `Obsolete` event: | |
| - `CSSRule` | |
| - `CSSStyle` | |
| - `MatchedStyles` | |
| - ... | |
| ```javascript | |
| CSSObject = function() { | |
| this._obsolete = false; | |
| } | |
| CSSObject.Events = { | |
| Obsolete | |
| } | |
| CSSObject.prototype = { | |
| isObsolete: function() { | |
| return this._obsolete; | |
| }, | |
| obsolete: function() { | |
| if (this._obsolete) | |
| return; | |
| this._obsolete = true; | |
| FIRE(Obsolete); | |
| } | |
| } | |
| ``` | |
| ### 2. CSSSelectorList, CSSStyle, CSSMedia | |
| The only three **editable** models are `CSSSelectorList`, `CSSStyle` and `CSSMedia`. | |
| They all look similar: | |
| - They all provide the `Obsolete`, `RangeChanged` and `ModelUpdated` events. | |
| - They all have `equals` method. | |
| - They all have `initWithPayload` method which could be called multiple times. Each `initWithPayload` fires `ModelUpdated` event. | |
| - Each has a `range` getter which returns either a text range inside parent stylesheet, or `NULL` in case of `CSSOM/User-Agent` origin. | |
| That said, all `CSSSelectorList`, `CSSStyle` and `CSSMedia` extend the following base class. | |
| ```javascript | |
| /** | |
| * @extends {CSSObject} | |
| * @param {?styleSheetId} styleSheetId | |
| * @param {?TextRange} textRange | |
| */ | |
| CSSEditableModel = function(styleSheetId, textRange) { | |
| this._styleSheetId = styleSheetId; | |
| this._range = textRange; | |
| } | |
| CSSEditableModel.Events = { | |
| RangeChanged, | |
| ModelUpdated | |
| } | |
| CSSEditableModel.prototype = { | |
| /** | |
| * @return {?string} | |
| */ | |
| id: function() { | |
| // @see CSSModel.textId below. | |
| return CSSModel.textId(this._styleSheetId, this._range); | |
| }, | |
| handleEdit: function(event) { | |
| // This method is called for source-based models only. | |
| console.asert(this._range); | |
| console.assert(this._styleSheetId); | |
| if (this._styleSheetId !== event.styleSheetId) | |
| return; | |
| var newRange = this._range.rebaseline(event); | |
| if (!newRange) { | |
| this.obsolete(); | |
| } else if (!newRange.equals(this._range)) { | |
| this._range = newRange; | |
| FIRE(RangeChanged); | |
| } | |
| }, | |
| /** | |
| * @param {?CSSEditableModel} other | |
| * @return {boolean} | |
| */ | |
| equals: function(other) { | |
| // Do not forget to do a instanceOf check! | |
| return false; | |
| }, | |
| /** | |
| * @protected | |
| */ | |
| initWithPayload: function(payload) { | |
| FIRE(ModelUpdated); | |
| }, | |
| /** | |
| * @return {boolean} | |
| */ | |
| editable: function() { | |
| return !!this._range && !!this._styleSheetId; | |
| }, | |
| /** | |
| * @return {!Promise.<boolean>} | |
| */ | |
| setText: function(newText) { | |
| return Promise.resolve(false); | |
| } | |
| } | |
| ``` | |
| > **Note** every model overrides `initWithPayload` method. | |
| ### 3. CSSRule | |
| `CSSRule` is just a container of `CSSStyle` and `CSSSelectorList` objects. It gets obsolete once either style or selector gets obsolete. | |
| > **Note** As there is no `CSSStyleSheetHeader` for inline stylesheets, we store `styleSheetId` instead of direct links to `CSSStyleSheetHeader` objects. | |
| > **FIXME** Should selector and style become obsolete as the rule does? | |
| ```javascript | |
| /** | |
| * @extends {CSSObject} | |
| */ | |
| CSSRule = function(cssModel, payload) { | |
| this.styleSheetId = payload.styleSheetId; | |
| this.origin = payload.origin; | |
| this.style = cssModel.styleForPayload(payload.style); | |
| this.selector = cssModel.selectorForPayload(payload.selector); | |
| LISTEN(style, this.obsolete); | |
| LISTEN(selector, this.obsolete); | |
| } | |
| ``` | |
| ### 4. CSSModel | |
| The biggest class that manages all sources-based objects is `CSSModel`. | |
| The class deduplicates models via the following methods: | |
| - `selectorForPayload` | |
| - `styleForPayload` | |
| - `mediaForPayload` | |
| > **NOTE** As there is a 1-to-1 relationship between `CSSRule` and `CSSSelector` objects, there's no need to deduplicate `CSSRule` objects. | |
| The class handles all `CSSAgent` events. | |
| The class provides the following methods for fetching: | |
| - `matchedStylesForNode` : `MatchedStyles` | |
| - `computedStyleForNode` : `ComputedStyle` | |
| - `inlineStyleForNode` : `InlineStyle` | |
| - `platformFontsForNode` : `PlatformFonts` | |
| - `allMediaQueries` : `MediaList` | |
| #### 4.1 Model Deduplication | |
| The CSSModel deduplicates selectors, styles and medias. It stores them all in a map, referencing by id. The id of editable model is just its position in the source styles sheet. | |
| ```javascript | |
| CSSModel = function() { | |
| // CSSSelectorList, CSSMedia, CSSStyle | |
| this._models = new Map(); | |
| } | |
| CSSModel.textId = function(styleSheetId, range) { | |
| if (!styleSheetId || !range) | |
| return null; | |
| return styleSheetId + ":" + range.toString(); | |
| } | |
| CSSModel.prototype = { | |
| // selectorForPayload and styleForPayload are the same. | |
| ruleForPayload: function(payload) { | |
| return this._modelForPayload(payload, CSSRule); | |
| }, | |
| _modelForPayload: function(payload, ModelConstructor) { | |
| var newModel = new ModelConstructor(this, payload); | |
| if (!newModel.id()) | |
| return newModel; | |
| var oldModel = this._models.get(newModel.id()); | |
| if (oldModel && oldModel.equals(newModel)) | |
| return oldModel; | |
| if (oldModel && !oldModel.equals(newModel)) { | |
| console.error("Old model exists but does not match!"); | |
| oldModel.obsolete(); | |
| this._models.delete(newModel.id()); | |
| } | |
| this._models.set(newModel.id(), newModel); | |
| return newModel; | |
| }, | |
| } | |
| ``` | |
| #### 4.2 CSSAgent Event Handling | |
| CSSModel handles all CSS events: | |
| - it rebases text positions of editable models as some edits happen | |
| - it removes rules once some were removed | |
| - it adds and removes CSSStyleSheetHeader objects | |
| ```javascript | |
| CSSModel = function() { | |
| ... | |
| LISTEN(SelectorChanged, this._onModelEdited.bind(this, CSSSelectorList)); | |
| LISTEN(StyleChanged, this._onModelEdited.bind(this, CSSStyle)); | |
| LISTEN(MediaChanged, this._onModelEdited.bind(this, CSSMedia)); | |
| LISTEN(RuleAdded, this._onRuleAdded); | |
| LISTEN(RuleRemoved, this._onRuleRemoved); | |
| LISTEN(StyleSheetAdded, this._onStyleSheetAdded); | |
| LISTEN(StyleSheetRemoved, this._onStyleSheetRemoved); | |
| LISTEN(StyleSheetTextUpdated, this._onStyleSheetTextUpdated); | |
| } | |
| CSSModel.prototype = { | |
| ... | |
| _onStyleSheetTextUpdated: function(event) { | |
| // Remove all models from this styleSheet. | |
| var models = new Map(); | |
| for (var model of this._models) { | |
| if (model.styleSheetId === event.styleSheetId) | |
| model.obsolete(); | |
| if (!model.isObsolete()) | |
| models.set(model.id(), model); | |
| } | |
| this._models = models; | |
| }, | |
| _onStyleSheetRemoved: function(event) { | |
| REMOVE_STYLE_SHEET_HEADER(); | |
| var models = new Map(); | |
| for (var model of this._models) { | |
| if (model.styleSheetId === event.styleSheetId) | |
| model.obsolete(); | |
| if (!model.isObsolete()) | |
| models.set(model.id(), model); | |
| } | |
| this._models = models; | |
| }, | |
| _onRuleRemoved: function(event) { | |
| // The rebase will obsolete all models with intersecting ranges. | |
| this._rebaseModelRanges(event); | |
| }, | |
| _onRuleAdded: function(event) { | |
| // The rebase will obsolete all models with intersecting ranges. | |
| this._rebaseModelRanges(event); | |
| }, | |
| _rebaseModelRanges: function(event) { | |
| var newModels = new Map(); | |
| for (var model of this._models) { | |
| model.handleEdit(event); | |
| if (!model.isObsolete()) | |
| newModels.set(model.id(), model); | |
| } | |
| this._models = newModels; | |
| }, | |
| _onModelEdited: function(ModelClass, event) { | |
| var textId = CSSModel.textId(event.styleSheetId, event.oldRange); | |
| // Pull off edited model from map to avoid it be rebaselined. | |
| var editedModel = this._models.get(textId); | |
| this._models.delete(textId); | |
| this._rebaseModelRanges(event); | |
| if (!editedModel) | |
| return; | |
| if (!(editedModel instanceof ModelClass)) { | |
| editedModel.obsolete(); | |
| return; | |
| } | |
| editedModel.initWithPayload(event.payload); | |
| this._models.set(editedModel.id(), editedModel); | |
| }, | |
| ... | |
| } | |
| ``` | |
| #### 4.3 Fetching methods | |
| All the fetching methods return a specific descendant of `CSSObject`, which gets obsolete under specific conditions. | |
| > **Example** `getComputedStyle` method returns `ComputedStyle` object which gets obsolete on any change of css or dom models. | |
| The invalidation summary: | |
| - `ComputedStyle` and `PlatformFonts` objects get obsolete once *any* css agent event happens, as well as any *domMutated* event. | |
| - `MediaList` object gets obsolete once any of its medias get obsolete, or `StyleSheetAdded`. | |
| - `matchedStyle` object gets obsolete once any of its rule objects get obsolete, or `CSStyleSheetAdded` gets fired, or `RuleAdded` gets fired, or `MediaQueryResultChanged` gets fired. | |
| > **FIXME** when do `InlineStyle` object gets obsolete? | |
| All fetching methods cache returned value per node until it gets obsolete. E.g. `getComputedStyleForNode`, **called with the same `nodeId`**, does the following: | |
| 1. On the first run, `getComputedStyleForNode` fetches payload from CSSAgent, constructs the `ComputedStyle` object and saves it internally. | |
| 2. As far as the `ComputedStyle` is not obsolete, the `getComputedStyleForNode` always returns the same `ComputedStyle` object without any round-trip to the backend. | |
| 3. As soon as the cached value gets obsolete, the `getComputedStyleForNode` will fetch a new value from the backend. | |
| > **Note** This same-node caching behavior is implemented with the help of `CSSCachedObject` utility class. | |
| Whenever `getComputedStyleForNode` **gets called with different `nodeId`'s**, it maintains caches for the last finite (e.g. 2) amount of `nodeId`'s. | |
| > **Note** The across-nodeId caching is implemented via the map with constrain on amount of entries (with FIFO policy when dealing with oversizing) | |
| ### Appendix: CSSCachedObject | |
| Consider you've got a `fetchCSSObject` method, which grabs some payload from backend and creates a `CSSObject`. (examples: `getComputedStyle`, `getMatchedStyles` etc). | |
| You'd like to call the `fetchCSSObject` as little as possible, returning previous value until it is obsolete. | |
| The `CSSCachedObject` implements this functionality. | |
| ```javascript | |
| /** | |
| * @param {function():Promise<CSSObject>} fetchCSSObject | |
| */ | |
| CSSCachedObject = function(fetchCSSObject) { | |
| this._fetchAction = fetchCSSObject; | |
| this._cssObject = null; | |
| this._promise = null; | |
| } | |
| CSSCachedObject.prototype = { | |
| /** | |
| * @return {!Promise.<!CSSObject>} | |
| */ | |
| fetch: function() { | |
| // If we have relevant data - return it. | |
| if (this._cssObject && !this._cssObject.isObsolete()) | |
| return this._promise; | |
| // If we have outdated data - clear caches. | |
| if (this._cssObject && this._cssObject.isObsolete()) { | |
| delete this._cssObject; | |
| delete this._promise; | |
| } | |
| // If we don't have a fetching promise, then fetch. | |
| if (!this._promise) { | |
| this._promise = this._fetchAction() | |
| .then(this._onSuccess) | |
| .catch(this._onFail); | |
| } | |
| return this._promise; | |
| }, | |
| _onSuccess: function(cssObject) { | |
| this._cssObject = cssObject; | |
| return cssObject; | |
| }, | |
| _onFail: function(error) { | |
| console.log("Error:" + error); | |
| delete this._promise; | |
| } | |
| } | |
| ``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment