diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 0396f4673..e8fb048a7 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -29,6 +29,7 @@ atom.packages.packageDirPaths.unshift(fixturePackagesPath) atom.keymaps.loadBundledKeymaps() keyBindingsToRestore = atom.keymaps.getKeyBindings() commandsToRestore = atom.commands.getSnapshot() +styleElementsToRestore = atom.styles.getSnapshot() window.addEventListener 'core:close', -> window.close() window.addEventListener 'beforeunload', -> @@ -74,6 +75,7 @@ beforeEach -> atom.workspace = new Workspace() atom.keymaps.keyBindings = _.clone(keyBindingsToRestore) atom.commands.restoreSnapshot(commandsToRestore) + atom.styles.restoreSnapshot(styleElementsToRestore) window.resetTimeouts() atom.packages.packageStates = {} diff --git a/spec/style-manager-spec.coffee b/spec/style-manager-spec.coffee new file mode 100644 index 000000000..1bf4770b3 --- /dev/null +++ b/spec/style-manager-spec.coffee @@ -0,0 +1,66 @@ +StyleManager = require '../src/style-manager' + +describe "StyleManager", -> + [manager, addEvents, removeEvents, updateEvents] = [] + + beforeEach -> + manager = new StyleManager + addEvents = [] + removeEvents = [] + updateEvents = [] + + manager.onDidAddStyleElement (event) -> addEvents.push(event) + manager.onDidRemoveStyleElement (event) -> removeEvents.push(event) + manager.onDidUpdateStyleElement (event) -> updateEvents.push(event) + + describe "::addStyleSheet(source, params)", -> + it "adds a stylesheet based on the given source and returns a disposable allowing it to be removed", -> + disposable = manager.addStyleSheet("a {color: red;}") + + expect(addEvents.length).toBe 1 + expect(addEvents[0].textContent).toBe "a {color: red;}" + + styleElements = manager.getStyleElements() + expect(styleElements.length).toBe 1 + expect(styleElements[0].textContent).toBe "a {color: red;}" + + disposable.dispose() + + expect(removeEvents.length).toBe 1 + expect(removeEvents[0].textContent).toBe "a {color: red;}" + expect(manager.getStyleElements().length).toBe 0 + + describe "when a sourcePath parameter is specified", -> + it "ensures a maximum of one style element for the given source path, updating a previous if it exists", -> + disposable1 = manager.addStyleSheet("a {color: red;}", sourcePath: '/foo/bar') + + expect(addEvents.length).toBe 1 + expect(addEvents[0].getAttribute('source-path')).toBe '/foo/bar' + + disposable2 = manager.addStyleSheet("a {color: blue;}", sourcePath: '/foo/bar') + + expect(addEvents.length).toBe 1 + expect(updateEvents.length).toBe 1 + expect(updateEvents[0].getAttribute('source-path')).toBe '/foo/bar' + expect(updateEvents[0].textContent).toBe "a {color: blue;}" + + disposable2.dispose() + addEvents = [] + + manager.addStyleSheet("a {color: yellow;}", sourcePath: '/foo/bar') + + expect(addEvents.length).toBe 1 + expect(addEvents[0].getAttribute('source-path')).toBe '/foo/bar' + expect(addEvents[0].textContent).toBe "a {color: yellow;}" + + describe "when a group parameter is specified", -> + it "inserts the stylesheet at the end of any existing stylesheets for the same group", -> + manager.addStyleSheet("a {color: red}", group: 'a') + manager.addStyleSheet("a {color: blue}", group: 'b') + manager.addStyleSheet("a {color: green}", group: 'a') + + expect(manager.getStyleElements().map (elt) -> elt.textContent).toEqual [ + "a {color: red}" + "a {color: green}" + "a {color: blue}" + ] diff --git a/spec/styles-element-spec.coffee b/spec/styles-element-spec.coffee new file mode 100644 index 000000000..0cfb1a185 --- /dev/null +++ b/spec/styles-element-spec.coffee @@ -0,0 +1,54 @@ +StylesElement = require '../src/styles-element' +StyleManager = require '../src/style-manager' + +describe "StylesElement", -> + [element, addedStyleElements, removedStyleElements, updatedStyleElements] = [] + + beforeEach -> + element = new StylesElement + document.querySelector('#jasmine-content').appendChild(element) + addedStyleElements = [] + removedStyleElements = [] + updatedStyleElements = [] + element.onDidAddStyleElement (element) -> addedStyleElements.push(element) + element.onDidRemoveStyleElement (element) -> removedStyleElements.push(element) + element.onDidUpdateStyleElement (element) -> updatedStyleElements.push(element) + + it "renders a style tag for all currently active stylesheets in the style manager", -> + initialChildCount = element.children.length + + disposable1 = atom.styles.addStyleSheet("a {color: red;}") + expect(element.children.length).toBe initialChildCount + 1 + expect(element.children[initialChildCount].textContent).toBe "a {color: red;}" + expect(addedStyleElements).toEqual [element.children[initialChildCount]] + + disposable2 = atom.styles.addStyleSheet("a {color: blue;}") + expect(element.children.length).toBe initialChildCount + 2 + expect(element.children[initialChildCount + 1].textContent).toBe "a {color: blue;}" + expect(addedStyleElements).toEqual [element.children[initialChildCount], element.children[initialChildCount + 1]] + + disposable1.dispose() + expect(element.children.length).toBe initialChildCount + 1 + expect(element.children[initialChildCount].textContent).toBe "a {color: blue;}" + expect(removedStyleElements).toEqual [addedStyleElements[0]] + + it "orders style elements by group", -> + initialChildCount = element.children.length + + atom.styles.addStyleSheet("a {color: red}", group: 'a') + atom.styles.addStyleSheet("a {color: blue}", group: 'b') + atom.styles.addStyleSheet("a {color: green}", group: 'a') + + expect(element.children[initialChildCount].textContent).toBe "a {color: red}" + expect(element.children[initialChildCount + 1].textContent).toBe "a {color: green}" + expect(element.children[initialChildCount + 2].textContent).toBe "a {color: blue}" + + it "updates existing style nodes when style elements are updated", -> + initialChildCount = element.children.length + + atom.styles.addStyleSheet("a {color: red;}", sourcePath: '/foo/bar') + atom.styles.addStyleSheet("a {color: blue;}", sourcePath: '/foo/bar') + + expect(element.children.length).toBe initialChildCount + 1 + expect(element.children[initialChildCount].textContent).toBe "a {color: blue;}" + expect(updatedStyleElements).toEqual [element.children[initialChildCount]] diff --git a/spec/theme-manager-spec.coffee b/spec/theme-manager-spec.coffee index c232f11ac..048d793ae 100644 --- a/spec/theme-manager-spec.coffee +++ b/spec/theme-manager-spec.coffee @@ -92,8 +92,8 @@ describe "ThemeManager", -> runs -> reloadHandler.reset() - expect($('style.theme')).toHaveLength 1 - expect($('style.theme:eq(0)').attr('id')).toMatch /atom-dark-syntax/ + expect($('style[group=theme]')).toHaveLength 1 + expect($('style[group=theme]:eq(0)').attr('source-path')).toMatch /atom-dark-syntax/ atom.config.set('core.themes', ['atom-light-syntax', 'atom-dark-syntax']) waitsFor -> @@ -101,9 +101,9 @@ describe "ThemeManager", -> runs -> reloadHandler.reset() - expect($('style.theme')).toHaveLength 2 - expect($('style.theme:eq(0)').attr('id')).toMatch /atom-dark-syntax/ - expect($('style.theme:eq(1)').attr('id')).toMatch /atom-light-syntax/ + expect($('style[group=theme]')).toHaveLength 2 + expect($('style[group=theme]:eq(0)').attr('source-path')).toMatch /atom-dark-syntax/ + expect($('style[group=theme]:eq(1)').attr('source-path')).toMatch /atom-light-syntax/ atom.config.set('core.themes', []) waitsFor -> @@ -111,7 +111,7 @@ describe "ThemeManager", -> runs -> reloadHandler.reset() - expect($('style.theme')).toHaveLength 0 + expect($('style[group=theme]')).toHaveLength 0 # atom-dark-ui has an directory path, the syntax one doesn't atom.config.set('core.themes', ['theme-with-index-less', 'atom-dark-ui']) @@ -119,7 +119,7 @@ describe "ThemeManager", -> reloadHandler.callCount == 1 runs -> - expect($('style.theme')).toHaveLength 2 + expect($('style[group=theme]')).toHaveLength 2 importPaths = themeManager.getImportPaths() expect(importPaths.length).toBe 1 expect(importPaths[0]).toContain 'atom-dark-ui' @@ -142,8 +142,8 @@ describe "ThemeManager", -> expect(stylesheetAddedHandler).toHaveBeenCalled() expect(stylesheetsChangedHandler).toHaveBeenCalled() - element = $('head style[id*="css.css"]') - expect(element.attr('id')).toBe themeManager.stringToId(cssPath) + element = $('head style[source-path*="css.css"]') + expect(element.attr('source-path')).toBe themeManager.stringToId(cssPath) expect(element.text()).toBe fs.readFileSync(cssPath, 'utf8') expect(element[0].sheet).toBe stylesheetAddedHandler.argsForCall[0][0] @@ -159,8 +159,8 @@ describe "ThemeManager", -> themeManager.requireStylesheet(lessPath) expect($('head style').length).toBe lengthBefore + 1 - element = $('head style[id*="sample.less"]') - expect(element.attr('id')).toBe themeManager.stringToId(lessPath) + element = $('head style[source-path*="sample.less"]') + expect(element.attr('source-path')).toBe themeManager.stringToId(lessPath) expect(element.text()).toBe """ #header { color: #4d926f; @@ -178,9 +178,9 @@ describe "ThemeManager", -> it "supports requiring css and less stylesheets without an explicit extension", -> themeManager.requireStylesheet path.join(__dirname, 'fixtures', 'css') - expect($('head style[id*="css.css"]').attr('id')).toBe themeManager.stringToId(atom.project.resolve('css.css')) + expect($('head style[source-path*="css.css"]').attr('source-path')).toBe themeManager.stringToId(atom.project.resolve('css.css')) themeManager.requireStylesheet path.join(__dirname, 'fixtures', 'sample') - expect($('head style[id*="sample.less"]').attr('id')).toBe themeManager.stringToId(atom.project.resolve('sample.less')) + expect($('head style[source-path*="sample.less"]').attr('source-path')).toBe themeManager.stringToId(atom.project.resolve('sample.less')) $('head style[id*="css.css"]').remove() $('head style[id*="sample.less"]').remove() diff --git a/src/atom.coffee b/src/atom.coffee index b8dee131e..cf6aa54bd 100644 --- a/src/atom.coffee +++ b/src/atom.coffee @@ -14,6 +14,7 @@ fs = require 'fs-plus' {$} = require './space-pen-extensions' WindowEventHandler = require './window-event-handler' +StylesElement = require './styles-element' # Essential: Atom global for dealing with packages, themes, menus, and the window. # @@ -186,6 +187,7 @@ class Atom extends Model Clipboard = require './clipboard' Syntax = require './syntax' ThemeManager = require './theme-manager' + StyleManager = require './style-manager' ContextMenuManager = require './context-menu-manager' MenuManager = require './menu-manager' {devMode, safeMode, resourcePath} = @getLoadSettings() @@ -205,6 +207,8 @@ class Atom extends Model @keymap = @keymaps # Deprecated @commands = new CommandRegistry @packages = new PackageManager({devMode, configDirPath, resourcePath, safeMode}) + @styles = new StyleManager + document.head.appendChild(new StylesElement) @themes = new ThemeManager({packageManager: @packages, configDirPath, resourcePath, safeMode}) @contextMenu = new ContextMenuManager({resourcePath, devMode}) @menu = new MenuManager({resourcePath}) diff --git a/src/style-manager.coffee b/src/style-manager.coffee new file mode 100644 index 000000000..9f6b912fd --- /dev/null +++ b/src/style-manager.coffee @@ -0,0 +1,86 @@ +{Emitter, Disposable} = require 'event-kit' + +module.exports = +class StyleManager + constructor: -> + @emitter = new Emitter + @styleElements = [] + @styleElementsBySourcePath = {} + + observeStyleElements: (callback) -> + callback(styleElement) for styleElement in @getStyleElements() + @onDidAddStyleElement(callback) + + onDidAddStyleElement: (callback) -> + @emitter.on 'did-add-style-element', callback + + onDidRemoveStyleElement: (callback) -> + @emitter.on 'did-remove-style-element', callback + + onDidUpdateStyleElement: (callback) -> + @emitter.on 'did-update-style-element', callback + + getStyleElements: -> + @styleElements.slice() + + addStyleSheet: (source, params) -> + sourcePath = params?.sourcePath + group = params?.group + + if sourcePath? and styleElement = @styleElementsBySourcePath[sourcePath] + updated = true + else + styleElement = document.createElement('style') + if sourcePath? + styleElement.sourcePath = sourcePath + styleElement.setAttribute('source-path', sourcePath) + + if context? + styleElement.context = context + styleElement.setAttribute('context', context) + + if group? + styleElement.group = group + styleElement.setAttribute('group', group) + + styleElement.textContent = source + + if updated + @emitter.emit 'did-update-style-element', styleElement + else + @addStyleElement(styleElement) + + new Disposable => @removeStyleElement(styleElement) + + addStyleElement: (styleElement) -> + {sourcePath, group} = styleElement + + if group? + for existingElement, index in @styleElements + if existingElement.group is group + insertIndex = index + 1 + else + break if insertIndex? + insertIndex ?= @styleElements.length + + @styleElements.splice(insertIndex, 0, styleElement) + @styleElementsBySourcePath[sourcePath] ?= styleElement if sourcePath? + @emitter.emit 'did-add-style-element', styleElement + + removeStyleElement: (styleElement) -> + index = @styleElements.indexOf(styleElement) + unless index is -1 + @styleElements.splice(index, 1) + delete @styleElementsBySourcePath[styleElement.sourcePath] if styleElement.sourcePath? + @emitter.emit 'did-remove-style-element', styleElement + + getSnapshot: -> + @styleElements.slice() + + restoreSnapshot: (styleElementsToRestore) -> + for styleElement in @getStyleElements() + @removeStyleElement(styleElement) unless styleElement in styleElementsToRestore + + existingStyleElements = @getStyleElements() + for styleElement in styleElementsToRestore + @addStyleElement(styleElement) unless styleElement in existingStyleElements diff --git a/src/styles-element.coffee b/src/styles-element.coffee new file mode 100644 index 000000000..069c3f8c7 --- /dev/null +++ b/src/styles-element.coffee @@ -0,0 +1,50 @@ +{Emitter, CompositeDisposable} = require 'event-kit' + +class StylesElement extends HTMLElement + createdCallback: -> + @emitter = new Emitter + + onDidAddStyleElement: (callback) -> + @emitter.on 'did-add-style-element', callback + + onDidRemoveStyleElement: (callback) -> + @emitter.on 'did-remove-style-element', callback + + onDidUpdateStyleElement: (callback) -> + @emitter.on 'did-update-style-element', callback + + attachedCallback: -> + @subscriptions = new CompositeDisposable + @styleElementClonesByOriginalElement = new WeakMap + @subscriptions.add atom.styles.observeStyleElements(@styleElementAdded.bind(this)) + @subscriptions.add atom.styles.onDidRemoveStyleElement(@styleElementRemoved.bind(this)) + @subscriptions.add atom.styles.onDidUpdateStyleElement(@styleElementUpdated.bind(this)) + + styleElementAdded: (styleElement) -> + styleElementClone = styleElement.cloneNode(true) + @styleElementClonesByOriginalElement.set(styleElement, styleElementClone) + + group = styleElement.getAttribute('group') + if group? + for child in @children + if child.getAttribute('group') is group and child.nextSibling?.getAttribute('group') isnt group + insertBefore = child.nextSibling + break + + @insertBefore(styleElementClone, insertBefore) + @emitter.emit 'did-add-style-element', styleElementClone + + styleElementRemoved: (styleElement) -> + styleElementClone = @styleElementClonesByOriginalElement.get(styleElement) + styleElementClone.remove() + @emitter.emit 'did-remove-style-element', styleElementClone + + styleElementUpdated: (styleElement) -> + styleElementClone = @styleElementClonesByOriginalElement.get(styleElement) + styleElementClone.textContent = styleElement.textContent + @emitter.emit 'did-update-style-element', styleElementClone + + detachedCallback: -> + @subscriptions.dispose() + +module.exports = StylesElement = document.registerElement 'atom-styles', prototype: StylesElement.prototype diff --git a/src/theme-manager.coffee b/src/theme-manager.coffee index 3c05c3eb9..edf371556 100644 --- a/src/theme-manager.coffee +++ b/src/theme-manager.coffee @@ -19,9 +19,39 @@ class ThemeManager constructor: ({@packageManager, @resourcePath, @configDirPath, @safeMode}) -> @emitter = new Emitter + @styleSheetDisposablesBySourcePath = {} @lessCache = null @initialLoadComplete = false @packageManager.registerPackageActivator(this, ['theme']) + @sheetsByStyleElement = new WeakMap + + stylesElement = document.head.querySelector('atom-styles') + stylesElement.onDidAddStyleElement @styleElementAdded.bind(this) + stylesElement.onDidRemoveStyleElement @styleElementRemoved.bind(this) + stylesElement.onDidUpdateStyleElement @styleElementUpdated.bind(this) + + styleElementAdded: (styleElement) -> + {sheet} = styleElement + @sheetsByStyleElement.set(styleElement, sheet) + @emit 'stylesheet-added', sheet + @emitter.emit 'did-add-stylesheet', sheet + @emit 'stylesheets-changed' + @emitter.emit 'did-change-stylesheets' + + styleElementRemoved: (styleElement) -> + sheet = @sheetsByStyleElement.get(styleElement) + @emit 'stylesheet-removed', sheet + @emitter.emit 'did-remove-stylesheet', sheet + @emit 'stylesheets-changed' + @emitter.emit 'did-change-stylesheets' + + styleElementUpdated: ({sheet}) -> + @emit 'stylesheet-removed', sheet + @emitter.emit 'did-remove-stylesheet', sheet + @emit 'stylesheet-added', sheet + @emitter.emit 'did-add-stylesheet', sheet + @emit 'stylesheets-changed' + @emitter.emit 'did-change-stylesheets' ### Section: Event Subscription @@ -188,7 +218,6 @@ class ThemeManager if fullPath = @resolveStylesheet(stylesheetPath) content = @loadStylesheet(fullPath) @applyStylesheet(fullPath, content, type) - new Disposable => @removeStylesheet(fullPath) else throw new Error("Could not find a file at path '#{stylesheetPath}'") @@ -204,8 +233,7 @@ class ThemeManager @userStylesheetPath = userStylesheetPath @userStylesheetFile = new File(userStylesheetPath) - @userStylesheetFile.on 'contents-changed moved removed', => - @loadUserStylesheet() + @userStylesheetFile.on 'contents-changed moved removed', => @loadUserStylesheet() userStylesheetContents = @loadStylesheet(userStylesheetPath, true) @applyStylesheet(userStylesheetPath, userStylesheetContents, 'userTheme') @@ -219,7 +247,7 @@ class ThemeManager @requireStylesheet(nativeStylesheetPath) stylesheetElementForId: (id) -> - document.head.querySelector("""style[id="#{id}"]""") + document.head.querySelector("atom-styles style[source-path=\"#{id}\"]") resolveStylesheet: (stylesheetPath) -> if path.extname(stylesheetPath).length > 0 @@ -256,40 +284,10 @@ class ThemeManager """ removeStylesheet: (stylesheetPath) -> - fullPath = @resolveStylesheet(stylesheetPath) ? stylesheetPath - element = @stylesheetElementForId(@stringToId(fullPath)) - if element? - {sheet} = element - element.remove() - @emit 'stylesheet-removed', sheet - @emitter.emit 'did-remove-stylesheet', sheet - @emit 'stylesheets-changed' - @emitter.emit 'did-change-stylesheets' + @styleSheetDisposablesBySourcePath[stylesheetPath]?.dispose() applyStylesheet: (path, text, type='bundled') -> - styleId = @stringToId(path) - styleElement = @stylesheetElementForId(styleId) - - if styleElement? - @emit 'stylesheet-removed', styleElement.sheet - @emitter.emit 'did-remove-stylesheet', styleElement.sheet - styleElement.textContent = text - else - styleElement = document.createElement('style') - styleElement.setAttribute('class', type) - styleElement.setAttribute('id', styleId) - styleElement.textContent = text - - elementToInsertBefore = _.last(document.head.querySelectorAll("style.#{type}"))?.nextElementSibling - if elementToInsertBefore? - document.head.insertBefore(styleElement, elementToInsertBefore) - else - document.head.appendChild(styleElement) - - @emit 'stylesheet-added', styleElement.sheet - @emitter.emit 'did-add-stylesheet', styleElement.sheet - @emit 'stylesheets-changed' - @emitter.emit 'did-change-stylesheets' + @styleSheetDisposablesBySourcePath[path] = atom.styles.addStyleSheet(text, sourcePath: path, group: type) ### Section: Private @@ -358,17 +356,3 @@ class ThemeManager themePaths.push(path.join(themePath, Package.stylesheetsDir)) themePaths.filter (themePath) -> fs.isDirectorySync(themePath) - - updateGlobalEditorStyle: (property, value) -> - unless styleNode = @stylesheetElementForId('global-editor-styles') - @applyStylesheet('global-editor-styles', 'atom-text-editor {}') - styleNode = @stylesheetElementForId('global-editor-styles') - - {sheet} = styleNode - editorRule = sheet.cssRules[0] - editorRule.style[property] = value - - @emit 'stylesheet-updated', sheet - @emitter.emit 'did-update-stylesheet', sheet - @emit 'stylesheets-changed' - @emitter.emit 'did-change-stylesheets' diff --git a/src/workspace-element.coffee b/src/workspace-element.coffee index 686674cde..e50ba8879 100644 --- a/src/workspace-element.coffee +++ b/src/workspace-element.coffee @@ -8,8 +8,11 @@ WorkspaceView = null module.exports = class WorkspaceElement extends HTMLElement + globalTextEditorStyleSheet: null + createdCallback: -> @subscriptions = new CompositeDisposable + @initializeGlobalTextEditorStyleSheet() @initializeContent() @observeScrollbarStyle() @observeTextEditorFontConfig() @@ -22,6 +25,10 @@ class WorkspaceElement extends HTMLElement detachedCallback: -> @model.destroy() + initializeGlobalTextEditorStyleSheet: -> + atom.styles.addStyleSheet('atom-text-editor {}', sourcePath: 'global-text-editor-styles') + @globalTextEditorStyleSheet = document.head.querySelector('style[source-path="global-text-editor-styles"]').sheet + initializeContent: -> @classList.add 'workspace' @setAttribute 'tabindex', -1 @@ -46,9 +53,9 @@ class WorkspaceElement extends HTMLElement @classList.add("scrollbars-visible-when-scrolling") observeTextEditorFontConfig: -> - @subscriptions.add atom.config.observe 'editor.fontSize', @setTextEditorFontSize - @subscriptions.add atom.config.observe 'editor.fontFamily', @setTextEditorFontFamily - @subscriptions.add atom.config.observe 'editor.lineHeight', @setTextEditorLineHeight + @subscriptions.add atom.config.observe 'editor.fontSize', @setTextEditorFontSize.bind(this) + @subscriptions.add atom.config.observe 'editor.fontFamily', @setTextEditorFontFamily.bind(this) + @subscriptions.add atom.config.observe 'editor.lineHeight', @setTextEditorLineHeight.bind(this) createSpacePenShim: -> WorkspaceView ?= require './workspace-view' @@ -68,13 +75,18 @@ class WorkspaceElement extends HTMLElement @__spacePenView.setModel(@model) setTextEditorFontSize: (fontSize) -> - atom.themes.updateGlobalEditorStyle('font-size', fontSize + 'px') + @updateGlobalEditorStyle('font-size', fontSize + 'px') setTextEditorFontFamily: (fontFamily) -> - atom.themes.updateGlobalEditorStyle('font-family', fontFamily) + @updateGlobalEditorStyle('font-family', fontFamily) setTextEditorLineHeight: (lineHeight) -> - atom.themes.updateGlobalEditorStyle('line-height', lineHeight) + @updateGlobalEditorStyle('line-height', lineHeight) + + updateGlobalEditorStyle: (property, value) -> + editorRule = @globalTextEditorStyleSheet.cssRules[0] + editorRule.style[property] = value + atom.themes.emitter.emit 'did-update-stylesheet', @globalTextEditorStyleSheet handleFocus: (event) -> @model.getActivePane().activate()