Skip to content

Instantly share code, notes, and snippets.

@ggoodman
Created July 24, 2012 21:57
Show Gist options
  • Save ggoodman/3172938 to your computer and use it in GitHub Desktop.
Save ggoodman/3172938 to your computer and use it in GitHub Desktop.
Reacting to additions/deletions/changes to keys and their values in Angular.js

How can I react to additions and deletions of keys in a scope object as well as to changes to sub values?

The scratch

The new version of plunker has the concept of a scratch. The scratch is the current state of the editor that can periodically be saved as a plunk. The scratch is structured as follows:

{
  files: {
    "index.html": {
      filename: "index.html",
      content: "<h1>Html goes here</h1>"
    }, /* More files... */
  }
}

The scratch is defined as a directive and so I can't seem to get access to the parent scope to broadcast events. See scratch.coffee.

Because the scratch is the authority on what is in the current edit session, it seems normal that it should generate the events that would allow other components (the ace textarea) to react to file additions/deletions/changes.

With things organized as described above, I can't figure out how to let the scratch remain the authority on edit session state while allowing it to 'notify' other components of changes to its structure.

Any ideas?

#= require ../vendor/angular
#= require ../services/modes
EditSession = require("ace/edit_session").EditSession
UndoManager = require("ace/undomanager").UndoManager
module = angular.module("plunker.ace", ["plunker.modes"])
module.directive "plunkerAce", ["modes", (modes) ->
restrict: "A"
link: ($scope, el, attrs, ngModel) ->
$scope.ace = ace.edit(el[0])
$scope.$on "layout:resize", ->
$scope.ace.resize()
addSession = (file) ->
unless file.session
session = new EditSession(file.content or "")
session.setTabSize(2)
session.setUseSoftTabs(true)
session.setUndoManager(new UndoManager())
session.setMode(modes.findByFilename(file.filename).source)
changing = false
#$scope.$watch file.content, (content) ->
# console.log "VALUE", content
# changing = true
# session.setValue(content)
# changing = false
read = -> file.content = session.getValue()
session.on 'change', -> $scope.$apply(read) unless changing
file.session = session
# If this worked, then all would be well
$scope.$watch "scratch.files", (files) ->
addSession(file) for filename, file of files
$scope.$on "scratch:add", (file) -> addSession(file)
$scope.$watch "active", (active) ->
addSession(active) unless active.session # Hack to create a session on the first activation
$scope.ace.setSession(active.session) if active.session
]
#= require ../vendor/angular
module = angular.module("plunker.scratch", ["plunker.url"])
module.factory "scratch", ["$http", "$q", "url", ($http, $q, url) ->
new class Scratch
@defaults:
description: "Untitled"
files:
"index.html":
content: """
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="style.css" />
<script src="script.js"></script>
</head>
<body>
<h1>Basic Plunk</h1>
</body>
</html>
"""
filename: "index.html"
"style.css":
content: """
/* CSS goes here */
h1 {
color: blue;
}
"""
filename: "style.css"
"script.js":
content: """
// Javascript goes here
"""
filename: "script.js"
constructor: ->
angular.copy Scratch.defaults, @
promptNewFile: (new_filename) ->
if new_filename ||= prompt("Please enter the filename")
for filename, file of @files
if file.filename == new_filename
alert("A file already exists called: '#{new_filename}'")
return
@files[new_filename] =
filename: new_filename
content: ""
]
@ggoodman
Copy link
Author

I just learned that there is an optional third parameter to $watch()... This seems to allow me to react to the type of events I'm looking for!

@IgorMinar
Copy link

yes, deep watching sounds like a good way to go. especially since it's unlikely to create perf problems in this case (the number of files in a scratch is small).

@ggoodman
Copy link
Author

@IgorMinar check out what I ended up doing in https://github.com/filearts/plunker/blob/angularjs/servers/www/assets/js/directives/ace.coffee

EditSession = require("ace/edit_session").EditSession
UndoManager = require("ace/undomanager").UndoManager


module = angular.module("plunker.ace", ["plunker.modes"])

module.directive "plunkerSession", ["modes", (modes) ->
  restrict: "E"
  require: "?ngModel"
  template: """
    <div style="display: none" ng-model="file.content"></div>
  """
  replace: true
  link: ($scope, el, attrs, ngModel) ->
    file = $scope.file

    session = new EditSession(file.content or "")
    session.setTabSize(2)
    session.setUseSoftTabs(true)
    session.setUndoManager(new UndoManager())
    session.setMode(mode.source) if mode = modes.findByFilename(file.filename)

    ngModel.$render = ->
      session.setValue(ngModel.$viewValue or "")

    read = -> ngModel.$setViewValue(session.getValue())
    session.on 'change', -> $scope.$apply(read)

    read()

    $scope.$on "$destroy", ->
      # How do I destroy the session?

    $scope.$watch "file.filename", (filename) ->
      session.setMode(mode.source) if mode = modes.findByFilename(file.filename)

    $scope.$watch "history.last()", (active) ->
      if active == file
        $scope.ace.setSession(session)
        $scope.ace.focus()
] 

module.directive "plunkerAce", ["modes", (modes) ->
  restrict: "E"
  template: """
    <div class="editor-canvas">
      <plunker-session ng-repeat="(filename, file) in scratch.files"></plunker-session>
    </div>
  """
  replace: true
  link: ($scope, el, attrs, ngModel) ->
    $scope.ace = ace.edit(el[0])


    $scope.$on "layout:resize", ->
      $scope.ace.resize()
]

As you can see, I created an ace directive with represents the editor itself. This directive, in turn, has a template that iterates over another custom directive session based on the scratch.files object. This second directive gets its own scope thanks to the beauty of AngularJS and as such can use the great two-way binding features of ngModel. This lets the session properly encapsulate the data and behaviour of each buffer that can then be attached as needed to the ace instance.

I think that this is a pretty neat pattern for interacting with external libs that can act on an arbitrary number of sub-elements of $scope data.

All in all, a truly awesome lib. Sorry for asking such 'trivial' questions without hacking away first. I'm just excited to get something usable out the door!

@IgorMinar
Copy link

Looks good except for one thing: you are expecting ace, file and history to be present on the current scope that that the directive is placed into.

This implicit dependency leads to code that is hard to maintain as it grows. That's why we have a concept of isolate scopes, these scopes don't inherit from their prototypical parents and allow you to create truly encapsulated and thus reusable components. Check out: https://github.com/IgorMinar/ng-todo/blob/demo-throneofjs/todo.js#L51-53

@ggoodman
Copy link
Author

@IgorMinar, are you saying that in both the ace and session directives, I should have a scope attribute that explicitly lists all scope attributes expected from parent contexts?

@IgorMinar
Copy link

yes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment