Merge pull request #1154 from taigaio/medium-editor

New wysiwyg (medium.com style)
stable
David Barragán Merino 2016-11-22 13:27:02 +01:00 committed by GitHub
commit 8ca70604a3
1354 changed files with 2493 additions and 1486 deletions

View File

@ -1,3 +0,0 @@
{
"directory" : "vendor"
}

View File

@ -5,10 +5,8 @@ before_install:
- export CHROME_BIN=chromium-browser
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
- travis_retry npm install -g bower
- travis_retry npm install -g gulp
install:
- travis_retry npm install
- travis_retry bower install
before_script:
- gulp deploy

View File

@ -2,6 +2,8 @@
## 3.1.0 No name yet (no date yet)
- Velocity forecasting. Create sprints according to team velocity.
- Remove bower
- Add new wysiwyg editor (emojis, local storage changes, mentions)
## 3.0.0 Stellaria Borealis (2016-10-02)

View File

@ -39,6 +39,7 @@ Every code patch accepted in taiga codebase is licensed under [AGPL v3.0](http:/
Please read carefully [our license](https://github.com/taigaio/taiga-front/blob/master/LICENSE) and ask us if you have any questions.
Emoji provided free by [Twemoji](https://github.com/twitter/twemoji)
#### Bug reports, enhancements and support ####
@ -125,14 +126,12 @@ sass -v # should return Sass 3.3.8 (Maptastic Maple)
Complete process for all OS at: http://sass-lang.com/install
**Node + Bower + Gulp**
**Node + Gulp**
We recommend using [nvm](https://github.com/creationix/nvm) to manage different node versions
```
npm install -g gulp
npm install -g bower
npm install
bower install
gulp
```

View File

@ -390,14 +390,22 @@ Svg = () ->
module.directive("tgSvg", [Svg])
Autofocus = ($timeout) ->
Autofocus = ($timeout, $parse, animationFrame) ->
return {
restrict: 'A',
link : ($scope, $element) ->
$timeout -> $element[0].focus()
link : ($scope, $element, attrs) ->
if attrs.ngShow
model = $parse(attrs.ngShow)
$scope.$watch model, (value) ->
if value == true
$timeout () -> $element[0].focus()
else
$timeout () -> $element[0].focus()
}
module.directive('tgAutofocus', ['$timeout', Autofocus])
module.directive('tgAutofocus', ['$timeout', '$parse', "animationFrame", Autofocus])
module.directive 'tgPreloadImage', () ->
spinner = "<img class='loading-spinner' src='/" + window._version + "/svg/spinner-circle.svg' alt='loading...' />"

View File

@ -496,40 +496,37 @@ DeleteButtonDirective = ($log, $repo, $confirm, $location, $template) ->
module.directive("tgDeleteButton", ["$log", "$tgRepo", "$tgConfirm", "$tgLocation", "$tgTemplate", DeleteButtonDirective])
#############################################################################
## Editable description directive
## Editable subject directive
#############################################################################
EditableDescriptionDirective = ($rootscope, $repo, $confirm, $compile, $loading, $selectedText, $modelTransform, $template, $translate) ->
template = $template.get("common/components/editable-description.html")
noDescriptionMegEditMode = $template.get("common/components/editable-description-msg-edit-mode.html")
noDescriptionMegReadMode = $template.get("common/components/editable-description-msg-read-mode.html")
EditableSubjectDirective = ($rootscope, $repo, $confirm, $loading, $modelTransform, $template) ->
template = $template.get("common/components/editable-subject.html")
link = ($scope, $el, $attrs, $model) ->
$el.find('.edit-description').hide()
$el.find('.view-description .edit').hide()
$scope.$on "object:updated", () ->
$el.find('.edit-description').hide()
$el.find('.view-description').show()
$el.find('.edit-subject').hide()
$el.find('.view-subject').show()
isEditable = ->
return $scope.project.my_permissions.indexOf($attrs.requiredPerm) != -1
save = (description) ->
save = (subject) ->
currentLoading = $loading()
.target($el.find('.save-container'))
.start()
transform = $modelTransform.save (item) ->
item.description = description
item.subject = subject
return item
transform.then ->
transform.then =>
$confirm.notify("success")
$rootscope.$broadcast("object:updated")
$el.find('.edit-description').hide()
$el.find('.view-description').show()
$el.find('.edit-subject').hide()
$el.find('.view-subject').show()
transform.then null, ->
$confirm.notify("error")
@ -537,60 +534,43 @@ EditableDescriptionDirective = ($rootscope, $repo, $confirm, $compile, $loading,
transform.finally ->
currentLoading.finish()
cancelEdition = () ->
$scope.item.revert()
$el.find('.edit-description').hide()
$el.find('.view-description').show()
return transform
$el.on "mouseup", ".view-description", (event) ->
# We want to dettect the a inside the div so we use the target and
# not the currentTarget
target = angular.element(event.target)
$el.click ->
return if not isEditable()
return if target.is('a')
return if $selectedText.get().length
$el.find('.edit-description').show()
$el.find('.view-description').hide()
$el.find('textarea').focus()
$el.on "click", "a", (event) ->
target = angular.element(event.target)
href = target.attr('href')
if href.indexOf("#") == 0
event.preventDefault()
$('body').scrollTop($(href).offset().top)
$el.find('.edit-subject').show()
$el.find('.view-subject').hide()
$el.find('input').focus()
$el.on "click", ".save", (e) ->
e.preventDefault()
description = $scope.item.description
save(description)
subject = $scope.item.subject
save(subject)
$el.on "keydown", "textarea", (event) ->
return if event.keyCode != 27
$el.on "keyup", "input", (event) ->
if event.keyCode == 13
subject = $scope.item.subject
save(subject)
else if event.keyCode == 27
$scope.$apply () => $model.$modelValue.revert()
$scope.$applyAsync () ->
title = $translate.instant("COMMON.CONFIRM_CLOSE_EDIT_MODE_TITLE")
message = $translate.instant("COMMON.CONFIRM_CLOSE_EDIT_MODE_MESSAGE")
$confirm.ask(title, null, message).then (askResponse) ->
cancelEdition()
askResponse.finish()
$el.find('.edit-subject').hide()
$el.find('.view-subject').show()
$el.find('.edit-subject').hide()
$scope.$watch $attrs.ngModel, (value) ->
return if not value
$scope.item = value
if isEditable()
$el.find('.view-description .edit').show()
$el.find('.view-description .us-content').addClass('editable')
$scope.noDescriptionMsg = $compile(noDescriptionMegEditMode)($scope)
else
$scope.noDescriptionMsg = $compile(noDescriptionMegReadMode)($scope)
if not isEditable()
$el.find('.view-subject .edit').remove()
$scope.$on "$destroy", ->
$el.off()
return {
link: link
restrict: "EA"
@ -598,81 +578,8 @@ EditableDescriptionDirective = ($rootscope, $repo, $confirm, $compile, $loading,
template: template
}
module.directive("tgEditableDescription", [
"$rootScope",
"$tgRepo",
"$tgConfirm",
"$compile",
"$tgLoading",
"$selectedText",
"$tgQueueModelTransformation",
"$tgTemplate",
"$translate",
EditableDescriptionDirective])
EditableWysiwyg = (attachmentsService, attachmentsFullService) ->
link = ($scope, $el, $attrs, $model) ->
isInEditMode = ->
return $el.find('textarea').is(':visible') and $model.$modelValue.id
uploadFile = (file, type) ->
return if !attachmentsService.validate(file)
return attachmentsFullService.addAttachment($model.$modelValue.project, $model.$modelValue.id, type, file).then (result) ->
if taiga.isImage(result.getIn(['file', 'name']))
return '![' + result.getIn(['file', 'name']) + '](' + result.getIn(['file', 'url']) + ')'
else
return '[' + result.getIn(['file', 'name']) + '](' + result.getIn(['file', 'url']) + ')'
$el.on 'dragover', (e) ->
textarea = $el.find('textarea').focus()
return false
$el.on 'drop', (e) ->
e.stopPropagation()
e.preventDefault()
if isInEditMode()
dataTransfer = e.dataTransfer || (e.originalEvent && e.originalEvent.dataTransfer)
textarea = $el.find('textarea')
textarea.addClass('in-progress')
type = $model.$modelValue['_name']
if type == "userstories"
type = "us"
else if type == "tasks"
type = "task"
else if type == "issues"
type = "issue"
else if type == "wiki"
type = "wiki_page"
promises = _.map dataTransfer.files, (file) ->
return uploadFile(file, type)
Promise.all(promises).then (result) ->
textarea = $el.find('textarea')
$.markItUp({ replaceWith: result.join(' ') })
textarea.removeClass('in-progress')
return {
link: link
restrict: "EA"
require: "ngModel"
}
module.directive("tgEditableWysiwyg", ["tgAttachmentsService", "tgAttachmentsFullService", EditableWysiwyg])
module.directive("tgEditableSubject", ["$rootScope", "$tgRepo", "$tgConfirm", "$tgLoading", "$tgQueueModelTransformation",
"$tgTemplate", EditableSubjectDirective])
#############################################################################
## Common list directives

View File

@ -75,7 +75,6 @@ sizeFormat = =>
module.filter("sizeFormat", sizeFormat)
toMutableFilter = ->
toMutable = (js) ->
return js.toJS()
@ -128,6 +127,15 @@ darkerFilter = ->
module.filter("darker", darkerFilter)
markdownToHTML = (wysiwigService) ->
return (input) ->
if input
return wysiwigService.getHTML(input)
return ""
module.filter("markdownToHTML", ["tgWysiwygService", markdownToHTML])
inArray = ($filter) ->
return (list, arrayFilter, element) ->
if arrayFilter

View File

@ -92,15 +92,16 @@ Loader = ($rootscope) ->
return {
pageLoaded: pageLoaded
open: () -> open
start: (auto=false) ->
if !open
start()
autoClose() if auto
onStart: (fn) ->
$rootscope.$on("loader:start", fn)
return $rootscope.$on("loader:start", fn)
onEnd: (fn) ->
$rootscope.$on("loader:end", fn)
return $rootscope.$on("loader:end", fn)
logRequest: () ->
requestCount++

View File

@ -1,489 +0,0 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/common/wisiwyg.coffee
###
taiga = @.taiga
bindOnce = @.taiga.bindOnce
module = angular.module("taigaCommon")
# How to test lists (-, *, 1.)
# test it with text after & before the list
# + is the cursor position
# CASE 1
# - aa+
# --> enter
# - aa
# - +
# CASE 1
# - +
# --> enter
# +
# CASE 3
# - bb+cc
# --> enter
# - bb
# - cc
# CASE 3
# +- aa
# --> enter
# - aa
#############################################################################
## WYSIWYG markitup editor directive
#############################################################################
MarkitupDirective = ($rootscope, $rs, $selectedText, $template, $compile, $translate, projectService) ->
previewTemplate = $template.get("common/wysiwyg/wysiwyg-markitup-preview.html", true)
link = ($scope, $el, $attrs, $model) ->
if not $scope.project
# for backward compatibility
$scope.project = projectService.project.toJS()
element = angular.element($el)
previewDomNode = $("<div/>", {class: "preview"})
closePreviewMode = ->
element.parents(".markdown").find(".preview").remove()
element.parents(".markItUp").show()
$scope.$on "markdown-editor:submit", ->
closePreviewMode()
cancelablePromise = null
previewInProgress = false
preview = ->
return if previewInProgress
previewInProgress = true
markdownDomNode = element.parents(".markdown")
markItUpDomNode = element.parents(".markItUp")
$rs.mdrender.render($scope.project.id, $model.$modelValue).then (data) ->
html = previewTemplate({data: data.data})
html = $compile(html)($scope)
markdownDomNode.append(html)
markItUpDomNode.hide()
previewInProgress = false
markdown = element.closest(".markdown")
markdown.on "mouseup.preview", ".preview", (event) ->
event.preventDefault()
target = angular.element(event.target)
if !target.is('a') and $selectedText.get().length
return
markdown.off(".preview")
closePreviewMode()
setCaretPosition = (textarea, caretPosition) ->
if textarea.createTextRange
range = textarea.createTextRange()
range.move("character", caretPosition)
range.select()
else if textarea.selectionStart
textarea.focus()
textarea.setSelectionRange(caretPosition, caretPosition)
# Calculate the scroll position
totalLines = textarea.value.split("\n").length
line = textarea.value[0..(caretPosition - 1)].split("\n").length
scrollRelation = line / totalLines
$el.scrollTop((scrollRelation * $el[0].scrollHeight) - ($el.height() / 2))
addLine = (textarea, nline, replace) ->
lines = textarea.value.split("\n")
if replace
lines[nline] = replace + lines[nline]
else
lines[nline] = ""
cursorPosition = 0
for line, key in lines
cursorPosition += line.length + 1 || 1
break if key == nline
textarea.value = lines.join("\n")
#return the new position
if replace
return cursorPosition - lines[nline].length + replace.length - 1
else
return cursorPosition
prepareUrlFormatting = (markItUp) ->
regex = /(<<<|>>>)/gi
result = 0
indices = []
(indices.push(result.index)) while ( (result = regex.exec(markItUp.textarea.value)) )
markItUp.donotparse = indices
urlFormatting = (markItUp) ->
regex = /<<</gi
result = 0
startIndex = 0
loop
result = regex.exec(markItUp.textarea.value)
break if !result
if result.index not in markItUp.donotparse
startIndex = result.index
break
return if !result
regex = />>>/gi
endIndex = 0
loop
result = regex.exec(markItUp.textarea.value)
break if !result
if result.index not in markItUp.donotparse
endIndex = result.index
break
value = markItUp.textarea.value
url = value.substring(startIndex, endIndex).replace('<<<', '').replace('>>>', '')
url = url.replace('(', '%28').replace(')', '%29')
url = url.replace('[', '%5B').replace(']', '%5D')
value = value.substring(0, startIndex) + url + value.substring(endIndex+3, value.length)
markItUp.textarea.value = value
markItUp.donotparse = undefined
markdownTitle = (markItUp, char) ->
heading = ""
n = $.trim(markItUp.selection or markItUp.placeHolder).length
for i in [0..n-1]
heading += char
return "\n"+heading+"\n"
renderMarkItUp = () ->
markdownSettings =
nameSpace: "markdown"
onShiftEnter: {keepDefault:false, openWith:"\n\n"}
onEnter:
keepDefault: false,
replaceWith: () ->
# Allow textcomplete to intercept the enter key if the options list is displayed
# @todo There doesn't seem to be a more graceful way to do this with the textcomplete API.
if not $('.textcomplete-dropdown').is(':visible')
"\n"
afterInsert: (data) ->
lines = data.textarea.value.split("\n")
# Detect if we are in this situation +- aa at the beginning if the textarea
if data.caretPosition > 0
cursorLine = data.textarea.value[0..(data.caretPosition - 1)].split("\n").length
else
cursorLine = 1
newLineContent = data.textarea.value[data.caretPosition..].split("\n")[0]
lastLine = lines[cursorLine - 1]
# unordered list -
match = lastLine.match /^(\s*- ).*/
if match
emptyListItem = lastLine.match /^(\s*)\-\s$/
if emptyListItem
nline = cursorLine - 1
replace = null
else
nline = cursorLine
replace = "#{match[1]}"
markdownCaretPositon = addLine(data.textarea, nline, replace)
# unordered list *
match = lastLine.match /^(\s*\* ).*/
if match
emptyListItem = lastLine.match /^(\s*\* )$/
if emptyListItem
nline = cursorLine - 1
replace = null
else
nline = cursorLine
replace = "#{match[1]}"
markdownCaretPositon = addLine(data.textarea, nline, replace)
# ordered list
match = lastLine.match /^(\s*)(\d+)\.\s/
if match
emptyListItem = lastLine.match /^(\s*)(\d+)\.\s$/
if emptyListItem
nline = cursorLine - 1
replace = null
else
nline = cursorLine
replace = "#{match[1] + (parseInt(match[2], 10) + 1)}. "
markdownCaretPositon = addLine(data.textarea, nline, replace)
setCaretPosition(data.textarea, markdownCaretPositon) if markdownCaretPositon
markupSet: [
{
name: $translate.instant("COMMON.WYSIWYG.H1_BUTTON")
key: "1"
placeHolder: $translate.instant("COMMON.WYSIWYG.H1_SAMPLE_TEXT")
closeWith: (markItUp) -> markdownTitle(markItUp, "=")
},
{
name: $translate.instant("COMMON.WYSIWYG.H2_BUTTON")
key: "2"
placeHolder: $translate.instant("COMMON.WYSIWYG.H2_SAMPLE_TEXT")
closeWith: (markItUp) -> markdownTitle(markItUp, "-")
},
{
name: $translate.instant("COMMON.WYSIWYG.H3_BUTTON")
key: "3"
openWith: "### "
placeHolder: $translate.instant("COMMON.WYSIWYG.H3_SAMPLE_TEXT")
},
{
separator: "---------------"
},
{
name: $translate.instant("COMMON.WYSIWYG.BOLD_BUTTON")
key: "B"
openWith: "**"
closeWith: "**"
placeHolder: $translate.instant("COMMON.WYSIWYG.BOLD_BUTTON_SAMPLE_TEXT")
},
{
name: $translate.instant("COMMON.WYSIWYG.ITALIC_SAMPLE_TEXT")
key: "I"
openWith: "_"
closeWith: "_"
placeHolder: $translate.instant("COMMON.WYSIWYG.ITALIC_SAMPLE_TEXT")
},
{
name: $translate.instant("COMMON.WYSIWYG.STRIKE_BUTTON")
key: "S"
openWith: "~~"
closeWith: "~~"
placeHolder: $translate.instant("COMMON.WYSIWYG.STRIKE_SAMPLE_TEXT")
},
{
separator: "---------------"
},
{
name: $translate.instant("COMMON.WYSIWYG.BULLETED_LIST_BUTTON")
openWith: "- "
placeHolder: $translate.instant("COMMON.WYSIWYG.BULLETED_LIST_SAMPLE_TEXT")
},
{
name: $translate.instant("COMMON.WYSIWYG.NUMERIC_LIST_BUTTON")
openWith: (markItUp) -> markItUp.line+". "
placeHolder: $translate.instant("COMMON.WYSIWYG.NUMERIC_LIST_SAMPLE_TEXT")
},
{
separator: "---------------"
},
{
name: $translate.instant("COMMON.WYSIWYG.PICTURE_BUTTON")
key: "P"
openWith: "!["
closeWith: '](<<<[![Url:!:http://]!]>>> "[![Title]!]")'
placeHolder: $translate.instant("COMMON.WYSIWYG.PICTURE_SAMPLE_TEXT")
beforeInsert:(markItUp) -> prepareUrlFormatting(markItUp)
afterInsert:(markItUp) -> urlFormatting(markItUp)
},
{
name: $translate.instant("COMMON.WYSIWYG.LINK_BUTTON")
key: "L"
openWith: "["
closeWith: '](<<<[![Url:!:http://]!]>>> "[![Title]!]")'
placeHolder: $translate.instant("COMMON.WYSIWYG.LINK_SAMPLE_TEXT")
beforeInsert:(markItUp) -> prepareUrlFormatting(markItUp)
afterInsert:(markItUp) -> urlFormatting(markItUp)
},
{
separator: "---------------"
},
{
name: $translate.instant("COMMON.WYSIWYG.QUOTE_BLOCK_BUTTON")
openWith: "> "
placeHolder: $translate.instant("COMMON.WYSIWYG.QUOTE_BLOCK_SAMPLE_TEXT")
},
{
name: $translate.instant("COMMON.WYSIWYG.CODE_BLOCK_BUTTON")
openWith: "```\n"
placeHolder: $translate.instant("COMMON.WYSIWYG.CODE_BLOCK_SAMPLE_TEXT")
closeWith: "\n```"
},
{
separator: "---------------"
},
{
name: $translate.instant("COMMON.WYSIWYG.PREVIEW_BUTTON")
call: preview
className: "preview-icon"
},
]
afterInsert: (event) ->
target = angular.element(event.textarea)
$model.$setViewValue(target.val())
element
.markItUpRemove()
.markItUp(markdownSettings)
.textcomplete([
# us, task, and issue autocomplete: #id or #<part of title>
{
cache: true
match: /(^|\s)#([a-z0-9]+)$/i,
search: (term, callback) ->
term = taiga.slugify(term)
searchTypes = ['issues', 'tasks', 'userstories', 'epics']
searchProps = ['ref', 'subject']
filter = (item) =>
for prop in searchProps
if taiga.slugify(item[prop]).indexOf(term) >= 0
return true
return false
cancelablePromise.abort() if cancelablePromise
cancelablePromise = $rs.search.do($scope.project.id, term)
cancelablePromise.then (res) =>
# ignore wikipages if they're the only results. can't exclude them in search
if res.count < 1 or res.count == res.wikipages.length
callback([])
else
for type in searchTypes
if res[type] and res[type].length > 0
callback(res[type].filter(filter), true)
# must signal end of lists
callback([])
replace: (res) ->
return "$1\##{res.ref} "
template: (res, term) ->
return "\##{res.ref} - #{res.subject}"
}
# username autocomplete: @username or @<part of name>
{
cache: true
match: /(^|\s)@([a-z0-9\-\._]{2,})$/i
search: (term, callback) ->
username = taiga.slugify(term)
searchProps = ['username', 'full_name', 'full_name_display']
if $scope.project.members.length < 1
callback([])
else
callback $scope.project.members.filter (user) =>
for prop in searchProps
if taiga.slugify(user[prop]).indexOf(username) >= 0
return true
return false
replace: (user) ->
return "$1@#{user.username} "
template: (user) ->
return "#{user.username} - #{user.full_name_display}"
}
# wiki pages autocomplete: [[slug or [[<part of slug>
# if the search function was called with the 3rd param the regex
# like the docs claim, we could combine this with the #123 search
{
cache: true
match: /(^|\s)\[\[([a-z0-9\-]+)$/i
search: (term, callback) ->
term = taiga.slugify(term)
$rs.search.do($scope.project.id, term).then (res) =>
if res.count < 1
callback([])
if res.count < 1 or not res.wikipages or res.wikipages.length <= 0
callback([])
else
callback res.wikipages.filter((page) =>
return taiga.slugify(page['slug']).indexOf(term) >= 0
), true
# must signal end of lists
callback([])
replace: (res) ->
return "$1[[#{res.slug}]]"
template: (res, term) ->
return res.slug
}
],
{
debounce: 200
}
)
renderMarkItUp()
unbind = $rootscope.$on "$translateChangeEnd", renderMarkItUp
element.on "keypress", (event) ->
$scope.$apply()
$scope.$on "$destroy", ->
$el.off()
unbind()
return {link:link, require:"ngModel"}
module.directive("tgMarkitup", ["$rootScope", "$tgResources", "$selectedText", "$tgTemplate", "$compile",
"$translate", "tgProjectService", MarkitupDirective])

View File

@ -53,11 +53,13 @@ class UserStoryDetailController extends mixOf(taiga.Controller, taiga.PageMixin)
"$tgQueueModelTransformation",
"tgErrorHandlingService",
"$tgConfig",
"tgProjectService"
"tgProjectService",
"tgWysiwygService"
]
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location,
@log, @appMetaService, @navUrls, @analytics, @translate, @modelTransform, @errorHandlingService, @configService, @projectService) ->
@log, @appMetaService, @navUrls, @analytics, @translate, @modelTransform,
@errorHandlingService, @configService, @projectService, @wysiwigService) ->
bindMethods(@)
@scope.usRef = @params.usref
@ -89,7 +91,7 @@ class UserStoryDetailController extends mixOf(taiga.Controller, taiga.PageMixin)
description = @translate.instant("US.PAGE_DESCRIPTION", {
userStoryStatus: @scope.statusById[@scope.us.status]?.name or "--"
userStoryPoints: @scope.us.total_points
userStoryDescription: angular.element(@scope.us.description_html or "").text()
userStoryDescription: angular.element(@wysiwigService.getHTML(@scope.us.description) or "").text()
userStoryClosedTasks: closedTasks
userStoryTotalTasks: totalTasks
userStoryProgressPercentage: progressPercentage

View File

@ -218,126 +218,82 @@ WikiSummaryDirective = ($log, $template, $compile, $translate, avatarService) ->
module.directive("tgWikiSummary", ["$log", "$tgTemplate", "$compile", "$translate", "tgAvatarService", WikiSummaryDirective])
WikiWysiwyg = ($modelTransform, $rootscope, $confirm, attachmentsFullService,
$qqueue, $repo, $analytics, wikiHistoryService) ->
link = ($scope, $el, $attrs) ->
$scope.editableDescription = false
#############################################################################
## Editable Wiki Content Directive
#############################################################################
EditableWikiContentDirective = ($window, $document, $repo, $confirm, $loading, $analytics, $qqueue, $translate,
$wikiHistoryService) ->
link = ($scope, $el, $attrs, $model) ->
isEditable = ->
return $scope.project.my_permissions.indexOf("modify_wiki_page") != -1
switchToEditMode = ->
$el.find('.edit-wiki-content').show()
$el.find('.view-wiki-content').hide()
$el.find('textarea').focus()
switchToReadMode = ->
$el.find('.edit-wiki-content').hide()
$el.find('.view-wiki-content').show()
disableEdition = ->
$el.find(".view-wiki-content .edit").remove()
$el.find(".edit-wiki-content").remove()
cancelEdition = ->
return if not $model.$modelValue.id
$model.$modelValue.revert()
switchToReadMode()
getSelectedText = ->
if $window.getSelection
return $window.getSelection().toString()
else if $document.selection
return $document.selection.createRange().text
return null
save = $qqueue.bindAdd (wiki) ->
$scope.saveDescription = $qqueue.bindAdd (description, cb) ->
onSuccess = (wikiPage) ->
if not wiki.id?
if not $scope.item.id?
$analytics.trackEvent("wikipage", "create", "create wiki page", 1)
$model.$setViewValue wikiPage.clone()
$wikiHistoryService.loadHistoryEntries()
wikiHistoryService.loadHistoryEntries()
$confirm.notify("success")
switchToReadMode()
onError = ->
$confirm.notify("error")
currentLoading = $loading()
.target($el.find('.save'))
.start()
$scope.item.content = description
if wiki.id?
promise = $repo.save(wiki).then(onSuccess, onError)
if $scope.item.id?
promise = $repo.save($scope.item).then(onSuccess, onError)
else
promise = $repo.create("wiki", wiki).then(onSuccess, onError)
promise = $repo.create("wiki", $scope.item).then(onSuccess, onError)
promise.finally ->
currentLoading.finish()
promise.finally(cb)
$el.on "click", "a", (event) ->
target = angular.element(event.currentTarget)
href = target.attr('href')
uploadFile = (file, cb) ->
return attachmentsFullService.addAttachment($scope.project.id, $scope.item.id, 'wiki_page', file).then (result) ->
cb(result.getIn(['file', 'name']), result.getIn(['file', 'url']))
if href.indexOf("#") == 0
event.preventDefault()
$('body').scrollTop($(href).offset().top)
$scope.uploadFiles = (files, cb) ->
for file in files
uploadFile(file, cb)
$el.on "mousedown", ".view-wiki-content", (event) ->
target = angular.element(event.target)
return if not isEditable()
return if event.button == 2
$scope.$watch $attrs.model, (value) ->
return if not value
$scope.item = value
$scope.version = value.version
$scope.storageKey = $scope.project.id + "-" + value.id + "-" + $attrs.type
$el.on "mouseup", ".view-wiki-content", (event) ->
target = angular.element(event.target)
return if getSelectedText()
return if not isEditable()
return if target.is('a')
return if target.is('pre')
$scope.$watch 'project', (project) ->
return if !project
switchToEditMode()
$el.on "click", ".save", debounce 2000, ->
save($scope.wiki)
$el.on "click", ".cancel", ->
$scope.$apply(cancelEdition)
$el.on "keydown", "textarea", (event) ->
return if event.keyCode != 27
$scope.$applyAsync () ->
title = $translate.instant("COMMON.CONFIRM_CLOSE_EDIT_MODE_TITLE")
message = $translate.instant("COMMON.CONFIRM_CLOSE_EDIT_MODE_MESSAGE")
$confirm.ask(title, null, message).then (askResponse) ->
cancelEdition()
askResponse.finish()
$scope.$watch $attrs.ngModel, (wikiPage) ->
return if not wikiPage
if isEditable()
$el.addClass('editable')
if not wikiPage.id? or $.trim(wikiPage.content).length == 0
switchToEditMode()
else
disableEdition()
$scope.$on "$destroy", ->
$el.off()
$scope.editableDescription = project.my_permissions.indexOf("modify_wiki_page") != -1
return {
link: link
restrict: "EA"
require: "ngModel"
templateUrl: "wiki/editable-wiki-content.html"
scope: true,
link: link,
template: """
<div>
<tg-wysiwyg
ng-if="editableDescription"
version='version'
storage-key='storageKey'
content='item.content'
on-save='saveDescription(text, cb)'
on-upload-file='uploadFiles(files, cb)'>
</tg-wysiwyg>
<div
class="wysiwyg"
ng-if="!editableDescription && item.content.length"
ng-bind-html="item.content | markdownToHTML"></div>
<div
class="wysiwyg"
ng-if="!editableDescription && !item.content.length">
{{'COMMON.DESCRIPTION.NO_DESCRIPTION' | translate}}
</div>
</div>
"""
}
module.directive("tgEditableWikiContent", ["$window", "$document", "$tgRepo", "$tgConfirm", "$tgLoading",
"$tgAnalytics", "$tgQqueue", "$translate", "tgWikiHistoryService",
EditableWikiContentDirective])
module.directive("tgWikiWysiwyg", [
"$tgQueueModelTransformation",
"$rootScope",
"$tgConfirm",
"tgAttachmentsFullService",
"$tgQqueue", "$tgRepo", "$tgAnalytics", "tgWikiHistoryService"
WikiWysiwyg])

View File

@ -251,6 +251,16 @@ getRandomDefaultColor = () ->
getDefaulColorList = () ->
return _.clone(DEFAULT_COLOR_LIST)
getMatches = (string, regex, index) ->
index || (index = 1)
matches = []
match = null
while match = regex.exec(string)
matches.push(match[index])
return matches
taiga = @.taiga
taiga.addClass = addClass
taiga.nl2br = nl2br
@ -280,3 +290,4 @@ taiga.isPdf = isPdf
taiga.patch = patch
taiga.getRandomDefaultColor = getRandomDefaultColor
taiga.getDefaulColorList = getDefaulColorList
taiga.getMatches = getMatches

270
app/js/medium-mention.js Normal file
View File

@ -0,0 +1,270 @@
var MentionExtension = MediumEditor.Extension.extend({
name: 'mediumMention',
init: function() {
this.subscribe('editableKeyup', this.handleKeyup.bind(this));
this.subscribe('editableKeydown', this.handleKeydown.bind(this));
this.subscribe('blur', this.cancel.bind(this));
},
isEditMode: function() {
return !this.base.origElements.parentNode.classList.contains('read-mode')
},
cancel: function() {
if (this.isEditMode()) {
this.hidePanel();
this.reset();
}
},
handleKeydown: function(e) {
var code = e.keyCode ? e.keyCode : e.which;
if (this.mentionPanel && code === MediumEditor.util.keyCode.ENTER) {
e.preventDefault();
}
},
handleKeyup: function(e) {
var code = e.keyCode ? e.keyCode : e.which;
var isSpace = code === MediumEditor.util.keyCode.SPACE;
var isBackspace = code === MediumEditor.util.keyCode.BACKSPACE;
if (this.mentionPanel) {
this.keyDownMentionPanel(e);
}
var moveKeys = [37, 38, 39, 40];
if (moveKeys.indexOf(code) !== -1) {
return;
}
this.selection = this.document.getSelection();
if (isBackspace && this.selection.focusNode.nodeName.toLowerCase() === 'p') {
return;
}
if (!isSpace && this.selection.rangeCount === 1) {
var endChar = this.selection.getRangeAt(0).startOffset;
var textContent = this.selection.focusNode.textContent;
this.word = this.getLastWord(textContent);
textContent = textContent.substring(0, endChar);
if (this.word.length > 1 && ['@', '#', ':'].indexOf(this.word[0]) != -1) {
this.wrap();
this.showPanel();
MediumEditor.selection.select(
this.document,
this.wordNode.firstChild,
this.word.length
);
return;
}
} else if (isSpace) {
this.cancelMentionSpace();
}
this.hidePanel();
},
reset: function() {
this.wordNode = null;
this.word = null;
this.selection = null;
},
cancelMentionSpace: function() {
if (this.wordNode && this.wordNode.nextSibling) {
var textNode = this.document.createTextNode('');
textNode.textContent = this.word + '\u00A0';
this.wordNode.parentNode.replaceChild(textNode, this.wordNode);
MediumEditor.selection.select(this.document, textNode, this.word.length + 1);
}
this.reset();
},
wrap: function() {
var range = this.selection.getRangeAt(0).cloneRange();
if (range.startContainer.parentNode.nodeName.toLowerCase() === 'a') {
var parentLink = range.startContainer.parentNode.parentNode;
var textNode = this.document.createTextNode(range.startContainer.parentNode.innerText);
parentLink.replaceChild(textNode, range.startContainer.parentNode);
this.selection.removeAllRanges();
range = document.createRange();
range.setStart(textNode, textNode.length);
range.setEnd(textNode, textNode.length);
this.selection.addRange(range);
}
if (!range.startContainer.parentNode.classList.contains('mention')) {
this.wordNode = this.document.createElement('span');
this.wordNode.classList.add('mention');
range.setStart(range.startContainer, this.selection.getRangeAt(0).startOffset - this.word.length);
range.surroundContents(this.wordNode);
this.selection.removeAllRanges();
this.selection.addRange(range);
//move cursor to old position
range.setStart(range.startContainer, range.endOffset);
range.setStart(range.endContainer, range.endOffset);
this.selection.removeAllRanges();
this.selection.addRange(range);
} else {
this.wordNode = range.startContainer.parentNode;
}
},
refreshPositionPanel: function() {
var bound = this.wordNode.getBoundingClientRect();
this.mentionPanel.style.top = this.window.pageYOffset + bound.bottom + 'px';
this.mentionPanel.style.left = this.window.pageXOffset + bound.left + 'px';
},
selectMention: function(item) {
if (item.image) {
var img = document.createElement('img');
img.src = item.image;
this.wordNode.parentNode.replaceChild(img, this.wordNode);
this.wordNode = img;
} else {
var link = document.createElement('a');
link.setAttribute('href', item.url);
if (item.ref) {
link.innerText = '#' + item.ref + '-' + item.subject;
} else {
link.innerText = '@' + item.username;
}
this.wordNode.parentNode.replaceChild(link, this.wordNode);
this.wordNode = link;
}
var textNode = this.document.createTextNode('');
textNode.textContent = '\u00A0';
this.wordNode.parentNode.insertBefore(textNode, this.wordNode.nextSibling);
MediumEditor.selection.select(this.document, textNode, 1);
var target = this.base.getFocusedElement();
this.base.events.updateInput(target, {
target: target,
currentTarget: target
});
this.hidePanel();
this.reset();
},
showPanel: function() {
if(document.querySelectorAll('.medium-editor-mention-panel').length) {
this.refreshPositionPanel();
this.getItems(this.word, this.renderPanel.bind(this));
return;
}
var el = this.document.createElement('div');
el.classList.add('medium-editor-mention-panel');
this.mentionPanel = el;
this.getEditorOption('elementsContainer').appendChild(el);
this.refreshPositionPanel();
this.getItems(this.word, this.renderPanel.bind(this));
},
keyDownMentionPanel: function(e) {
var code = e.keyCode ? e.keyCode : e.which;
var active = this.mentionPanel.querySelector('.active');
this.wordNode = document.querySelector('span.mention');
if(!active) {
return;
}
if (code === MediumEditor.util.keyCode.ENTER) {
e.preventDefault();
e.stopPropagation();
var event = document.createEvent('HTMLEvents');
event.initEvent('click', true, false);
active.dispatchEvent(event);
return;
}
active.classList.remove('active');
if (code === 38) {
if(active.previousSibling) {
active.previousSibling.classList.add('active');
} else {
active.parentNode.lastChild.classList.add('active');
}
} else if (code === 40) {
if(active.nextSibling) {
active.nextSibling.classList.add('active');
} else {
active.parentNode.firstChild.classList.add('active');
}
}
},
renderPanel: function(items) {
this.mentionPanel.innerHTML = '';
if (!items.length) return;
var ul = this.document.createElement('ul');
ul.classList.add('medium-mention');
items.forEach(function(it) {
var li = this.document.createElement('li');
if (it.image) {
var img = this.document.createElement('img');
img.src = it.image;
li.appendChild(img);
var textNode = document.createTextNode('');
textNode.textContent = ' ' + it.name;
li.appendChild(textNode);
} else if (it.ref) {
li.innerText = '#' + it.ref + ' - ' + it.subject;
} else {
li.innerText = '@' + it.username;
}
li.addEventListener('click', this.selectMention.bind(this, it));
ul.appendChild(li);
}.bind(this));
ul.firstChild.classList.add('active');
this.mentionPanel.appendChild(ul);
},
hidePanel: function() {
if (this.mentionPanel) {
this.mentionPanel.parentNode.removeChild(this.mentionPanel);
this.mentionPanel = null;
}
},
getLastWord: function(text) {
var n = text.split(' ');
return n[n.length - 1].trim();
}
});

View File

@ -228,32 +228,7 @@
}
},
"WYSIWYG": {
"H1_BUTTON": "First Level Heading",
"H1_SAMPLE_TEXT": "Your title here...",
"H2_BUTTON": "Second Level Heading",
"H2_SAMPLE_TEXT": "Your title here...",
"H3_BUTTON": "Third Level Heading",
"H3_SAMPLE_TEXT": "Your title here...",
"BOLD_BUTTON": "Bold",
"BOLD_BUTTON_SAMPLE_TEXT": "Your text here...",
"ITALIC_BUTTON": "Italic",
"ITALIC_SAMPLE_TEXT": "Your text here...",
"STRIKE_BUTTON": "Strike",
"STRIKE_SAMPLE_TEXT": "Your text here...",
"BULLETED_LIST_BUTTON": "Bulleted List",
"BULLETED_LIST_SAMPLE_TEXT": "Your text here...",
"NUMERIC_LIST_BUTTON": "Numeric List",
"NUMERIC_LIST_SAMPLE_TEXT": "Your text here...",
"PICTURE_BUTTON": "Picture",
"PICTURE_SAMPLE_TEXT": "Your alternative text to picture here...",
"LINK_BUTTON": "Link",
"LINK_SAMPLE_TEXT": "Your text to link here....",
"QUOTE_BLOCK_BUTTON": "Quote Block",
"QUOTE_BLOCK_SAMPLE_TEXT": "Your text here...",
"CODE_BLOCK_BUTTON": "Code Block",
"CODE_BLOCK_SAMPLE_TEXT": "Your text here...",
"PREVIEW_BUTTON": "Preview",
"EDIT_BUTTON": "Edit",
"OUTDATED": "Another person has made changes while you were editing. Check the new version on the activiy tab before you save your changes.",
"ATTACH_FILE_HELP": "Attach files by dragging & dropping on the textarea above.",
"ATTACH_FILE_HELP_SAVE_FIRST": "Save first before if you want to attach files by dragging & dropping on the textarea above.",
"MARKDOWN_HELP": "Markdown syntax help"

View File

@ -0,0 +1,54 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/components/bind-code.directive.coffee
###
BindCode = ($sce, $parse, $compile, wysiwygService, wysiwygCodeHightlighterService) ->
return {
restrict: 'A',
compile: (tElement, tAttrs) ->
tgBindCodeGetter = $parse(tAttrs.tgBindCode)
tgBindCodeWatch = $parse tAttrs.tgBindCode, (value) ->
return (value || '').toString()
$compile.$$addBindingClass(tElement)
return (scope, element, attr) ->
$compile.$$addBindingInfo(element, attr.tgBindCode);
scope.$watch tgBindCodeWatch, () ->
html = wysiwygService.getHTML(tgBindCodeGetter(scope))
element.html($sce.getTrustedHtml(html) || '')
wysiwygCodeHightlighterService.addHightlighter(element)
}
angular.module("taigaComponents")
.directive("tgBindCode", [
"$sce",
"$parse",
"$compile",
"tgWysiwygService",
"tgWysiwygCodeHightlighterService",
BindCode])

View File

@ -0,0 +1,59 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/components/wysiwyg/comment-edit-wysiwyg.directive.coffee
###
CommentEditWysiwyg = (attachmentsFullService) ->
link = ($scope, $el, $attrs) ->
types = {
userstories: "us",
issues: "issue",
tasks: "task"
}
uploadFile = (file, cb) ->
return attachmentsFullService.addAttachment($scope.vm.projectId, $scope.vm.comment.comment.id, types[$scope.vm.comment.comment._name], file).then (result) ->
cb(result.getIn(['file', 'name']), result.getIn(['file', 'url']))
$scope.uploadFiles = (files, cb) ->
for file in files
uploadFile(file, cb)
return {
scope: true,
link: link,
template: """
<div>
<tg-wysiwyg
editonly
required
content='vm.comment.comment'
on-save="vm.saveComment(text, cb)"
on-cancel="vm.onEditMode({commentId: vm.comment.id})"
on-upload-file='uploadFiles(files, cb)'>
</tg-wysiwyg>
</div>
"""
}
angular.module("taigaComponents")
.directive("tgCommentEditWysiwyg", ["tgAttachmentsFullService", CommentEditWysiwyg])

View File

@ -0,0 +1,77 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/components/wysiwyg/comment-wysiwyg.directive.coffee
###
CommentWysiwyg = (attachmentsFullService) ->
link = ($scope, $el, $attrs) ->
$scope.editableDescription = false
$scope.saveComment = (description, cb) ->
$scope.content = ''
$scope.vm.type.comment = description
$scope.vm.onAddComment({callback: cb})
types = {
userstories: "us",
issues: "issue",
tasks: "task"
}
uploadFile = (file, cb) ->
return attachmentsFullService.addAttachment($scope.vm.projectId, $scope.vm.type.id, types[$scope.vm.type._name], file).then (result) ->
cb(result.getIn(['file', 'name']), result.getIn(['file', 'url']))
$scope.onChange = (markdown) ->
$scope.vm.type.comment = markdown
$scope.uploadFiles = (files, cb) ->
for file in files
uploadFile(file, cb)
$scope.content = ''
$scope.$watch "vm.type", (value) ->
return if not value
$scope.storageKey = "comment-" + value.project + "-" + value.id + "-" + value._name
return {
scope: true,
link: link,
template: """
<div>
<tg-wysiwyg
required
not-persist
placeholder='{{"COMMENTS.TYPE_NEW_COMMENT" | translate}}'
storage-key='storageKey'
content='content'
on-save='saveComment(text, cb)'
on-upload-file='uploadFiles(files, cb)'>
</tg-wysiwyg>
</div>
"""
}
angular.module("taigaComponents")
.directive("tgCommentWysiwyg", ["tgAttachmentsFullService", CommentWysiwyg])

View File

@ -0,0 +1,100 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/components/wysiwyg/item-wysiwyg.directive.coffee
###
# Used in details descriptions
ItemWysiwyg = ($modelTransform, $rootscope, $confirm, attachmentsFullService, $translate) ->
link = ($scope, $el, $attrs) ->
$scope.editableDescription = false
$scope.saveDescription = (description, cb) ->
transform = $modelTransform.save (item) ->
item.description = description
return item
transform.then ->
$confirm.notify("success")
$rootscope.$broadcast("object:updated")
transform.then null, ->
$confirm.notify("error")
transform.finally(cb)
uploadFile = (file, cb) ->
return attachmentsFullService.addAttachment($scope.project.id, $scope.item.id, $attrs.type, file).then (result) ->
cb(result.getIn(['file', 'name']), result.getIn(['file', 'url']))
$scope.uploadFiles = (files, cb) ->
for file in files
uploadFile(file, cb)
$scope.$watch $attrs.model, (value) ->
return if not value
$scope.item = value
$scope.version = value.version
$scope.storageKey = $scope.project.id + "-" + value.id + "-" + $attrs.type
$scope.$watch 'project', (project) ->
return if !project
$scope.editableDescription = project.my_permissions.indexOf($attrs.requiredPerm) != -1
return {
scope: true,
link: link,
template: """
<div>
<tg-wysiwyg
ng-if="editableDescription"
placeholder='{{"COMMON.DESCRIPTION.EMPTY" | translate}}'
version='version'
storage-key='storageKey'
content='item.description'
on-save='saveDescription(text, cb)'
on-upload-file='uploadFiles(files, cb)'>
</tg-wysiwyg>
<div
class="wysiwyg"
ng-if="!editableDescription && item.description.length"
ng-bind-html="item.description | markdownToHTML"></div>
<div
class="wysiwyg"
ng-if="!editableDescription && !item.description.length">
{{'COMMON.DESCRIPTION.NO_DESCRIPTION' | translate}}
</div>
</div>
"""
}
angular.module("taigaComponents")
.directive("tgItemWysiwyg", [
"$tgQueueModelTransformation",
"$rootScope",
"$tgConfirm",
"tgAttachmentsFullService",
"$translate",
ItemWysiwyg])

View File

@ -0,0 +1,180 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/components/wysiwyg/wysiwyg-code-hightlighter.service.coffee
###
class WysiwygCodeHightlighterService
constructor: () ->
if !@.languages
@.loadLanguages()
loadLanguages: () ->
$.getJSON("/#{window._version}/prism/prism-languages.json").then (_languages_) =>
@.languages = _.map _languages_, (it) ->
it.url = "/#{window._version}/prism/" + it.file
return it
getLanguageInClassList: (classes) ->
lan = _.find @.languages, (it) ->
return !!_.find classes, (className) ->
return 'language-' + it.name == className
return if lan then lan.name else null
addCodeLanguageSelectors: (mediumInstance) ->
$(mediumInstance.elements[0]).find('code').each (index, code) =>
if !code.classList.contains('has-code-lan-selector')
code.classList.add('has-code-lan-selector') # prevent multi instanciate
currentLan = @.getLanguageInClassList(code.classList)
id = new Date().getTime()
text = document.createTextNode(currentLan || 'text')
tab = document.createElement('div')
tab.appendChild(text)
tab.addEventListener 'click', () =>
@.searchLanguage tab, (lan) =>
if lan
tab.innerText = lan
@.updatePositionCodeTab(code.parentElement, tab)
code.classList.add('language-' + lan)
code.classList.add(lan)
document.body.appendChild(tab)
code.classList.add(id)
code.dataset.tab = tab
tab.classList.add('code-language-selector') # styles
tab.classList.add('medium-' + mediumInstance.id) # used to delete
tab.dataset.tabId = id
@.updatePositionCodeTab(code.parentElement, tab)
removeCodeLanguageSelectors: (mediumInstance) ->
return if !mediumInstance || !mediumInstance.elements
$(mediumInstance.elements[0]).find('code').each (index, code) ->
$(code).removeClass('has-code-lan-selector')
$('.medium-' + mediumInstance.id).remove()
updatePositionCodeTab: (node, tab) ->
preRects = node.getBoundingClientRect()
tab.style.top = (preRects.top + $(window).scrollTop()) + 'px'
tab.style.left = (preRects.left + preRects.width - tab.offsetWidth) + 'px'
getCodeLanHTML: (filter = '') ->
template = _.template("""
<% _.forEach(lans, function(lan) { %>
<li><%- lan %></li><% });
%>
""");
filteresLans = _.map @.languages, (it) -> it.name
if filter.length
filteresLans = _.filter filteresLans, (it) ->
return it.indexOf(filter) != -1
return template({ 'lans': filteresLans });
searchLanguage: (tab, cb) ->
search = document.createElement('div')
search.className = 'code-language-search'
preRects = tab.getBoundingClientRect()
search.style.top = (preRects.top + $(window).scrollTop() + preRects.height) + 'px'
search.style.left = preRects.left + 'px'
input = document.createElement('input')
input.setAttribute('type', 'text')
ul = document.createElement('ul')
ul.innerHTML = @.getCodeLanHTML()
search.appendChild(input)
search.appendChild(ul)
document.body.appendChild(search)
input.focus()
close = () ->
search.remove()
$(document.body).off('.leave-search-codelan')
clickedInSearchBox = (target) ->
return $(search).is(target) || !!$(search).has(target).length
$(document.body).on 'mouseup.leave-search-codelan', (e) ->
if !clickedInSearchBox(e.target)
cb(null)
close()
$(input).on 'keyup', (e) =>
filter = e.currentTarget.value
ul.innerHTML = @.getCodeLanHTML(filter)
$(ul).on 'click', 'li', (e) ->
cb(e.currentTarget.innerText)
close()
loadLanguage: (lan) ->
return new Promise (resolve) ->
if !Prism.languages[lan]
ljs.load("/#{window._version}/prism/prism-#{lan}.min.js", resolve)
else
resolve()
removeHightlighter: (element) ->
codes = $(element).find('code')
codes.each (index, code) ->
code.innerHTML = code.innerText
addHightlighter: (element) ->
codes = $(element).find('code')
codes.each (index, code) =>
lan = @.getLanguageInClassList(code.classList)
if lan
@.loadLanguage(lan).then () -> Prism.highlightElement(code)
updateCodeLanguageSelector: (mediumInstance) ->
$('.medium-' + mediumInstance.id).each (index, tab) =>
node = $('.' + tab.dataset.tabId)
if !node.length
tab.remove()
else
@.updatePositionCodeTab(node.parent()[0], tab)
angular.module("taigaComponents")
.service("tgWysiwygCodeHightlighterService", WysiwygCodeHightlighterService)

View File

@ -0,0 +1,117 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/components/wysiwyg/wysiwyg-mention.service.coffee
###
class WysiwygMentionService
@.$inject = [
"tgProjectService",
"tgWysiwygService",
"$tgNavUrls",
"$tgResources"
]
constructor: (@projectService, @wysiwygService, @navurls, @rs) ->
@.cancelablePromise = null
searchEmoji: (name, cb) ->
filteredEmojis = @wysiwygService.searchEmojiByName(name)
filteredEmojis = filteredEmojis.slice(0, 10)
cb(filteredEmojis)
searchUser: (term, cb) ->
searchProps = ['username', 'full_name', 'full_name_display']
users = @projectService.project.toJS().members.filter (user) =>
for prop in searchProps
if taiga.slugify(user[prop]).indexOf(term) >= 0
return true
return false
users = users.slice(0, 10).map (it) =>
it.url = @navurls.resolve('user-profile', {
project: @projectService.project.get('slug'),
username: it.username
})
return it
cb(users)
searchItem: (term) ->
return new Promise (resolve, reject) =>
term = taiga.slugify(term)
searchTypes = ['issues', 'tasks', 'userstories']
urls = {
issues: "project-issues-detail",
tasks: "project-tasks-detail",
userstories: "project-userstories-detail"
}
searchProps = ['ref', 'subject']
filter = (item) =>
for prop in searchProps
if taiga.slugify(item[prop]).indexOf(term) >= 0
return true
return false
@.cancelablePromise.abort() if @.cancelablePromise
@.cancelablePromise = @rs.search.do(@projectService.project.get('id'), term)
@.cancelablePromise.then (res) =>
# ignore wikipages if they're the only results. can't exclude them in search
if res.count < 1 or res.count == res.wikipages.length
resolve([])
else
result = []
for type in searchTypes
if res[type] and res[type].length > 0
items = res[type].filter(filter)
items = items.map (it) =>
it.url = @navurls.resolve(urls[type], {
project: @projectService.project.get('slug'),
ref: it.ref
})
return it
result = result.concat(items)
resolve(result.slice(0, 10))
search: (mention) ->
return new Promise (resolve) =>
if '#'.indexOf(mention[0]) != -1
@.searchItem(mention.replace('#', '')).then(resolve)
else if '@'.indexOf(mention[0]) != -1
@.searchUser(mention.replace('@', ''), resolve)
else if ':'.indexOf(mention[0]) != -1
@.searchEmoji(mention.replace(':', ''), resolve)
angular.module("taigaComponents").service("tgWysiwygMentionService", WysiwygMentionService)

View File

@ -0,0 +1,401 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/components/wysiwyg/wysiwyg.directive.coffee
###
taiga = @.taiga
bindOnce = @.taiga.bindOnce
Medium = ($translate, $confirm, $storage, wysiwygService, animationFrame, tgLoader, wysiwygCodeHightlighterService, wysiwygMentionService, analytics) ->
isCodeBlockSelected = (range, elm) ->
return !!$(range.endContainer).parentsUntil('.editor', 'code').length
removeCodeBlockAndHightlight = (range, elm) ->
code = $(range.endContainer).closest('code')[0]
pre = code.parentNode
p = document.createElement('p')
p.innerText = code.innerText
pre.parentNode.replaceChild(p, pre)
wysiwygCodeHightlighterService.removeCodeLanguageSelectors(elm)
addCodeBlockAndHightlight = (range, elm) ->
pre = document.createElement('pre')
code = document.createElement('code')
pre.appendChild(code)
code.appendChild(range.extractContents())
range.insertNode(pre)
elm.checkContentChanged()
wysiwygCodeHightlighterService.addCodeLanguageSelectors(elm)
# MediumEditor extension to add <code>
CodeButton = MediumEditor.extensions.button.extend({
name: 'code',
init: () ->
this.button = this.document.createElement('button')
this.button.classList.add('medium-editor-action')
this.button.innerHTML = '<b>Code</b>'
this.button.title = 'Code'
this.on(this.button, 'click', this.handleClick.bind(this))
getButton: () ->
return this.button
tagNames: ['code']
handleClick: (event) ->
range = MediumEditor.selection.getSelectionRange(self.document)
if isCodeBlockSelected(range, this.base)
removeCodeBlockAndHightlight(range, this.base)
else
addCodeBlockAndHightlight(range, this.base)
})
# bug
# <pre><code></code></pre> the enter key press doesn't work
oldIsBlockContainer = MediumEditor.util.isBlockContainer
MediumEditor.util.isBlockContainer = (element) ->
if !element
return oldIsBlockContainer(element)
if element.tagName
tagName = element.tagName
else
tagName = element.parentNode.tagName
if tagName.toLowerCase() == 'code'
return true
return oldIsBlockContainer(element)
link = ($scope, $el, $attrs) ->
mediumInstance = null
editorMedium = $el.find('.medium')
editorMarkdown = $el.find('.markdown')
isEditOnly = !!$attrs.$attr.editonly
notPersist = !!$attrs.$attr.notPersist
$scope.required = !!$attrs.$attr.required
$scope.editMode = isEditOnly || false
$scope.mode = $storage.get('editor-mode', 'html')
wysiwygService.loadEmojis()
setHtmlMedium = (markdown) ->
html = wysiwygService.getHTML(markdown)
editorMedium.html(html)
$scope.setMode = (mode) ->
$storage.set('editor-mode', mode)
if mode == 'markdown'
updateMarkdownWithCurrentHtml()
else
setHtmlMedium($scope.markdown)
$scope.mode = mode
mediumInstance.trigger('editableBlur', {}, editorMedium[0])
$scope.save = () ->
if $scope.mode == 'html'
updateMarkdownWithCurrentHtml()
return if $scope.required && !$scope.markdown.length
$scope.saving = true
$scope.outdated = false
$scope.onSave({text: $scope.markdown, cb: saveEnd})
return
$scope.cancel = () ->
if !isEditOnly
$scope.editMode = false
if notPersist
clean()
else if $scope.mode == 'html'
setHtmlMedium($scope.content)
$scope.markdown = $scope.content
discardLocalStorage()
mediumInstance.trigger('blur', {}, editorMedium[0])
$scope.outdated = false
$scope.onCancel()
return
clean = () ->
$scope.markdown = ''
editorMedium.html('')
refreshExtras = () ->
animationFrame.add () ->
if $scope.mode == 'html'
if $scope.editMode
wysiwygCodeHightlighterService.addCodeLanguageSelectors(mediumInstance)
wysiwygCodeHightlighterService.removeHightlighter(mediumInstance.elements[0])
else
wysiwygCodeHightlighterService.addHightlighter(mediumInstance.elements[0])
wysiwygCodeHightlighterService.removeCodeLanguageSelectors(mediumInstance)
else
wysiwygCodeHightlighterService.removeHightlighter(mediumInstance.elements[0])
wysiwygCodeHightlighterService.removeCodeLanguageSelectors(mediumInstance)
saveEnd = () ->
$scope.saving = false
if !isEditOnly
$scope.editMode = false
if notPersist
clean()
discardLocalStorage()
mediumInstance.trigger('blur', {}, editorMedium[0])
analytics.trackEvent('develop', 'save wysiwyg', $scope.mode, 1)
uploadEnd = (name, url) ->
if taiga.isImage(name)
mediumInstance.pasteHTML("<img src='" + url + "' /><br/>")
else
name = $('<div/>').text(name).html()
mediumInstance.pasteHTML("<a target='_blank' href='" + url + "'>" + name + "</a><br/>")
isOutdated = () ->
store = $storage.get($scope.storageKey)
if store && store.version && store.version != $scope.version
return true
return false
isDraft = () ->
store = $storage.get($scope.storageKey)
if store
return true
return false
getCurrentContent = () ->
store = $storage.get($scope.storageKey)
if store
return store.text
return $scope.content
discardLocalStorage = () ->
$storage.remove($scope.storageKey)
cancelWithConfirmation = () ->
if $scope.content == $scope.markdown
$scope.cancel()
document.activeElement.blur()
document.body.click()
return null
title = $translate.instant("COMMON.CONFIRM_CLOSE_EDIT_MODE_TITLE")
message = $translate.instant("COMMON.CONFIRM_CLOSE_EDIT_MODE_MESSAGE")
$confirm.ask(title, null, message).then (askResponse) ->
$scope.cancel()
askResponse.finish()
updateMarkdownWithCurrentHtml = () ->
$scope.markdown = wysiwygService.getMarkdown(editorMedium.html())
localSave = (markdown) ->
if $scope.storageKey
store = {}
store.version = $scope.version || 0
store.text = markdown
$storage.set($scope.storageKey, store)
change = () ->
if $scope.mode == 'html'
updateMarkdownWithCurrentHtml()
wysiwygCodeHightlighterService.updateCodeLanguageSelector(mediumInstance)
localSave($scope.markdown)
$scope.onChange({markdown: $scope.markdown})
throttleChange = _.throttle(change, 200)
create = (text, editMode=false) ->
if text.length
html = wysiwygService.getHTML(text)
editorMedium.html(html)
mediumInstance = new MediumEditor(editorMedium[0], {
targetBlank: true,
imageDragging: false,
placeholder: {
text: $scope.placeholder
},
toolbar: {
buttons: [
'bold',
'italic',
'strikethrough',
'anchor',
'image',
'orderedlist',
'unorderedlist',
'h1',
'h2',
'h3',
'quote',
'removeFormat',
'code'
]
},
extensions: {
code: new CodeButton(),
autolist: new AutoList(),
mediumMention: new MentionExtension({
getItems: (mention, mentionCb) ->
wysiwygMentionService.search(mention).then(mentionCb)
})
}
})
$scope.changeMarkdown = throttleChange
mediumInstance.subscribe 'editableInput', (e) ->
$scope.$applyAsync(throttleChange)
mediumInstance.subscribe "editableClick", (e) ->
e.stopPropagation()
if e.target.href
window.open(e.target.href)
mediumInstance.subscribe 'focus', (event) ->
$scope.$applyAsync () ->
if !$scope.editMode
$scope.editMode = true
mediumInstance.subscribe 'editableDrop', (event) ->
$scope.onUploadFile({files: event.dataTransfer.files, cb: uploadEnd})
mediumInstance.subscribe 'editableKeydown', (e) ->
code = if e.keyCode then e.keyCode else e.which
mention = $('.medium-mention')
if (code == 40 || code == 38) && mention.length
e.stopPropagation()
e.preventDefault()
return
if $scope.editMode && code == 27
e.stopPropagation()
$scope.$applyAsync(cancelWithConfirmation)
else if code == 27
editorMedium.blur()
$scope.editMode = editMode
$scope.$applyAsync(refreshExtras)
$scope.$watch () ->
return $scope.mode + ":" + $scope.editMode
, () ->
$scope.$applyAsync(refreshExtras)
unwatch = $scope.$watch 'content', (content) ->
if !_.isUndefined(content)
$scope.outdated = isOutdated()
if !mediumInstance && isDraft()
$scope.editMode = true
if $scope.markdown == content
return
content = getCurrentContent()
$scope.markdown = content
if mediumInstance
mediumInstance.destroy()
if tgLoader.open()
unwatchLoader = tgLoader.onEnd () ->
create(content, $scope.editMode)
unwatchLoader()
else
create(content, $scope.editMode)
unwatch()
$scope.$on "$destroy", () ->
if mediumInstance
wysiwygCodeHightlighterService.removeCodeLanguageSelectors(mediumInstance)
mediumInstance.destroy()
return {
templateUrl: "common/components/wysiwyg-toolbar.html",
scope: {
placeholder: '@',
version: '<',
storageKey: '<',
content: '<',
onCancel: '&',
onSave: '&',
onUploadFile: '&',
onChange: '&'
},
link: link
}
angular.module("taigaComponents").directive("tgWysiwyg", [
"$translate",
"$tgConfirm",
"$tgStorage",
"tgWysiwygService",
"animationFrame",
"tgLoader",
"tgWysiwygCodeHightlighterService",
"tgWysiwygMentionService",
"$tgAnalytics",
Medium
])

View File

@ -1,6 +1,5 @@
.wysiwyg {
line-height: 1.4rem;
margin-bottom: 2rem;
overflow: auto;
padding: 1rem;
h1 {
@ -37,7 +36,7 @@
ol {
line-height: 1.5;
list-style-position: outside;
margin-bottom: 0;
margin-bottom: 1rem;
margin-top: 0;
padding-left: 2em;
ul,
@ -48,6 +47,15 @@
ul {
list-style-type: disc;
}
.list-stye-none {
list-style: none;
}
b {
font-weight: bold;
}
i {
font-style: italic;
}
dl {
dt {
font-size: 1em;
@ -63,6 +71,7 @@
}
a {
color: $primary;
cursor: pointer;
&:hover {
color: $primary-light;
}
@ -134,3 +143,113 @@
border: 1px solid $whitish;
}
}
.medium-editor-mention-panel {
background-color: $white;
border: 1px solid $gray-light;
position: absolute;
ul {
margin-bottom: 0;
}
li {
border-top: 1px solid $gray-light;
cursor: pointer;
padding: 2px 5px;
&:first-child {
border-top: 0;
}
&:hover,
&.active {
background-color: $primary-dark;
color: $white;
}
}
}
tg-wysiwyg {
display: flex;
margin-bottom: 2rem;
.outdated {
color: $red;
}
.tools {
padding-left: 1rem;
a {
display: block;
margin-bottom: .5rem;
}
svg {
fill: $gray-light;
}
}
.editor {
width: 100%;
}
.mode-editor {
span {
color: $gray-light;
cursor: pointer;
margin-right: .5rem;
}
}
.medium-editor-placeholder,
.markdown-editor-placeholder {
color: $gray-light;
padding-left: 1rem;
&::after { // overwrite medium css
color: $gray-light;
font-style: normal;
}
}
.markdown:not(.empty) {
p {
margin-bottom: 0;
white-space: pre-wrap;
}
}
.read-mode {
cursor: pointer;
}
.edit-mode {
.markdown,
.medium {
border: 1px solid $gray-light;
}
.medium-editor-element {
min-height: 10rem;
}
}
.mention {
font-weight: bold;
}
}
.code-language-selector {
@include font-size(xsmall);
background-color: $white;
border: 1px solid $gray-light;
cursor: pointer;
padding: .2rem .5rem 0;
position: absolute;
}
.code-language-search {
@include font-size(xsmall);
background-color: $white;
border: 1px solid $gray-light;
position: absolute;
ul {
cursor: pointer;
margin-bottom: 0;
max-height: 20vh;
overflow-y: scroll;
}
li {
padding: .2rem .5rem;
}
}
// Override medium styles
.medium-editor-toolbar li .medium-editor-button-active {
color: $primary-light;
}

View File

@ -0,0 +1,121 @@
###
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino Garcia <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán Merino <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Juan Francisco Alcántara <juanfran.alcantara@kaleidos.net>
# Copyright (C) 2014-2016 Xavi Julian <xavier.julian@kaleidos.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# File: modules/components/wysiwyg/wysiwyg.service.coffee
###
class WysiwygService
constructor: (@wysiwygCodeHightlighterService) ->
searchEmojiByName: (name) ->
return _.filter @.emojis, (it) -> it.name.indexOf(name) != -1
setEmojiImagePath: (emojis) ->
@.emojis = _.map emojis, (it) ->
it.image = "/#{window._version}/emojis/" + it.image
return it
loadEmojis: () ->
$.getJSON("/#{window._version}/emojis/emojis-data.json").then(@.setEmojiImagePath.bind(this))
getEmojiById: (id) ->
return _.find @.emojis, (it) -> it.id == id
getEmojiByName: (name) ->
return _.find @.emojis, (it) -> it.name == name
replaceImgsByEmojiName: (html) ->
emojiIds = taiga.getMatches(html, /emojis\/([^"]+).png"/gi)
for emojiId in emojiIds
regexImgs = new RegExp('<img(.*)' + emojiId + '[^>]+\>', 'g')
emoji = @.getEmojiById(emojiId)
html = html.replace(regexImgs, ':' + emoji.name + ':')
return html
replaceEmojiNameByImgs: (text) ->
emojiIds = taiga.getMatches(text, /:([^: ]*):/g)
for emojiId in emojiIds
regexImgs = new RegExp(':' + emojiId + ':', 'g')
emoji = @.getEmojiByName(emojiId)
if emoji
text = text.replace(regexImgs, '![alt](' + emoji.image + ')')
return text
removeTrailingListBr: (text) ->
regex = new RegExp(/<li>(.*?)<br><\/li>/, 'g')
return text.replace(regex, '<li>$1</li>')
getMarkdown: (html) ->
# https://github.com/yabwe/medium-editor/issues/543
cleanIssueConverter = {
filter: ['html', 'body', 'span', 'div'],
replacement: (innerHTML) ->
return innerHTML
}
codeLanguageConverter = {
filter: (node) =>
return node.nodeName == 'PRE' &&
node.firstChild &&
node.firstChild.nodeName == 'CODE'
replacement: (content, node) =>
lan = @wysiwygCodeHightlighterService.getLanguageInClassList(node.firstChild.classList)
lan = '' if !lan
return '\n\n```' + lan + '\n' + _.trim(node.firstChild.textContent) + '\n```\n\n'
}
html = html.replace(/&nbsp;(<\/.*>)/g, "$1")
html = @.replaceImgsByEmojiName(html)
html = @.removeTrailingListBr(html)
markdown = toMarkdown(html, {
gfm: true,
converters: [cleanIssueConverter, codeLanguageConverter]
})
return markdown
getHTML: (text) ->
return "" if !text || !text.length
options = {
breaks: true
}
text = @.replaceEmojiNameByImgs(text)
md = window.markdownit({
breaks: true
})
result = md.render(text)
return result
angular.module("taigaComponents")
.service("tgWysiwygService", ["tgWysiwygCodeHightlighterService", WysiwygService])

View File

@ -28,7 +28,6 @@ class CommentController
constructor: (@currentUserService, @permissionService, @lightboxFactory) ->
@.hiddenDeletedComment = true
@.commentContent = angular.copy(@.comment)
showDeletedComment: () ->
@.hiddenDeletedComment = false
@ -45,6 +44,9 @@ class CommentController
@.user = @currentUserService.getUser()
return @.user.get('id') == @.comment.user.pk || @permissionService.check('modify_project')
saveComment: (text, cb) ->
@.onEditComment({commentId: @.comment.id, commentData: text, callback: cb})
displayCommentHistory: () ->
@lightboxFactory.create('tg-lb-display-historic', {
"class": "lightbox lightbox-display-historic"

View File

@ -1,5 +1,3 @@
include ../../../partials/common/components/wysiwyg.jade
.comment-wrapper(ng-if="!vm.comment.delete_comment_date")
img.comment-avatar(
tg-avatar="vm.comment.user"
@ -23,38 +21,21 @@ include ../../../partials/common/components/wysiwyg.jade
.comment-container
.comment-text.wysiwyg(
ng-if="!vm.editMode"
ng-bind-html="vm.comment.comment_html"
tg-bind-code="vm.comment.comment"
)
.comment-editor(
ng-if="vm.editMode"
ng-keyup="vm.checkCancelComment($event)"
)
.edit-comment(ng-model="vm.type")
textarea(
ng-model="vm.commentContent.comment"
)
.save-comment-wrapper
button.button-green.save-comment(
type="button"
title="{{'COMMENTS.EDIT_COMMENT' | translate}}"
translate="COMMENTS.EDIT_COMMENT"
ng-disabled="!vm.commentContent.comment.length || vm.editing == vm.comment.id"
ng-click="vm.onEditComment({commentId: vm.comment.id, commentData: vm.commentContent.comment})"
tg-loading="vm.editing == vm.comment.id"
)
.comment-options(ng-if="::vm.canEditDeleteComment()")
tg-comment-edit-wysiwyg.edit-comment
.comment-options(ng-if="vm.canEditDeleteComment() && !vm.editMode")
tg-svg.comment-option(
svg-icon="icon-edit"
svg-title-translate="COMMON.EDIT"
ng-click="vm.onEditMode({commentId: vm.comment.id})"
ng-if="!vm.editMode"
)
tg-svg.comment-option(
svg-icon="icon-close"
svg-title-translate="COMMON.CANCEL"
ng-click="vm.onEditMode({commentId: vm.comment.id})"
ng-if="vm.editMode"
)
tg-svg.comment-option(
svg-icon="icon-trash"
svg-title-translate="COMMON.DELETE"
@ -107,5 +88,5 @@ include ../../../partials/common/components/wysiwyg.jade
span(translate="COMMENTS.RESTORE")
p.deleted-comment-comment(
ng-if="!vm.hiddenDeletedComment"
ng-bind-html="vm.comment.comment_html"
tg-bind-code="vm.comment.comment"
)

View File

@ -1,5 +1,16 @@
.comments {
clear: both;
tg-wysiwyg {
margin-top: 1.5rem;
}
.read-mode {
border: 1px solid $gray-light;
height: 55px;
.medium-editor-placeholder,
.markdown-editor-placeholder {
height: 55px;
}
}
.add-comment {
margin-top: 1rem;
textarea {
@ -20,7 +31,6 @@
margin-top: 1rem;
padding: .5rem 4rem;
}
}
.comment {
display: block;
@ -143,12 +153,6 @@
.deleted-comment-comment {
margin-top: 1rem;
}
.comment-editor {
textarea {
height: 5rem;
min-height: 5rem;
}
}
}
.comment-text {

View File

@ -1,5 +1,3 @@
include ../../../partials/common/components/wysiwyg.jade
section.comments
.comments-wrapper
tg-comment.comment(
@ -15,25 +13,11 @@ section.comments
on-edit-mode="vm.onEditMode({commentId: commentId})"
on-delete-comment="vm.onDeleteComment({commentId: commentId})"
on-restore-deleted-comment="vm.onRestoreDeletedComment({commentId: commentId})"
on-edit-comment="vm.onEditComment({commentId: commentId, commentData: commentData})"
on-edit-comment="vm.onEditComment({commentId: commentId, commentData: commentData, callback: callback})"
)
tg-editable-wysiwyg.add-comment(
ng-model="vm.type"
tg-comment-wysiwyg(
tg-check-permission="{{::vm.canAddCommentPermission}}"
tg-toggle-comment
)
textarea(
ng-attr-placeholder="{{'COMMENTS.TYPE_NEW_COMMENT' | translate}}"
tg-markitup="tg-markitup"
ng-model="vm.type.comment"
)
+wysihelp
.save-comment-wrapper
button.button-green.save-comment(
type="button"
title="{{'COMMENTS.COMMENT' | translate}}"
translate="COMMENTS.COMMENT"
ng-disabled="!vm.type.comment.length || vm.loading"
ng-click="vm.onAddComment()"
tg-loading="vm.loading"
on-update="updateComment(text)"
type="vm.type"
)

View File

@ -15,5 +15,5 @@
)
.entry-text(
ng-class="{'ellipsed': !displayFullEntry && entry.comment.length >= 75, 'blurry': entry.comment.length >= 75 && !displayFullEntry}"
ng-bind-html="entry.comment_html"
ng-bind-html="entry.comment | markdownToHTML"
)

View File

@ -72,7 +72,7 @@ class HistorySectionController
@.deleting = commentId
return @rs.history.deleteComment(type, objectId, activityId).then =>
@._loadHistory()
@.deleting = commentId
@.deleting = null
editComment: (commentId, comment) ->
type = @.name
@ -93,12 +93,10 @@ class HistorySectionController
@._loadHistory()
@.editing = null
addComment: () ->
type = @.type
@.loading = true
addComment: (cb) ->
@repo.save(@.type).then =>
@._loadHistory()
@.loading = false
cb()
onOrderComments: () ->
@.reverse = !@.reverse

View File

@ -149,13 +149,17 @@ describe "HistorySection", ->
objectId = historyCtrl.id
commentId = 7
promise = mocks.tgResources.history.deleteComment.withArgs(type, objectId, commentId).promise().resolve()
deleteCommentPromise = mocks.tgResources.history.deleteComment.withArgs(type, objectId, commentId).promise()
historyCtrl.deleting = true
historyCtrl.deleteComment(commentId).then () ->
expect(historyCtrl._loadHistory).have.been.called
ctrlPromise = historyCtrl.deleteComment(commentId)
expect(historyCtrl.deleting).to.be.equal(7)
deleteCommentPromise.resolve()
ctrlPromise.then () ->
expect(historyCtrl._loadHistory).have.been.called
expect(historyCtrl.deleting).to.be.null
it "edit comment", () ->
historyCtrl = controller "HistorySection"
historyCtrl._loadHistory = sinon.stub()
@ -201,13 +205,15 @@ describe "HistorySection", ->
historyCtrl.type = "type"
type = historyCtrl.type
historyCtrl.loading = true
cb = sinon.spy()
promise = mocks.tgRepo.save.withArgs(type).promise().resolve()
historyCtrl.addComment().then () ->
historyCtrl.addComment(cb).then () ->
expect(historyCtrl._loadHistory).has.been.called
expect(historyCtrl.loading).to.be.false
expect(cb).to.have.been.called
it "order comments", () ->
historyCtrl = controller "HistorySection"

View File

@ -18,8 +18,8 @@ section.history(
on-delete-comment="vm.deleteComment(commentId)"
on-restore-deleted-comment="vm.restoreDeletedComment(commentId)"
on-edit-mode="vm.toggleEditMode(commentId)"
on-add-comment="vm.addComment()"
on-edit-comment="vm.editComment(commentId, commentData)"
on-add-comment="vm.addComment(callback)"
on-edit-comment="vm.editComment(commentId, commentData, callback)"
edit-mode="vm.editMode"
object="{{vm.id}}"

View File

@ -1,15 +0,0 @@
include wysiwyg.jade
.view-description
section.us-content.wysiwyg(tg-bind-html="item.description_html || noDescriptionMsg")
tg-svg.edit(svg-icon="icon-edit")
.edit-description
textarea(ng-attr-placeholder="{{'COMMON.DESCRIPTION.EMPTY' | translate}}", ng-model="item.description", tg-markitup="tg-markitup")
+wysihelp
div.save-container
span.save
tg-svg(
svg-icon="icon-save",
svg-title-translate="COMMON.SAVE"
)

View File

@ -0,0 +1,58 @@
.editor(ng-class="{'edit-mode': editMode, 'read-mode': !editMode}")
div(ng-if="outdated")
p.outdated {{'COMMON.WYSIWYG.OUTDATED' | translate}}
.medium.wysiwyg(
type="text",
ng-show="mode == 'html'"
)
textarea.markdown.e2e-markdown-textarea(
placeholder="{{placeholder}}"
ng-change="changeMarkdown()"
ng-model="markdown"
ng-show="mode == 'markdown' && editMode"
)
.markdown(
ng-class="{empty: !markdown.length}"
ng-click="editMode = true"
ng-show="mode == 'markdown' && !editMode"
)
p(ng-if="markdown.length") {{markdown}}
p.markdown-editor-placeholder.wysiwyg(ng-if="!markdown.length") {{placeholder}}
.mode-editor(ng-if="editMode")
span.e2e-markdown-mode(
ng-if="mode=='html'"
ng-click="setMode('markdown')"
) Markdown Mode
span.e2e-html-mode(
ng-if="mode=='markdown'"
ng-click="setMode('html')"
) HTML Mode
a.help-markdown(
ng-if="mode=='markdown'"
href="https://tree.taiga.io/support/misc/taiga-markdown-syntax/"
target="_blank"
title="{{'COMMON.WYSIWYG.MARKDOWN_HELP' | translate}}"
)
tg-svg(svg-icon="icon-question")
span(translate="COMMON.WYSIWYG.MARKDOWN_HELP")
.tools(ng-if="editMode")
a.e2e-save-editor(
ng-class="{disabled: required && !markdown.length}"
tg-loading="saving"
href="#",
ng-click="save()"
)
tg-svg(svg-icon="icon-save")
a.e2e-cancel-editor(
href="#",
ng-click="cancel()"
title="{{ 'COMMON.CANCEL' | translate }}"
)
tg-svg(svg-icon="icon-close")

View File

@ -1,11 +0,0 @@
mixin wysihelp
.wysiwyg-help
span.drag-drop-help(ng-if="wiki.id", translate="COMMON.WYSIWYG.ATTACH_FILE_HELP")
span.drag-drop-help(ng-if="!wiki.id", translate="COMMON.WYSIWYG.ATTACH_FILE_HELP_SAVE_FIRST")
a.help-markdown(
href="https://tree.taiga.io/support/misc/taiga-markdown-syntax/"
target="_blank"
title="{{'COMMON.WYSIWYG.MARKDOWN_HELP' | translate}}"
)
tg-svg(svg-icon="icon-question")
span(translate="COMMON.WYSIWYG.MARKDOWN_HELP")

View File

@ -41,10 +41,10 @@ div.wrapper(
)
tg-created-by-display.ticket-created-by(ng-model="epic")
section.duty-content(
tg-editable-description
tg-editable-wysiwyg
ng-model="epic"
section.duty-content
tg-item-wysiwyg(
type="epic",
model="epic",
required-perm="modify_epic"
)

View File

@ -34,10 +34,10 @@ div.wrapper(
)
tg-created-by-display.ticket-created-by(ng-model="issue")
section.duty-content(
tg-editable-description
tg-editable-wysiwyg
ng-model="issue"
section.duty-content
tg-item-wysiwyg(
type="issue",
model="issue",
required-perm="modify_issue"
)

View File

@ -43,7 +43,12 @@ div.wrapper(
)
tg-created-by-display.ticket-created-by(ng-model="task")
section.duty-content(tg-editable-description, tg-editable-wysiwyg, ng-model="task", required-perm="modify_task")
section.duty-content
tg-item-wysiwyg(
type="task",
model="task",
required-perm="modify_task"
)
// Custom Fields
tg-custom-attributes-values(

View File

@ -43,7 +43,12 @@ div.wrapper(
)
tg-created-by-display.ticket-created-by(ng-model="us")
section.duty-content(tg-editable-description, tg-editable-wysiwyg, ng-model="us", required-perm="modify_us")
section.duty-content
tg-item-wysiwyg(
type="us",
model="us",
required-perm="modify_us"
)
// Custom Fields
tg-custom-attributes-values(

View File

@ -1,29 +0,0 @@
include ../common/components/wysiwyg.jade
.view-wiki-content
section.wysiwyg(tg-bind-html='wiki.html')
a.edit(
href=""
title="{{'COMMON.EDIT' | translate}}"
)
tg-svg(svg-icon="icon-edit")
.edit-wiki-content(style='display: none;')
textarea(
ng-attr-placeholder="{{'WIKI.PLACEHOLDER_PAGE' | translate}}",
ng-model='wiki.content'
tg-markitup='tg-markitup'
)
+wysihelp
span.action-container
a.save(
title="{{'COMMON.SAVE' | translate}}"
href=""
)
tg-svg(svg-icon="icon-save")
a.cancel(
title="{{'COMMON.CANCEL' | translate}}"
href=""
)
tg-svg(svg-icon="icon-close")

View File

@ -17,11 +17,8 @@ div.wrapper(
span.green(translate="PROJECT.SECTION.WIKI")
h2.wiki-title(ng-bind='wikiTitle')
section.wiki-content(
tg-editable-wysiwyg,
tg-editable-wiki-content,
ng-model="wiki"
)
tg-wiki-wysiwyg(model="wiki")
.summary.wiki-summary(
tg-wiki-summary

View File

@ -1,42 +0,0 @@
.markItUpHeader {
ul {
background: $mass-white;
padding: .3rem;
li {
display: inline-block;
float: none;
a {
opacity: .8;
&:hover {
opacity: .3;
transition: opacity .2s linear;
}
}
}
.preview-icon {
position: absolute;
right: 4rem;
}
}
}
.markItUpContainer {
padding: 0;
}
.markdown {
position: relative;
}
.preview {
.actions {
background: $mass-white;
margin-top: .5rem;
min-height: 2rem;
padding: .3rem;
}
.content {
background: $white;
margin-bottom: 0;
}
}

View File

@ -1,5 +1,5 @@
// Bourbon
@import '../../../vendor/bourbon/app/assets/stylesheets/bourbon';
@import '../../../node_modules/bourbon/core/bourbon';
//#################################################
// dependencies
@ -22,3 +22,13 @@
@import '../dependencies/mixins/svg';
@import '../dependencies/mixins/track-buttons';
@import '../dependencies/mixins/empty-color';
//deprecated
@mixin placeholder {
$placeholders: ":-webkit-input" ":-moz" "-moz" "-ms-input";
@each $placeholder in $placeholders {
&:#{$placeholder}-placeholder {
@content;
}
}
}

View File

@ -10,73 +10,3 @@
padding: 1rem;
}
}
.wiki-content {
@include font-size(large);
position: relative;
&.editable {
&:hover {
.wysiwyg {
background: $mass-white;
cursor: pointer;
}
}
}
.view-wiki-content {
&:hover {
.edit {
opacity: 1;
top: -1.5rem;
transition: all .2s linear;
}
}
}
.edit {
@include svg-size(2rem);
background: $mass-white;
left: 0;
opacity: 0;
padding: .2rem .5rem;
position: absolute;
top: 0;
transition: all .2s linear;
&:hover {
cursor: pointer;
}
}
.preview {
padding-top: 1.8rem;
}
}
.edit-wiki-content {
a {
display: inline-block;
margin-right: .5rem;
&:last-child {
margin: 0;
}
&:hover {
cursor: pointer;
.icon {
fill: $primary-dark;
opacity: .3;
transition: all .2s linear;
}
}
}
.preview-icon {
position: absolute;
right: 3.5rem;
}
.action-container {
position: absolute;
right: 1rem;
top: .3rem;
}
.edit {
position: absolute;
right: 3.5rem;
top: .4rem;
}
}

View File

@ -1,175 +0,0 @@
/* -------------------------------------------------------------------
// markItUp!
// By Jay Salvat - http://markitup.jaysalvat.com/
// ------------------------------------------------------------------*/
.markItUp .markItUpButton1 a {
background-image:url("../images/markitup/h1.png");
}
.markItUp .markItUpButton2 a {
background-image:url("../images/markitup/h2.png");
}
.markItUp .markItUpButton3 a {
background-image:url("../images/markitup/h3.png");
}
.markItUp .markItUpButton4 a {
background-image:url("../images/markitup/bold.png");
}
.markItUp .markItUpButton5 a {
background-image:url("../images/markitup/italic.png");
}
.markItUp .markItUpButton6 a {
background-image:url("../images/markitup/stroke.png");
}
.markdown .markItUpButton7 a {
background-image:url("../images/markitup/list-bullet.png");
}
.markdown .markItUpButton8 a {
background-image:url("../images/markitup/list-numeric.png");
}
.markdown .markItUpButton9 a {
background-image:url("../images/markitup/picture.png");
}
.markdown .markItUpButton10 a {
background-image:url("../images/markitup/link.png");
}
.markdown .markItUpButton11 a {
background-image:url("../images/markitup/quotes.png");
}
.markdown .markItUpButton12 a {
background-image:url("../images/markitup/code.png");
}
.markdown .preview-icon a {
background-image:url("../images/markitup/preview.png");
}
.markdown .help a {
background-image:url("../images/markitup/help.png");
}
/* -------------------------------------------------------------------
// markItUp! Universal MarkUp Engine, JQuery plugin
// By Jay Salvat - http://markitup.jaysalvat.com/
// ------------------------------------------------------------------*/
.markItUp * {
margin:0px; padding:0px;
outline:none;
}
.markItUp a:link,
.markItUp a:visited {
color:#000;
text-decoration:none;
}
.markItUpContainer {
padding:5px 5px 2px 5px;
font:11px Verdana, Arial, Helvetica, sans-serif;
}
.markItUpEditor {
font:12px 'Courier New', Courier, monospace;
padding:5px;
height:320px;
clear:both;
line-height:18px;
overflow:auto;
}
.markItUpPreviewFrame {
overflow:auto;
background-color:#FFF;
width:99.9%;
height:300px;
margin:5px 0;
}
.markItUpFooter {
width:100%;
}
.markItUpResizeHandle {
overflow:hidden;
width:22px; height:5px;
margin-left:auto;
margin-right:auto;
background-image:url(../images/markitup/handle.png);
cursor:n-resize;
}
/***************************************************************************************/
/* first row of buttons */
.markItUp .markItUpHeader ul {
margin: 0;
}
.markItUpHeader ul li {
list-style:none;
float:left;
position:relative;
margin: 3px;
}
.markItUpHeader ul li:hover > ul{
display:block;
}
.markItUpHeader ul .markItUpDropMenu {
background:transparent url(../images/markitup/menu.png) no-repeat 115% 50%;
margin-right:5px;
}
.markItUpHeader ul .markItUpDropMenu li {
margin-right:0px;
}
/* next rows of buttons */
.markItUpHeader ul ul {
display:none;
position:absolute;
top:18px; left:0px;
background:#FFF;
border:1px solid #000;
}
.markItUpHeader ul ul li {
float:none;
border-bottom:1px solid #000;
}
.markItUpHeader ul ul .markItUpDropMenu {
background:#FFF url(../images/markitup/submenu.png) no-repeat 100% 50%;
}
.markItUpHeader ul .markItUpSeparator {
margin:2px 10px 0 10px;
width:1px;
height:16px;
overflow:hidden;
background-color:#CCC;
}
.markItUpHeader ul ul .markItUpSeparator {
width:auto; height:1px;
margin:0px;
}
/* next rows of buttons */
.markItUpHeader ul ul ul {
position:absolute;
top:-1px; left:150px;
}
.markItUpHeader ul ul ul li {
float:none;
}
.markItUpHeader ul a {
display:block;
width:16px; height:16px;
text-indent:-10000px;
background-repeat:no-repeat;
padding:3px;
margin:0px;
}
.markItUpHeader ul ul a {
display:block;
padding-left:0px;
text-indent:0;
width:120px;
padding:5px 5px 5px 25px;
background-position:2px 50%;
}
.markItUpHeader ul ul a:hover {
color:#FFF;
background-color:#000;
}

View File

@ -1,90 +0,0 @@
{
"name": "taiga-layout",
"version": "2.1.0",
"homepage": "https://github.com/taiga.io/taiga-layout",
"authors": [
{
"name": "Andrey Antukh",
"email": "niwi@niwi.nz"
},
{
"name": "Jesus Espino Garcia",
"email": "jespinog@gmail.com"
},
{
"name": "David Barragán Merino",
"email": "dbarragan@dbarragan.com"
},
{
"name": "Xavi Julian",
"email": "xavier.julian@kaleidos.net"
},
{
"name": "Alejandro Alonso",
"email": "alejandro.alonso@kaleidos.net"
},
{
"name": "Anler Hernández",
"email": "hello@anler.me"
},
{
"name": "Juan Francisco Alcántara",
"email": "juanfran.alcantara@kaleidos.net"
}
],
"description": "Taiga project management system (frontend)",
"license": "AGPL-3.0",
"repository": {
"type": "git",
"url": "git@github.com:taigaio/taiga-front.git"
},
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"app/vendor",
"test",
"tests"
],
"dependencies": {
"emoticons": "~0.1.7",
"jquery-flot": "~0.8.2",
"angular": "1.5.5",
"angular-route": "1.5.5",
"angular-animate": "1.5.5",
"angular-aria": "1.5.5",
"angular-sanitize": "1.5.5",
"checksley": "~0.6.0",
"jquery": "~2.2.3",
"markitup-1x": "~1.1.14",
"jquery-textcomplete": "yuku-t/jquery-textcomplete#~0.7",
"flot-axislabels": "markrcote/flot-axislabels",
"flot-orderBars": "emmerich/flot-orderBars",
"flot.tooltip": "~0.8.4",
"moment": "~2.13.0",
"pikaday": "~1.4.0",
"raven-js": "~3.0.0",
"l.js": "~0.1.0",
"angular-translate": "~2.10.0",
"angular-translate-loader-partial": "~2.10.0",
"angular-translate-loader-static-files": "~2.10.0",
"angular-translate-interpolation-messageformat": "~2.10.0",
"ng-infinite-scroll-npm-is-better-than-bower": "^1.3.0",
"immutable": "~3.8.1",
"bluebird": "~3.3.5",
"intro.js": "~2.1.0",
"lodash": "~4.11.2",
"messageformat": "^0.3.1",
"dragula.js": "dragula#^3.6.6",
"bourbon": "^4.2.7"
},
"resolutions": {
"lodash": "~4.11.2",
"moment": "~2.10.6",
"jquery": "~2.2.3",
"angular": "1.5.5",
"messageformat": "0.3.1",
"angular-translate": "2.10.0"
},
"private": true
}

View File

@ -146,24 +146,6 @@ helper.assignedTo = function() {
return obj;
};
helper.editComment = function() {
let el = $('.comment-editor');
let obj = {
el:el,
updateText: function (text) {
el.$('textarea').sendKeys(text);
},
saveComment: async function () {
el.$('.save-comment').click();
await browser.waitForAngular();
}
}
return obj;
};
helper.history = function() {
let el = $('section.history');
let obj = {
@ -179,16 +161,6 @@ helper.history = function() {
await browser.waitForAngular();
},
addComment: async function(comment) {
obj.writeComment(comment);
el.$('.save-comment').click();
await browser.waitForAngular();
},
writeComment: function(comment) {
el.$('textarea[tg-markitup]').sendKeys(comment);
},
countComments: async function() {
let comments = await el.$$(".comment-wrapper");
return comments.length;
@ -227,6 +199,10 @@ helper.history = function() {
await browser.waitForAngular();
},
getComments: function() {
return $$('tg-comment');
},
showVersionsLastComment: async function() {
el.$$(".comment-edited a").last().click();
await browser.waitForAngular();
@ -252,11 +228,11 @@ helper.history = function() {
el.$$(".deleted-comment-wrapper .restore-comment").last().click();
await browser.waitForAngular();
}
}
};
return obj;
}
};
helper.block = function() {
let el = $('tg-block-button');

View File

@ -6,6 +6,7 @@ var customFieldsHelper = require('../helpers/custom-fields-helper');
var commonUtil = require('../utils/common');
var lightbox = require('../utils/lightbox');
var notifications = require('../utils/notifications');
var sharedWysiwyg = require('./wysiwyg').wysiwygTesting;
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
@ -48,49 +49,6 @@ shared.tagsTesting = async function() {
expect(newtagsText).to.be.not.eql(tagsText);
}
shared.descriptionTesting = function() {
it('confirm close with ESC', async function() {
let descriptionHelper = detailHelper.description();
descriptionHelper.enabledEditionMode();
browser.actions().sendKeys(protractor.Key.ESCAPE).perform();
await lightbox.confirm.cancel();
let descriptionVisibility = await $('.edit-description').isDisplayed();
expect(descriptionVisibility).to.be.true;
descriptionHelper.focus();
browser.actions().sendKeys(protractor.Key.ESCAPE).perform();
await lightbox.confirm.ok();
descriptionVisibility = await $('.edit-description').isDisplayed();
expect(descriptionVisibility).to.be.false;
});
it('edit', async function() {
let descriptionHelper = detailHelper.description();
let description = await descriptionHelper.getInnerHtml();
let date = Date.now();
descriptionHelper.enabledEditionMode();
descriptionHelper.setText("New description " + date);
descriptionHelper.save();
let newDescription = await descriptionHelper.getInnerHtml();
let notificationOpen = await notifications.success.open();
expect(notificationOpen).to.be.equal.true;
expect(newDescription).to.be.not.equal(description);
await notifications.success.close();
});
}
shared.statusTesting = async function(status1 , status2) {
let statusHelper = detailHelper.statusSelector();
@ -195,68 +153,9 @@ shared.assignedToTesting = function() {
shared.historyTesting = async function(screenshotsFolder) {
let historyHelper = detailHelper.history();
//Adding a comment
historyHelper.selectCommentsTab();
await utils.common.takeScreenshot(screenshotsFolder, "show comments tab");
let commentsCounter = await historyHelper.countComments();
let date = Date.now();
await historyHelper.addComment("New comment " + date);
await utils.common.takeScreenshot(screenshotsFolder, "new coment");
let newCommentsCounter = await historyHelper.countComments();
expect(newCommentsCounter).to.be.equal(commentsCounter+1);
//Edit last comment
historyHelper.editLastComment();
let editComment = detailHelper.editComment();
editComment.updateText("This is the new and updated text");
editComment.saveComment();
await utils.common.takeScreenshot(screenshotsFolder, "edit comment");
//Show versions from last comment edited
historyHelper.showVersionsLastComment();
await utils.common.takeScreenshot(screenshotsFolder, "show comment versions");
historyHelper.closeVersionsLastComment();
//Deleting last comment
let deletedCommentsCounter = await historyHelper.countDeletedComments();
await historyHelper.deleteLastComment();
let newDeletedCommentsCounter = await historyHelper.countDeletedComments();
expect(newDeletedCommentsCounter).to.be.equal(deletedCommentsCounter+1);
await utils.common.takeScreenshot(screenshotsFolder, "deleted comment");
//Restore last comment
deletedCommentsCounter = await historyHelper.countDeletedComments();
await historyHelper.restoreLastComment();
newDeletedCommentsCounter = await historyHelper.countDeletedComments();
expect(newDeletedCommentsCounter).to.be.equal(deletedCommentsCounter-1);
await utils.common.takeScreenshot(screenshotsFolder, "restored comment");
//Store comment with a modification
commentsCounter = await historyHelper.countComments();
historyHelper.writeComment("New comment " + date);
let title = detailHelper.title();
title.setTitle('changed');
await title.save();
await utils.notifications.success.close();
newCommentsCounter = await historyHelper.countComments();
expect(newCommentsCounter).to.be.equal(commentsCounter+1);
//Check activity
await historyHelper.selectActivityTab();
await utils.common.takeScreenshot(screenshotsFolder, "show activity tab");
let activitiesCounter = await historyHelper.countActivities();
expect(newCommentsCounter).to.be.least(1);
}
shared.blockTesting = async function() {

500
e2e/shared/wysiwyg.js Normal file
View File

@ -0,0 +1,500 @@
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
var detailHelper = require('../helpers').detail;
var historyHelper = detailHelper.history();
var utils = require('../utils');
var EC = protractor.ExpectedConditions;
chai.use(chaiAsPromised);
var expect = chai.expect;
var shared = module.exports;
function selectEditorFirstChild(elm) {
browser.executeScript(function () {
// select the first paragraph
var range = document.createRange();
range.selectNode(arguments[0].firstChild);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}, elm.getWebElement());
browser.actions().mouseUp().perform(); // trigger medium events
}
function resetSelection() {
browser.executeScript(function () {
var sel = window.getSelection();
sel.removeAllRanges();
});
browser.actions().mouseUp().perform(); // trigger medium events
}
function getMarkdownText(elm) {
var markdownTextarea = getMarkdownTextarea(elm);
return markdownTextarea.getAttribute("value");
}
function getMarkdownTextarea(elm) {
return elm.$('.e2e-markdown-textarea');}
function htmlMode() {
$('.e2e-html-mode').click();
}
function markdownMode() {
$('.e2e-markdown-mode').click();
}
function saveEdition() {
$('.e2e-save-editor').click();
}
function cancelEdition(elm) {
$('.e2e-cancel-editor').click();
return browser.wait(async () => {
return !!await elm.$$('.read-mode').count();
}, 3000);
}
async function edit(elm, elmWrapper, text = null) {
await browser.wait(EC.elementToBeClickable(elm), 10000);
elm.click();
browser.sleep(200);
browser.executeScript(function () {
if(arguments[0].firstChild) {
var range = document.createRange();
range.setStart(arguments[0].firstChild, 0);
range.setEnd(arguments[0].lastChild, 0);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}, elm.getWebElement());
if (text !== null) {
await cleanWysiwyg(elm, elmWrapper);
return elm.sendKeys(text);
}
}
async function cleanWysiwyg(elm, elmWrapper) {
let isHtmlMode = await elm.isDisplayed();
if (isHtmlMode) {
let isPresent = await $('.e2e-markdown-mode').isPresent();
markdownMode();
}
var markdownTextarea = getMarkdownTextarea(elmWrapper);
await utils.common.clear(markdownTextarea);
return htmlMode();
}
shared.wysiwygTestingComments = function(parentSelector, section) {
var editor;
var editorWrapper;
beforeEach(() => {
let parent = $(parentSelector);
editor = parent.$('.medium');
editorWrapper = parent.$('tg-wysiwyg');
});
it('bold, test normal behavior and check markdown', async () => {
let commentsCounter = await historyHelper.countComments();
await edit(editor, editorWrapper, "test");
selectEditorFirstChild(editor);
$('.medium-editor-toolbar-active .medium-editor-action-bold').click();
resetSelection();
markdownMode();
let markdown = await getMarkdownText(editorWrapper);
expect(markdown).to.be.equal('**test**');
htmlMode();
saveEdition();
let newCommentsCounter = await historyHelper.countComments();
expect(newCommentsCounter).to.be.equal(commentsCounter+1);
});
it('convert to html', async () => {
let commentsCounter = await historyHelper.countComments();
await edit(editor, editorWrapper, '');
markdownMode();
let markdownTextarea = getMarkdownTextarea(editorWrapper);
await markdownTextarea.sendKeys('_test2_');
htmlMode();
let html = await editor.getInnerHtml();
expect(html).to.be.eql('<p><em>test2</em></p>\n');
saveEdition();
let newCommentsCounter = await historyHelper.countComments();
expect(newCommentsCounter).to.be.equal(commentsCounter+1);
});
it('code block', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys("var test = 2;");
selectEditorFirstChild(editor);
$('.medium-editor-toolbar-active .medium-editor-button-last').click();
$('.code-language-selector').click();
$('.code-language-search input').sendKeys('javascript');
$('.code-language-search li').click();
saveEdition();
let lastComment = historyHelper.getComments().last();
let hasHightlighter = !!await lastComment.$$('.token').count();
expect(hasHightlighter).to.be.true;
});
it('confirm exit when there is changes', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys('text text text');
editor.sendKeys(protractor.Key.ESCAPE);
await utils.lightbox.confirm.ok();
let isReadMode = !!await editorWrapper.$$('.read-mode').count();
expect(isReadMode).to.be.true;
let html = await editor.getText();
expect(html).not.to.be.eql('text text text');
});
it('keep changes on reload', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys('text text text');
editor.sendKeys(protractor.Key.ESCAPE);
browser.sleep(400);
browser.refresh();
let isReadMode = !!await editorWrapper.$$('.read-mode').count();
expect(isReadMode).to.be.false;
let html = await editor.getText();
expect(html).to.be.eql('text text text');
await cancelEdition(editorWrapper);
});
it('mention user', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys('@use');
$$('.medium-mention li').get(2).click();
let html = await editor.getInnerHtml();
expect(html).to.be.eql('<p><a href="/profile/user8">@user8</a>&nbsp;</p>');
markdownMode();
let markdown = await getMarkdownText(editorWrapper);
expect(markdown).to.be.equal('[@user8](/profile/user8)');
htmlMode();
await cancelEdition(editorWrapper);
});
it('emojis', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys(':smil');
$$('.medium-mention li').get(2).click();
let html = await editor.getInnerHtml();
expect(html).to.include('1f604.png');
markdownMode();
let markdown = await getMarkdownText(editorWrapper);
expect(markdown).to.be.equal(':smile:');
htmlMode();
await cancelEdition(editorWrapper);
});
it('cancel', async () => {
let prevHtml = await editor.getInnerHtml();
await edit(editor, editorWrapper, 'xxx yyy zzz');
await cancelEdition(editorWrapper);
let html = await editor.getInnerHtml();
expect(html).to.be.equal(prevHtml);
});
it('edit comment', async () => {
historyHelper.editLastComment();
let editWrapperLast = historyHelper.getComments().last();
let editLast = editWrapperLast.$('.medium');
await edit(editLast, editWrapperLast, "This is the new and updated text");
await utils.common.takeScreenshot(section, "edit comment");
saveEdition();
//Show versions from last comment edited
historyHelper.showVersionsLastComment();
await utils.common.takeScreenshot(section, "show comment versions");
historyHelper.closeVersionsLastComment();
});
it('delete last comment', async () => {
let deletedCommentsCounter = await historyHelper.countDeletedComments();
await historyHelper.deleteLastComment();
let newDeletedCommentsCounter = await historyHelper.countDeletedComments();
expect(newDeletedCommentsCounter).to.be.equal(deletedCommentsCounter+1);
await utils.common.takeScreenshot(section, 'deleted comment');
});
it('restore last comment', async () => {
let deletedCommentsCounter = await historyHelper.countDeletedComments();
await historyHelper.restoreLastComment();
let newDeletedCommentsCounter = await historyHelper.countDeletedComments();
expect(newDeletedCommentsCounter).to.be.equal(deletedCommentsCounter-1);
await utils.common.takeScreenshot(section, 'restored comment');
});
};
shared.wysiwygTesting = function(parentSelector) {
var editor;
var editorWrapper;
beforeEach(async () => {
let isReadMode = !!await editorWrapper.$$('.read-mode').count();
if (isReadMode) {
editor.click();
}
await cleanWysiwyg(editor, editorWrapper);
markdownMode();
var markdownTextarea = getMarkdownTextarea(editorWrapper);
browser.wait(EC.elementToBeClickable(markdownTextarea), 10000);
await markdownTextarea.sendKeys('test');
htmlMode();
saveEdition();
await browser.wait(EC.elementToBeClickable(editor), 10000);
});
before(() => {
let parent = $(parentSelector);
editor = parent.$('.medium');
editorWrapper = parent.$('tg-wysiwyg');
});
it('bold, test normal behavior and check markdown', async () => {
await edit(editor, editorWrapper, "test");
selectEditorFirstChild(editor);
$('.medium-editor-toolbar-active .medium-editor-action-bold').click();
resetSelection();
let html = await editor.getInnerHtml();
expect(html).to.be.eql('<p><b>test</b></p>');
saveEdition();
await edit(editor, editorWrapper);
markdownMode();
let markdown = await getMarkdownText(editorWrapper);
expect(markdown).to.be.equal('**test**');
});
it('convert to html', async () => {
await edit(editor, editorWrapper, '');
markdownMode();
let markdownTextarea = getMarkdownTextarea(editorWrapper);
await markdownTextarea.sendKeys('_test2_');
htmlMode();
let html = await editor.getInnerHtml();
expect(html).to.be.eql('<p><em>test2</em></p>\n');
});
it('code block', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys("var test = 2;");
selectEditorFirstChild(editor);
$('.medium-editor-toolbar-active .medium-editor-button-last').click();
$('.code-language-selector').click();
$('.code-language-search input').sendKeys('javascript');
$('.code-language-search li').click();
saveEdition();
let hasHightlighter = !!await editor.$$('.token').count();
expect(hasHightlighter).to.be.true;
});
it('save with confirmconfirm exit when there is changes', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys('text text text');
editor.sendKeys(protractor.Key.ESCAPE);
await utils.lightbox.confirm.ok();
let isReadMode = !!await editorWrapper.$$('.read-mode').count();
expect(isReadMode).to.be.true;
let html = await editor.getText();
expect(html).not.to.be.eql('text text text');
});
it('keep changes on reload', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys('text text text');
editor.sendKeys(protractor.Key.ESCAPE);
browser.sleep(400);
browser.refresh();
let isReadMode = !!await editorWrapper.$$('.read-mode').count();
expect(isReadMode).to.be.false;
let html = await editor.getText();
expect(html).to.be.eql('text text text');
});
it('mention user', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys('@use');
$$('.medium-mention li').get(2).click();
let html = await editor.getInnerHtml();
expect(html).to.be.eql('<p><a href="/profile/user8">@user8</a>&nbsp;</p>');
markdownMode();
let markdown = await getMarkdownText(editorWrapper);
expect(markdown).to.be.equal('[@user8](/profile/user8)');
htmlMode();
});
it('emojis', async () => {
await edit(editor, editorWrapper, '');
editor.sendKeys(':smil');
$$('.medium-mention li').get(2).click();
let html = await editor.getInnerHtml();
expect(html).to.include('1f604.png');
markdownMode();
let markdown = await getMarkdownText(editorWrapper);
expect(markdown).to.be.equal(':smile:');
});
it('cancel', async () => {
let prevHtml = await editor.getInnerHtml();
await edit(editor, editorWrapper, 'xxx yyy zzz');
await cancelEdition(editorWrapper);
let html = await editor.getInnerHtml();
expect(html).to.be.equal(prevHtml);
});
};

View File

@ -1,6 +1,9 @@
var utils = require('../../utils');
var sharedDetail = require('../../shared/detail');
var epicDetailHelper = require('../../helpers').epicDetail;
var wysiwyg = require('../../shared/wysiwyg');
var sharedWysiwyg = wysiwyg.wysiwygTesting;
var sharedWysiwygComments = wysiwyg.wysiwygTestingComments;
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
@ -39,7 +42,7 @@ describe('Epic detail', async function(){
it('tags edition', sharedDetail.tagsTesting);
describe('description', sharedDetail.descriptionTesting);
describe('description', sharedWysiwyg.bind(this, '.duty-content'));
describe('related userstories', function() {
let relatedUserstories = epicDetailHelper.relatedUserstories();
@ -68,6 +71,8 @@ describe('Epic detail', async function(){
it('history', sharedDetail.historyTesting.bind(this, "epics"));
describe('comments epics', sharedWysiwygComments.bind(this, '.comments', 'epics'));
it('block', sharedDetail.blockTesting);
describe('team requirement edition', sharedDetail.teamRequirementTesting);

View File

@ -1,5 +1,8 @@
var utils = require('../../utils');
var sharedDetail = require('../../shared/detail');
var wysiwyg = require('../../shared/wysiwyg');
var sharedWysiwyg = wysiwyg.wysiwygTesting;
var sharedWysiwygComments = wysiwyg.wysiwygTestingComments;
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
@ -29,7 +32,7 @@ describe('Issue detail', async function(){
it('tags edition', sharedDetail.tagsTesting);
describe('description', sharedDetail.descriptionTesting);
describe('description', sharedWysiwyg.bind(this, '.duty-content'));
it('status edition', sharedDetail.statusTesting.bind(this, 'In progress', 'Ready for test'));
@ -39,6 +42,8 @@ describe('Issue detail', async function(){
it('history', sharedDetail.historyTesting.bind(this, "issues"));
describe('comments issue', sharedWysiwygComments.bind(this, '.comments', 'issues'));
it('block', sharedDetail.blockTesting);
it('attachments', sharedDetail.attachmentTesting);

View File

@ -1,6 +1,9 @@
var utils = require('../../utils');
var sharedDetail = require('../../shared/detail');
var taskDetailHelper = require('../../helpers').taskDetail;
var wysiwyg = require('../../shared/wysiwyg');
var sharedWysiwyg = wysiwyg.wysiwygTesting;
var sharedWysiwygComments = wysiwyg.wysiwygTestingComments;
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
@ -31,7 +34,7 @@ describe('Task detail', function(){
it('tags edition', sharedDetail.tagsTesting);
describe('description', sharedDetail.descriptionTesting);
describe('description', sharedWysiwyg.bind(this, '.duty-content'));
it('status edition', sharedDetail.statusTesting.bind(this, 'In progress', 'Ready for test'));
@ -55,6 +58,8 @@ describe('Task detail', function(){
it('history', sharedDetail.historyTesting.bind(this, "tasks"));
describe('comments task', sharedWysiwygComments.bind(this, '.comments', 'tasks'));
it('block', sharedDetail.blockTesting);
it('attachments', sharedDetail.attachmentTesting);

View File

@ -1,6 +1,9 @@
var utils = require('../../utils');
var sharedDetail = require('../../shared/detail');
var usDetailHelper = require('../../helpers').usDetail;
var wysiwyg = require('../../shared/wysiwyg');
var sharedWysiwyg = wysiwyg.wysiwygTesting;
var sharedWysiwygComments = wysiwyg.wysiwygTestingComments;
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
@ -30,7 +33,7 @@ describe('User story detail', function(){
it('tags edition', sharedDetail.tagsTesting);
describe('description', sharedDetail.descriptionTesting);
describe('description', sharedWysiwyg.bind(this, '.duty-content'));
it('status edition', sharedDetail.statusTesting.bind(this, 'Ready', 'In progress'));
@ -44,6 +47,8 @@ describe('User story detail', function(){
it('history', sharedDetail.historyTesting.bind(this, "user-stories"));
describe('comments us', sharedWysiwygComments.bind(this, '.comments', 'issues'));
it('block', sharedDetail.blockTesting);
it('attachments', sharedDetail.attachmentTesting);

View File

@ -1,6 +1,7 @@
var utils = require('../utils');
var sharedDetail = require('../shared/detail');
var wikiHelper = require('../helpers').wiki;
var sharedWysiwyg = require('../shared/wysiwyg').wysiwygTesting;
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
@ -64,30 +65,7 @@ describe('wiki', function() {
await utils.common.takeScreenshot("wiki", "deleting-the-created-link");
});
it('edition', async function() {
let timesEdited = wikiHelper.editor().getTimesEdited();
let lastEditionDatetime = wikiHelper.editor().getLastEditionDateTime();
wikiHelper.editor().enabledEditionMode();
let settingText = "This is the new text" + new Date().getTime();
wikiHelper.editor().setText(settingText);
//preview
wikiHelper.editor().preview();
await utils.common.takeScreenshot("wiki", "home-edition-preview");
wikiHelper.editor().closePreview();
//save
wikiHelper.editor().save();
let newHtml = await wikiHelper.editor().getInnerHtml();
let newTimesEdited = wikiHelper.editor().getTimesEdited();
let newLastEditionDatetime = wikiHelper.editor().getLastEditionDateTime();
expect(newHtml).to.be.equal("<p>" + settingText + "</p>");
expect(newTimesEdited).to.be.eventually.equal(timesEdited+1);
expect(newLastEditionDatetime).to.be.not.equal(lastEditionDatetime);
await utils.common.takeScreenshot("wiki", "home-edition");
});
describe('wiki editor', sharedWysiwyg.bind(this));
it('confirm close with ESC in lightbox', async function() {
wikiHelper.editor().enabledEditionMode();

BIN
emojis/0023-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

BIN
emojis/002a-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

BIN
emojis/0030-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

BIN
emojis/0031-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

BIN
emojis/0032-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

BIN
emojis/0033-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

BIN
emojis/0034-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

BIN
emojis/0035-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

BIN
emojis/0036-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 B

BIN
emojis/0037-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

BIN
emojis/0038-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

BIN
emojis/0039-20e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

BIN
emojis/1f004.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B

BIN
emojis/1f0cf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

BIN
emojis/1f170.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

BIN
emojis/1f171.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

BIN
emojis/1f17e.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

BIN
emojis/1f17f.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

BIN
emojis/1f18e.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

BIN
emojis/1f191.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

BIN
emojis/1f192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

BIN
emojis/1f193.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

BIN
emojis/1f194.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

BIN
emojis/1f195.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

BIN
emojis/1f196.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

BIN
emojis/1f197.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

BIN
emojis/1f198.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

BIN
emojis/1f199.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

BIN
emojis/1f19a.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

BIN
emojis/1f1e6-1f1e8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
emojis/1f1e6-1f1e9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

BIN
emojis/1f1e6-1f1ea.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

BIN
emojis/1f1e6-1f1eb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

BIN
emojis/1f1e6-1f1ec.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

BIN
emojis/1f1e6-1f1ee.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

BIN
emojis/1f1e6-1f1f1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

BIN
emojis/1f1e6-1f1f2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

BIN
emojis/1f1e6-1f1f4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

BIN
emojis/1f1e6-1f1f6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

BIN
emojis/1f1e6-1f1f7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 B

BIN
emojis/1f1e6-1f1f8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

BIN
emojis/1f1e6-1f1f9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

BIN
emojis/1f1e6-1f1fa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

BIN
emojis/1f1e6-1f1fc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

BIN
emojis/1f1e6-1f1fd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

BIN
emojis/1f1e6-1f1ff.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

BIN
emojis/1f1e7-1f1e6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 B

Some files were not shown because too many files have changed in this diff Show More