Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editor.coffee
5653 views
1
#########################################################################
2
# This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
# License: MS-RSL – see LICENSE.md for details
4
#########################################################################
5
6
$ = window.$
7
8
# Do this first, before any templates are initialized (e.g., elsewhere too).
9
10
{TEMPLATES_HTML} = require("./editor-templates")
11
12
$("body").append(TEMPLATES_HTML);
13
14
templates = $("#webapp-editor-templates")
15
16
{ init_buttonbars } = require("./editors/editor-button-bar")
17
init_buttonbars()
18
19
# Editor files in a project
20
# Show button labels if there are at most this many file tabs opened.
21
# This is in exports so that an elite user could customize this by doing, e.g.,
22
# require('./editor').SHOW_BUTTON_LABELS=0
23
exports.SHOW_BUTTON_LABELS = 4
24
25
exports.MIN_SPLIT = MIN_SPLIT = 0.02
26
exports.MAX_SPLIT = MAX_SPLIT = 0.98 # maximum pane split proportion for editing
27
28
TOOLTIP_DELAY = delay: {show: 500, hide: 100}
29
30
async = require('async')
31
32
message = require('@cocalc/util/message')
33
34
{redux} = require('./app-framework')
35
36
_ = underscore = require('underscore')
37
38
{webapp_client} = require('./webapp-client')
39
{EventEmitter} = require('events')
40
{alert_message} = require('./alerts')
41
{ appBasePath } = require("@cocalc/frontend/customize/app-base-path");
42
43
feature = require('./feature')
44
IS_MOBILE = feature.IS_MOBILE
45
46
misc = require('@cocalc/util/misc')
47
{drag_start_iframe_disable, drag_stop_iframe_enable, sagews_canonical_mode} = require('./misc')
48
49
# Ensure CodeMirror is available and configured
50
CodeMirror = require("codemirror")
51
52
# SMELL: undo doing the import below -- just use misc.[stuff] is more readable.
53
{copy, trunc, from_json, to_json, keys, defaults, required, filename_extension, filename_extension_notilde,
54
len, path_split, uuid} = require('@cocalc/util/misc')
55
56
syncdoc = require('./syncdoc')
57
sagews = require('./sagews/sagews')
58
printing = require('./printing')
59
60
{file_nonzero_size} = require('./project/utils')
61
62
copypaste = require('./copy-paste-buffer')
63
64
extra_alt_keys = (extraKeys, editor, opts) ->
65
misc.merge extraKeys,
66
"Shift-Alt-L" : (cm) => cm.align_assignments()
67
'Alt-Z' : (cm) => cm.undo()
68
'Shift-Alt-Z' : (cm) => cm.redo()
69
'Alt-A' : (cm) => cm.execCommand('selectAll')
70
'Shift-Alt-A' : (cm) => cm.execCommand('selectAll')
71
'Alt-K' : (cm) => cm.execCommand('killLine')
72
'Alt-D' : (cm) => cm.execCommand('selectNextOccurrence')
73
'Alt-F' : (cm) => cm.execCommand('find')
74
'Shift-Alt-F' : (cm) => cm.execCommand('replace')
75
'Shift-Alt-R' : (cm) => cm.execCommand('replaceAll')
76
'Shift-Alt-D' : (cm) => cm.execCommand('duplicateLine')
77
'Alt-G' : (cm) => cm.execCommand('findNext')
78
'Shift-Alt-G' : (cm) => cm.execCommand('findPrev')
79
'Alt-Up' : (cm) => cm.execCommand('goPageUp')
80
'Alt-Down' : (cm) => cm.execCommand('goPageDown')
81
'Alt-K' : (cm) => cm.execCommand('goPageUp')
82
'Alt-J' : (cm) => cm.execCommand('goPageDown')
83
'Alt-P' : (cm) => cm.execCommand('goLineUp')
84
'Alt-N' : (cm) => cm.execCommand('goLineDown')
85
86
if editor?.goto_line?
87
extraKeys['Alt-L'] = (cm) => editor.goto_line(cm)
88
if editor?.toggle_split_view?
89
extraKeys['Alt-I'] = (cm) => editor.toggle_split_view(cm)
90
if editor?.copy?
91
extraKeys['Alt-C'] = (cm) => editor.copy(cm) # gets overwritten for vim mode, of course
92
else
93
extraKeys['Alt-C'] = (cm) => copypaste.set_buffer(cm.getSelection())
94
95
if editor?.cut?
96
extraKeys['Alt-X'] = (cm) => editor.cut(cm)
97
else
98
extraKeys['Alt-X'] = (cm) =>
99
copypaste.set_buffer(cm.getSelection())
100
cm.replaceSelection('')
101
if editor?.paste?
102
extraKeys['Alt-V'] = (cm) => editor.paste(cm)
103
else
104
extraKeys['Alt-V'] = (cm) => cm.replaceSelection(copypaste.get_buffer())
105
if editor?.click_save_button?
106
extraKeys['Alt-S'] = (cm) => editor.click_save_button()
107
else if editor?.save?
108
extraKeys['Alt-S'] = (cm) => editor.save()
109
110
if opts.bindings == 'vim'
111
# An additional key to get to visual mode in vim (added for ipad Smart Keyboard)
112
extraKeys["Alt-C"] = (cm) =>
113
CodeMirror.Vim.exitInsertMode(cm)
114
extraKeys["Alt-F"] = (cm) =>
115
cm.execCommand('goPageDown')
116
extraKeys["Alt-B"] = (cm) =>
117
cm.execCommand('goPageUp')
118
119
120
{file_associations, VIDEO_EXTS} = require('./file-associations')
121
122
file_nonzero_size_cb = (project_id, path, cb) =>
123
try
124
if not await file_nonzero_size(project_id, path)
125
cb("Unable to convert file to PDF")
126
else
127
cb()
128
catch err
129
cb(err)
130
131
132
initialize_new_file_type_list = () ->
133
file_types_so_far = {}
134
v = misc.keys(file_associations)
135
v.sort()
136
f = (elt, ext, exclude) ->
137
if not ext
138
return
139
data = file_associations[ext]
140
if exclude and data.exclude_from_menu
141
return
142
if data.name? and not file_types_so_far[data.name]
143
file_types_so_far[data.name] = true
144
e = $("<li><a href='#new-file' data-ext='#{ext}'><i style='width: 18px;' class='fa #{data.icon}'></i> <span style='text-transform:capitalize'>#{data.name} </span> <span class='lighten'>(.#{ext})</span></a></li>")
145
elt.append(e)
146
147
elt = $(".smc-new-file-type-list")
148
for ext in v
149
f(elt, ext, true)
150
151
elt = $(".smc-mini-new-file-type-list")
152
file_types_so_far = {}
153
for ext in ['sagews', 'term', 'ipynb', 'tex', 'md', 'tasks', 'course', 'sage', 'py']
154
f(elt, ext)
155
elt.append($("<li class='divider'></li><li><a href='#new-folder'><i style='width: 18px;' class='fa fa-folder'></i> <span>Folder </span></a></li>"))
156
157
elt.append($("<li class='divider'></li><li><a href='#projects-add-collaborators'><i style='width: 18px;' class='fa fa-user'></i> <span>Collaborators... </span></a></li>"))
158
159
initialize_new_file_type_list()
160
161
exports.file_icon_class = file_icon_class = (ext) ->
162
assoc = exports.file_options('x.' + ext)
163
return assoc.icon
164
165
# This defines a bunch of custom modes and gets some info about special case of sagews
166
{sagews_decorator_modes} = require('./codemirror/custom-modes')
167
168
exports.file_options = require("./editor-tmp").file_options
169
170
# old "local storage" code was here, now moved to TS
171
editor_local_storage = require('./editor-local-storage')
172
exports.local_storage_delete = editor_local_storage.local_storage_delete
173
exports.local_storage = editor_local_storage.local_storage
174
local_storage = exports.local_storage # used below
175
176
###############################################
177
# Abstract base class for editors (not exports.Editor)
178
###############################################
179
# Derived classes must:
180
# (1) implement the _get and _set methods
181
# (2) show/hide/remove
182
#
183
# Events ensure that *all* users editor the same file see the same
184
# thing (synchronized).
185
#
186
187
class FileEditor extends EventEmitter
188
# ATTN it is crucial to call this constructor in subclasses via super(@project_id, @filename)
189
constructor: (project_id, filename) ->
190
super()
191
@project_id = project_id
192
@filename = filename
193
@ext = misc.filename_extension_notilde(@filename)?.toLowerCase()
194
@_show = underscore.debounce(@_show, 50)
195
196
is_active: () =>
197
misc.tab_to_path(redux.getProjectStore(@project_id).get('active_project_tab')) == @filename
198
199
# call it, to set the @default_font_size from the account settings
200
init_font_size: () =>
201
@default_font_size = redux.getStore('account').get('font_size')
202
203
val: (content) =>
204
if not content?
205
# If content not defined, returns current value.
206
return @_get()
207
else
208
# If content is defined, sets value.
209
@_set(content)
210
211
# has_unsaved_changes() returns the state, where true means that
212
# there are unsaved changed. To set the state, do
213
# has_unsaved_changes(true or false).
214
has_unsaved_changes: (val) =>
215
if not val?
216
return @_has_unsaved_changes
217
else
218
if not @_has_unsaved_changes? or @_has_unsaved_changes != val
219
if val
220
@save_button.removeClass('disabled')
221
else
222
@_when_had_no_unsaved_changes = new Date() # when we last knew for a fact there are no unsaved changes
223
@save_button.addClass('disabled')
224
@_has_unsaved_changes = val
225
226
# committed means "not saved to the database/server", whereas save above
227
# means "saved to *disk*".
228
has_uncommitted_changes: (val) =>
229
if not val?
230
return @_has_uncommitted_changes
231
else
232
@_has_uncommitted_changes = val
233
if val
234
if not @_show_uncommitted_warning_timeout?
235
# We have not already started a timer, so start one -- if we do not hear otherwise, show
236
# the warning in 30s.
237
@_show_uncommitted_warning_timeout = setTimeout((()=>@_show_uncommitted_warning()), 30000)
238
else
239
if @_show_uncommitted_warning_timeout?
240
clearTimeout(@_show_uncommitted_warning_timeout)
241
delete @_show_uncommitted_warning_timeout
242
@uncommitted_element?.hide()
243
244
_show_uncommitted_warning: () =>
245
delete @_show_uncommitted_warning_timeout
246
@uncommitted_element?.show()
247
248
focus: () => # FUTURE in derived class (???)
249
250
_get: () =>
251
console.warn("Incomplete: editor -- needs to implement _get in derived class")
252
253
_set: (content) =>
254
console.warn("Incomplete: editor -- needs to implement _set in derived class")
255
256
restore_cursor_position: () =>
257
# implement in a derived class if you need this
258
259
disconnect_from_session: (cb) =>
260
# implement in a derived class if you need this
261
262
local_storage: (key, value) =>
263
return local_storage(@project_id, @filename, key, value)
264
265
show: (opts) =>
266
if not opts?
267
if @_last_show_opts?
268
opts = @_last_show_opts
269
else
270
opts = {}
271
@_last_show_opts = opts
272
273
# only re-render the editor if it is active. that's crucial, because e.g. the autosave
274
# of latex triggers a build, which in turn calls @show to update itself. that would cause
275
# the latex editor to be visible despite not being the active editor.
276
if not @is_active?()
277
return
278
279
@element.show()
280
# if above line reveals it, give it a bit time to do the layout first
281
@_show(opts) # critical -- also do an initial layout! Otherwise get a horrible messed up animation effect.
282
setTimeout((=> @_show(opts)), 10)
283
if DEBUG
284
window?.smc?.doc = @ # useful for debugging...
285
286
_show: (opts={}) =>
287
# define in derived class
288
289
hide: () =>
290
#@element?.hide()
291
292
remove: () =>
293
@syncdoc?.close()
294
@element?.remove()
295
@removeAllListeners()
296
297
terminate_session: () =>
298
# If some backend session on a remote machine is serving this session, terminate it.
299
300
exports.FileEditor = FileEditor
301
302
###############################################
303
# Codemirror-based File Editor
304
305
# - 'saved' : when the file is successfully saved by the user
306
# - 'show' :
307
# - 'toggle-split-view' :
308
###############################################
309
class CodeMirrorEditor extends FileEditor
310
constructor: (project_id, filename, content, opts) ->
311
super(project_id, filename)
312
313
editor_settings = redux.getStore('account').get_editor_settings()
314
opts = @opts = defaults opts,
315
mode : undefined
316
geometry : undefined # (default=full screen);
317
read_only : false
318
delete_trailing_whitespace: editor_settings.strip_trailing_whitespace # delete on save
319
show_trailing_whitespace : editor_settings.show_trailing_whitespace
320
allow_javascript_eval : true # if false, the one use of eval isn't allowed.
321
line_numbers : editor_settings.line_numbers
322
first_line_number : editor_settings.first_line_number
323
indent_unit : editor_settings.indent_unit
324
tab_size : editor_settings.tab_size
325
smart_indent : editor_settings.smart_indent
326
electric_chars : editor_settings.electric_chars
327
undo_depth : editor_settings.undo_depth # no longer relevant, since done via sync system
328
match_brackets : editor_settings.match_brackets
329
code_folding : editor_settings.code_folding
330
auto_close_brackets : editor_settings.auto_close_brackets
331
match_xml_tags : editor_settings.match_xml_tags
332
auto_close_xml_tags : editor_settings.auto_close_xml_tags
333
line_wrapping : editor_settings.line_wrapping
334
spaces_instead_of_tabs : editor_settings.spaces_instead_of_tabs
335
style_active_line : 15 # editor_settings.style_active_line # (a number between 0 and 127)
336
bindings : editor_settings.bindings # 'standard', 'vim', or 'emacs'
337
theme : editor_settings.theme
338
track_revisions : editor_settings.track_revisions
339
public_access : false
340
latex_editor : false
341
342
# I'm making the times below very small for now. If we have to adjust these to reduce load, due to lack
343
# of capacity, then we will. Or, due to lack of optimization (e.g., for big documents). These parameters
344
# below would break editing a huge file right now, due to slowness of applying a patch to a codemirror editor.
345
346
cursor_interval : 1000 # minimum time (in ms) between sending cursor position info to hub -- used in sync version
347
sync_interval : 500 # minimum time (in ms) between synchronizing text with hub. -- used in sync version below
348
349
completions_size : 20 # for tab completions (when applicable, e.g., for sage sessions)
350
351
#console.log("mode =", opts.mode)
352
353
@element = templates.find(".webapp-editor-codemirror").clone()
354
355
@element.data('editor', @)
356
357
@init_save_button()
358
@init_uncommitted_element()
359
@init_history_button()
360
@init_edit_buttons()
361
362
@init_file_actions()
363
364
filename = @filename
365
if filename.length > 30
366
filename = "…" + filename.slice(filename.length-30)
367
368
# not really needed due to highlighted tab; annoying.
369
#@element.find(".webapp-editor-codemirror-filename").text(filename)
370
371
@show_exec_warning = redux.getStore('account').getIn(['editor_settings', 'show_exec_warning']) ? true
372
if @show_exec_warning and @ext in ['py', 'r', 'sage', 'f90']
373
msg = "<strong>INFO:</strong> you can only run <code>*.#{@ext}</code> files in a terminal or create a worksheet/notebook. <a href='#'>Close</a>"
374
msg_el = @element.find('.webapp-editor-codemirror-message')
375
msg_el.html(msg)
376
msg_el.find('a').click ->
377
msg_el.hide()
378
redux.getTable('account').set(editor_settings:{show_exec_warning:false})
379
380
@_video_is_on = @local_storage("video_is_on")
381
if not @_video_is_on?
382
@_video_is_on = false
383
384
extraKeys =
385
"Alt-Enter" : (editor) => @action_key(execute: true, advance:false, split:false)
386
"Cmd-Enter" : (editor) => @action_key(execute: true, advance:false, split:false)
387
"Ctrl-Enter" : (editor) => @action_key(execute: true, advance:true, split:true)
388
"Ctrl-;" : (editor) => @action_key(split:true, execute:false, advance:false)
389
"Cmd-;" : (editor) => @action_key(split:true, execute:false, advance:false)
390
"Ctrl-\\" : (editor) => @action_key(execute:false, toggle_input:true)
391
#"Cmd-x" : (editor) => @action_key(execute:false, toggle_input:true)
392
"Shift-Ctrl-\\" : (editor) => @action_key(execute:false, toggle_output:true)
393
#"Shift-Cmd-y" : (editor) => @action_key(execute:false, toggle_output:true)
394
395
"Cmd-S" : (editor) => @click_save_button()
396
"Alt-S" : (editor) => @click_save_button()
397
398
"Ctrl-L" : (editor) => @goto_line(editor)
399
"Cmd-L" : (editor) => @goto_line(editor)
400
401
"Shift-Ctrl-I" : (editor) => @toggle_split_view(editor)
402
"Shift-Cmd-I" : (editor) => @toggle_split_view(editor)
403
404
"Shift-Cmd-L" : (editor) => editor.align_assignments()
405
"Shift-Ctrl-L" : (editor) => editor.align_assignments()
406
407
"Shift-Ctrl-." : (editor) => @change_font_size(editor, +1)
408
"Shift-Ctrl-," : (editor) => @change_font_size(editor, -1)
409
410
"Shift-Cmd-." : (editor) => @change_font_size(editor, +1)
411
"Shift-Cmd-," : (editor) => @change_font_size(editor, -1)
412
413
"Shift-Tab" : (editor) => editor.unindent_selection()
414
415
"Ctrl-'" : "indentAuto"
416
"Cmd-'" : "indentAuto"
417
418
"Cmd-/" : "toggleComment"
419
"Ctrl-/" : "toggleComment" # shortcut chosen by jupyter project (undocumented)
420
421
"Tab" : (editor) => @press_tab_key(editor)
422
"Shift-Ctrl-C" : (editor) => @interrupt_key()
423
424
"Ctrl-Space" : "autocomplete"
425
"Alt-Space": "autocomplete"
426
427
if feature.IS_TOUCH
428
# Better more external keyboard friendly shortcuts, motivated by iPad.
429
extra_alt_keys(extraKeys, @, opts)
430
431
if opts.match_xml_tags
432
extraKeys['Ctrl-J'] = "toMatchingTag"
433
434
if opts.bindings != 'emacs'
435
# Emacs uses control s for find.
436
extraKeys["Ctrl-S"] = (editor) => @click_save_button()
437
438
# FUTURE: We will replace this by a general framework...
439
if misc.filename_extension_notilde(filename).toLowerCase() == "sagews"
440
evaluate_key = redux.getStore('account').get('evaluate_key').toLowerCase()
441
if evaluate_key == "enter"
442
evaluate_key = "Enter"
443
else
444
evaluate_key = "Shift-Enter"
445
extraKeys[evaluate_key] = (editor) => @action_key(execute: true, advance:true, split:false)
446
else
447
extraKeys["Shift-Enter"] = =>
448
alert_message
449
type : "error"
450
message : "You can only evaluate code in a file that ends with the extension 'sagews' or 'ipynb'. Create a Sage Worksheet or Jupyter notebook instead."
451
452
# Layouts:
453
# 0 - one single editor
454
# 1 - two editors, one on top of the other
455
# 2 - two editors, one next to the other
456
457
if IS_MOBILE
458
@_layout = 0
459
else
460
@_layout = @local_storage("layout") ? 0 # WARNING/UGLY: used by syncdoc.coffee and sagews.coffee !
461
if @_layout not in [0, 1, 2]
462
# IMPORTANT: If this were anything other than what is listed, the user
463
# would never be able to open tex files. So it's important that this be valid.
464
@_layout = 0
465
@_last_layout = undefined
466
467
if feature.isMobile.Android()
468
# see https://github.com/sragemathinc/smc/issues/1360
469
opts.style_active_line = false
470
471
make_editor = (node) =>
472
options =
473
firstLineNumber : opts.first_line_number
474
autofocus : false
475
mode : {name:opts.mode, globalVars: true}
476
lineNumbers : opts.line_numbers
477
showTrailingSpace : opts.show_trailing_whitespace
478
indentUnit : opts.indent_unit
479
tabSize : opts.tab_size
480
smartIndent : opts.smart_indent
481
electricChars : opts.electric_chars
482
undoDepth : opts.undo_depth
483
matchBrackets : opts.match_brackets
484
autoCloseBrackets : opts.auto_close_brackets and (misc.filename_extension_notilde(filename) not in ['hs', 'lhs']) #972
485
autoCloseTags : opts.auto_close_xml_tags
486
lineWrapping : opts.line_wrapping
487
readOnly : opts.read_only
488
styleActiveLine : opts.style_active_line
489
indentWithTabs : not opts.spaces_instead_of_tabs
490
showCursorWhenSelecting : true
491
extraKeys : extraKeys
492
cursorScrollMargin : 6
493
viewportMargin : Infinity # larger than the default of 10 specifically so *sage worksheets* (which are the only thing that uses this)
494
# don't feel jumpy when re-rendering output.
495
# NOTE that in cocalc right now, no remaining non-sagews editors use this code.
496
# with images, even using a viewport of 300 causes major jumpiness problems with images.
497
# See https://github.com/sagemathinc/cocalc/issues/7654
498
# Browser caching may have changed in newer browsers as well making 300 feel jumpy in "modern times".
499
500
if opts.match_xml_tags
501
options.matchTags = {bothTags: true}
502
503
if opts.code_folding
504
extraKeys["Ctrl-Q"] = (cm) -> cm.foldCodeSelectionAware()
505
extraKeys["Alt-Q"] = (cm) -> cm.foldCodeSelectionAware()
506
options.foldGutter = true
507
options.gutters = ["CodeMirror-linenumbers", "CodeMirror-foldgutter"]
508
509
if opts.latex_editor
510
options.gutters ?= []
511
options.gutters.push("Codemirror-latex-errors")
512
513
if opts.bindings? and opts.bindings != "standard"
514
options.keyMap = opts.bindings
515
#cursorBlinkRate: 1000
516
517
if opts.theme? and opts.theme != "standard"
518
options.theme = opts.theme
519
520
cm = CodeMirror.fromTextArea(node, options)
521
cm.save = () => @click_save_button()
522
523
# The Codemirror themes impose their own weird fonts, but most users want whatever
524
# they've configured as "monospace" in their browser. So we force that back:
525
e = $(cm.getWrapperElement())
526
e.attr('style', e.attr('style') + '; height:100%; font-family:monospace !important;')
527
# see http://stackoverflow.com/questions/2655925/apply-important-css-style-using-jquery
528
529
if opts.bindings == 'vim'
530
# annoying due to api change in vim mode
531
cm.setOption("vimMode", true)
532
533
return cm
534
535
elt = @element.find(".webapp-editor-textarea-0"); elt.text(content)
536
537
@codemirror = make_editor(elt[0])
538
@codemirror.name = '0'
539
#window.cm = @codemirror
540
541
elt1 = @element.find(".webapp-editor-textarea-1")
542
543
@codemirror1 = make_editor(elt1[0])
544
@codemirror1.name = '1'
545
546
buf = @codemirror.linkedDoc({sharedHist: true})
547
@codemirror1.swapDoc(buf)
548
549
@codemirror.on 'focus', () =>
550
@codemirror_with_last_focus = @codemirror
551
552
@codemirror1.on 'focus', () =>
553
@codemirror_with_last_focus = @codemirror1
554
555
if @opts.bindings == 'vim'
556
@_vim_mode = 'visual'
557
@codemirror.on 'vim-mode-change', (obj) =>
558
if obj.mode == 'normal'
559
@_vim_mode = 'visual'
560
@element.find("a[href='#vim-mode-toggle']").text('esc')
561
else
562
@_vim_mode = 'insert'
563
@element.find("a[href='#vim-mode-toggle']").text('i')
564
565
if feature.IS_TOUCH
566
# ugly hack so more usable on touch...
567
@element.find(".webapp-editor-resize-bar-layout-1").height('12px')
568
@element.find(".webapp-editor-resize-bar-layout-2").width('12px')
569
570
@init_font_size() # get the @default_font_size
571
@restore_font_size()
572
573
@init_draggable_splits()
574
575
if opts.read_only
576
@set_readonly_ui()
577
578
if misc.filename_extension(@filename)?.toLowerCase() == 'sagews'
579
@init_sagews_edit_buttons()
580
581
@snippets_dialog = null
582
# Render all icons using React.
583
@element.processIcons()
584
585
programmatical_goto_line: (line) =>
586
cm = @codemirror_with_last_focus
587
return if not cm?
588
pos = {line:line-1, ch:0}
589
info = cm.getScrollInfo()
590
cm.scrollIntoView(pos, info.clientHeight/2)
591
592
get_users_cursors: (account_id) =>
593
return @syncdoc?.get_users_cursors(account_id)
594
595
init_file_actions: () =>
596
if not @element?
597
return
598
dom_node = @element.find('.smc-editor-file-info-dropdown')[0]
599
require('./editors/file-info-dropdown').render_file_info_dropdown(@filename, @project_id, dom_node, @opts.public_access)
600
601
init_draggable_splits: () =>
602
@_layout1_split_pos = @local_storage("layout1_split_pos")
603
@_layout2_split_pos = @local_storage("layout2_split_pos")
604
605
layout1_bar = @element.find(".webapp-editor-resize-bar-layout-1")
606
layout1_bar.draggable
607
axis : 'y'
608
containment : @element
609
zIndex : 10
610
start : drag_start_iframe_disable
611
stop : (event, ui) =>
612
drag_stop_iframe_enable()
613
# compute the position of bar as a number from 0 to 1, with
614
# 0 being at top (left), 1 at bottom (right), and .5 right in the middle
615
e = @element.find(".webapp-editor-codemirror-input-container-layout-1")
616
top = e.offset().top
617
ht = e.height()
618
p = layout1_bar.offset().top + layout1_bar.height()/2
619
@_layout1_split_pos = (p - top) / ht
620
@local_storage("layout1_split_pos", @_layout1_split_pos)
621
# redraw, which uses split info
622
@show()
623
624
layout2_bar = @element.find(".webapp-editor-resize-bar-layout-2")
625
layout2_bar.draggable
626
axis : 'x'
627
containment : @element
628
zIndex : 100
629
start : drag_start_iframe_disable
630
stop : (event, ui) =>
631
drag_stop_iframe_enable()
632
# compute the position of bar as a number from 0 to 1, with
633
# 0 being at top (left), 1 at bottom (right), and .5 right in the middle
634
e = @element.find(".webapp-editor-codemirror-input-container-layout-2")
635
left = e.offset().left
636
width = e.width()
637
p = layout2_bar.offset().left
638
@_layout2_split_pos = (p - left) / width
639
@local_storage("layout2_split_pos", @_layout2_split_pos)
640
# redraw, which uses split info
641
@show()
642
643
hide_content: () =>
644
@element.find(".webapp-editor-codemirror-content").hide()
645
646
show_content: () =>
647
@hide_startup_message()
648
@element.find(".webapp-editor-codemirror-content").show()
649
for cm in @codemirrors()
650
cm_refresh(cm)
651
652
hide_startup_message: () =>
653
@element.find(".webapp-editor-codemirror-startup-message").hide()
654
655
show_startup_message: (mesg, type='info') =>
656
@hide_content()
657
if typeof(mesg) != 'string'
658
mesg = JSON.stringify(mesg)
659
e = @element.find(".webapp-editor-codemirror-startup-message").show().text(mesg)
660
for t in ['success', 'info', 'warning', 'danger']
661
e.removeClass("alert-#{t}")
662
e.addClass("alert-#{type}")
663
664
is_active: () =>
665
return @codemirror? and misc.tab_to_path(redux.getProjectStore(@project_id).get('active_project_tab')) == @filename
666
667
set_theme: (theme) =>
668
# Change the editor theme after the editor has been created
669
for cm in @codemirrors()
670
cm.setOption('theme', theme)
671
@opts.theme = theme
672
673
# add something visual to the UI to suggest that the file is read only
674
set_readonly_ui: (readonly=true) =>
675
@opts.read_only = readonly
676
@element.find(".webapp-editor-write-only").toggle(!readonly)
677
@element.find(".webapp-editor-read-only").toggle(readonly)
678
for cm in @codemirrors()
679
cm.setOption('readOnly', readonly)
680
681
set_cursor_center_focus: (pos, tries=5) =>
682
if tries <= 0
683
return
684
cm = @codemirror_with_last_focus
685
if not cm?
686
cm = @codemirror
687
if not cm?
688
return
689
cm.setCursor(pos)
690
info = cm.getScrollInfo()
691
try
692
# This call can fail during editor initialization (as of codemirror 3.19, but not before).
693
cm.scrollIntoView(pos, info.clientHeight/2)
694
catch e
695
setTimeout((() => @set_cursor_center_focus(pos, tries-1)), 250)
696
cm.focus()
697
698
disconnect_from_session: (cb) =>
699
# implement in a derived class if you need this
700
@syncdoc?.disconnect_from_session()
701
cb?()
702
703
codemirrors: () =>
704
c = [@codemirror, @codemirror1]
705
return underscore.filter(c, ((x) -> x?))
706
707
focused_codemirror: () =>
708
if @codemirror_with_last_focus?
709
return @codemirror_with_last_focus
710
else
711
return @codemirror
712
713
action_key: (opts) =>
714
# opts ignored by default; worksheets use them....
715
@click_save_button()
716
717
interrupt_key: () =>
718
# does nothing for generic editor, but important, e.g., for the sage worksheet editor.
719
720
press_tab_key: (editor) =>
721
if editor.somethingSelected()
722
CodeMirror.commands.defaultTab(editor)
723
else
724
@tab_nothing_selected(editor)
725
726
tab_nothing_selected: (editor) =>
727
if @opts.spaces_instead_of_tabs
728
editor.tab_as_space()
729
else
730
CodeMirror.commands.defaultTab(editor)
731
732
init_edit_buttons: () =>
733
that = @
734
button_names = ['search', 'next', 'prev', 'replace', 'undo', 'redo', 'autoindent',
735
'shift-left', 'shift-right', 'split-view','increase-font', 'decrease-font', 'goto-line',
736
'copy', 'paste', 'vim-mode-toggle']
737
738
if @opts.bindings != 'vim'
739
@element.find("a[href='#vim-mode-toggle']").remove()
740
741
# if the file extension indicates that we know how to print it, show and enable the print button
742
if printing.can_print(@ext)
743
button_names.push('print')
744
else
745
@element.find('a[href="#print"]').remove()
746
747
# sagews2pdf conversion
748
if @ext == 'sagews'
749
button_names.push('sagews2pdf')
750
button_names.push('sagews2ipynb')
751
else
752
@element.find('a[href="#sagews2pdf"]').remove()
753
@element.find('a[href="#sagews2ipynb"]').remove()
754
755
for name in button_names
756
e = @element.find("a[href=\"##{name}\"]")
757
e.data('name', name).tooltip(delay:{ show: 500, hide: 100 }).click (event) ->
758
that.click_edit_button($(@).data('name'))
759
return false
760
761
click_edit_button: (name) =>
762
cm = @codemirror_with_last_focus
763
if not cm?
764
cm = @codemirror
765
if not cm?
766
return
767
switch name
768
when 'search'
769
CodeMirror.commands.find(cm)
770
when 'next'
771
if cm._searchState?.query
772
CodeMirror.commands.findNext(cm)
773
else
774
CodeMirror.commands.goPageDown(cm)
775
cm.focus()
776
when 'prev'
777
if cm._searchState?.query
778
CodeMirror.commands.findPrev(cm)
779
else
780
CodeMirror.commands.goPageUp(cm)
781
cm.focus()
782
when 'replace'
783
CodeMirror.commands.replace(cm)
784
when 'undo'
785
cm.undo()
786
cm.focus()
787
when 'redo'
788
cm.redo()
789
cm.focus()
790
when 'split-view'
791
@toggle_split_view(cm)
792
when 'autoindent'
793
CodeMirror.commands.indentAuto(cm)
794
when 'shift-left'
795
cm.unindent_selection()
796
cm.focus()
797
when 'shift-right'
798
@press_tab_key(cm)
799
cm.focus()
800
when 'increase-font'
801
@change_font_size(cm, +1)
802
cm.focus()
803
when 'decrease-font'
804
@change_font_size(cm, -1)
805
cm.focus()
806
when 'goto-line'
807
@goto_line(cm)
808
when 'copy'
809
@copy(cm)
810
cm.focus()
811
when 'paste'
812
@paste(cm)
813
cm.focus()
814
when 'sagews2pdf'
815
@print(sagews2html = false)
816
when 'sagews2ipynb'
817
@convert_to_ipynb()
818
when 'print'
819
@print(sagews2html = true)
820
when 'vim-mode-toggle'
821
if @_vim_mode == 'visual'
822
CodeMirror.Vim.handleKey(cm, 'i')
823
else
824
CodeMirror.Vim.exitInsertMode(cm)
825
cm.focus()
826
827
restore_font_size: () =>
828
# we set the font_size from local storage
829
# or fall back to the default from the account settings
830
for i, cm of @codemirrors()
831
size = @local_storage("font_size#{i}")
832
if size?
833
@set_font_size(cm, size)
834
else if @default_font_size?
835
@set_font_size(cm, @default_font_size)
836
837
get_font_size: (cm) ->
838
if not cm?
839
return
840
elt = $(cm.getWrapperElement())
841
return elt.data('font-size') ? @default_font_size
842
843
set_font_size: (cm, size) =>
844
if not cm?
845
return
846
if size > 1
847
elt = $(cm.getWrapperElement())
848
elt.css('font-size', size + 'px')
849
elt.data('font-size', size)
850
851
change_font_size: (cm, delta) =>
852
if not cm?
853
return
854
#console.log("change_font_size #{cm.name}, #{delta}")
855
scroll_before = cm.getScrollInfo()
856
857
elt = $(cm.getWrapperElement())
858
size = elt.data('font-size')
859
if not size?
860
s = elt.css('font-size')
861
size = parseInt(s.slice(0,s.length-2))
862
new_size = size + delta
863
@set_font_size(cm, new_size)
864
@local_storage("font_size#{cm.name}", new_size)
865
866
# we have to do the scrollTo in the next render loop, since otherwise
867
# the getScrollInfo function below will return the sizing data about
868
# the cm instance before the above css font-size change has been rendered.
869
f = () =>
870
cm_refresh(cm)
871
scroll_after = cm.getScrollInfo()
872
x = (scroll_before.left / scroll_before.width) * scroll_after.width
873
y = (((scroll_before.top+scroll_before.clientHeight/2) / scroll_before.height) * scroll_after.height) - scroll_after.clientHeight/2
874
cm.scrollTo(x, y)
875
setTimeout(f, 0)
876
877
toggle_split_view: (cm) =>
878
if not cm?
879
return
880
@_layout = (@_layout + 1) % 3
881
@local_storage("layout", @_layout)
882
@show()
883
if cm? and not feature.IS_TOUCH
884
if @_layout > 0
885
cm.focus()
886
else
887
# focus first editor since it is only one that is visible.
888
@codemirror.focus()
889
f = () =>
890
for x in @codemirrors()
891
x.scrollIntoView() # scroll the cursors back into view -- see https://github.com/sagemathinc/cocalc/issues/1044
892
setTimeout(f, 1) # wait until next loop after codemirror has laid itself out.
893
@emit 'toggle-split-view'
894
895
goto_line: (cm) =>
896
if not cm?
897
return
898
focus = () =>
899
@focus()
900
cm.focus()
901
dialog = templates.find(".webapp-goto-line-dialog").clone()
902
dialog.modal('show')
903
dialog.find(".btn-close").off('click').click () ->
904
dialog.modal('hide')
905
setTimeout(focus, 50)
906
return false
907
input = dialog.find(".webapp-goto-line-input")
908
input.val(cm.getCursor().line+1) # +1 since line is 0-based
909
dialog.find(".webapp-goto-line-range").text("1-#{cm.lineCount()} or n%")
910
dialog.find(".webapp-goto-line-input").focus().select()
911
submit = () =>
912
dialog.modal('hide')
913
result = input.val().trim()
914
if result.length >= 1 and result[result.length-1] == '%'
915
line = Math.floor( cm.lineCount() * parseInt(result.slice(0,result.length-1)) / 100.0)
916
else
917
line = Math.min(parseInt(result)-1)
918
if line >= cm.lineCount()
919
line = cm.lineCount() - 1
920
if line <= 0
921
line = 0
922
pos = {line:line, ch:0}
923
cm.setCursor(pos)
924
info = cm.getScrollInfo()
925
cm.scrollIntoView(pos, info.clientHeight/2)
926
setTimeout(focus, 50)
927
dialog.find(".btn-submit").off('click').click(submit)
928
input.keydown (evt) =>
929
if evt.which == 13 # enter
930
submit()
931
return false
932
if evt.which == 27 # escape
933
setTimeout(focus, 50)
934
dialog.modal('hide')
935
return false
936
937
copy: (cm) =>
938
if not cm?
939
return
940
copypaste.set_buffer(cm.getSelection())
941
942
convert_to_ipynb: () =>
943
p = misc.path_split(@filename)
944
v = p.tail.split('.')
945
if v.length <= 1
946
ext = ''
947
base = p.tail
948
else
949
ext = v[v.length-1]
950
base = v.slice(0,v.length-1).join('.')
951
952
if ext != 'sagews'
953
console.error("editor.print called on file with extension '#{ext}' but only supports 'sagews'.")
954
return
955
956
async.series([
957
(cb) =>
958
@save(cb)
959
(cb) =>
960
webapp_client.exec
961
project_id : @project_id
962
command : "cc-sagews2ipynb"
963
args : [@filename]
964
err_on_exit : true
965
cb : (err, output) =>
966
if err
967
alert_message(type:"error", message:"Error occured converting '#{@filename}' -- #{err}")
968
else
969
path = base + '.ipynb'
970
if p.head
971
path = p.head + '/' + path
972
redux.getProjectActions(@project_id).open_file
973
path : path
974
foreground : true
975
])
976
977
cut: (cm) =>
978
if not cm?
979
return
980
copypaste.set_buffer(cm.getSelection())
981
cm.replaceSelection('')
982
983
paste: (cm) =>
984
if not cm?
985
return
986
cm.replaceSelection(copypaste.get_buffer())
987
988
print: (sagews2html = true) =>
989
switch @ext
990
when 'sagews'
991
if sagews2html
992
@print_html()
993
else
994
@print_sagews()
995
when 'txt', 'csv'
996
print_button = @element.find('a[href="#print"]')
997
print_button.icon_spin(start:true, delay:0).addClass("disabled")
998
printing.Printer(@, @filename + '.pdf').print (err) ->
999
print_button.removeClass('disabled')
1000
print_button.icon_spin(false)
1001
if err
1002
alert_message
1003
type : "error"
1004
message : "Printing error -- #{err}"
1005
1006
print_html: =>
1007
dialog = null
1008
d_content = null
1009
d_open = null
1010
d_download = null
1011
d_progress = _.noop
1012
output_fn = null # set this before showing the dialog
1013
1014
show_dialog = (cb) =>
1015
# this creates the dialog element and defines the action functions like d_progress
1016
dialog = $("""
1017
<div class="modal" tabindex="-1" role="dialog">
1018
<div class="modal-dialog" role="document">
1019
<div class="modal-content">
1020
<div class="modal-header">
1021
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
1022
<h4 class="modal-title">Print to HTML</h4>
1023
</div>
1024
<div class="modal-body">
1025
<div class="progress">
1026
<div class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;">
1027
0 %
1028
</div>
1029
</div>
1030
<div class="content" style="text-align: center;"></div>
1031
<div style="margin-top: 25px;">
1032
<p><b>More information</b></p>
1033
<p>
1034
This SageWS to HTML conversion transforms the current worksheet
1035
to a static HTML file.
1036
<br/>
1037
<a href="https://github.com/sagemathinc/cocalc/wiki/sagews2html" target='_blank'>Click here for more information</a>.
1038
</p>
1039
</div>
1040
</div>
1041
<div class="modal-footer">
1042
<button type="button" class="btn-download btn btn-primary disabled">Download</button>
1043
<button type="button" class="btn-open btn btn-success disabled">Open</button>
1044
<button type="button" class="btn-close btn btn-default" data-dismiss="modal">Close</button>
1045
</div>
1046
</div>
1047
</div>
1048
</div>
1049
""")
1050
d_content = dialog.find('.content')
1051
d_open = dialog.find('.btn-open')
1052
d_download = dialog.find('.btn-download')
1053
action = redux.getProjectActions(@project_id)
1054
d_progress = (p) ->
1055
pct = "#{Math.round(100 * p)}%"
1056
dialog.find(".progress-bar").css('width', pct).text(pct)
1057
dialog.find('.btn-close').click ->
1058
dialog.modal('hide')
1059
return false
1060
d_open.click =>
1061
action.download_file
1062
path : output_fn
1063
auto : false # open in new tab
1064
d_download.click =>
1065
action.download_file
1066
path : output_fn
1067
auto : true
1068
dialog.modal('show')
1069
cb()
1070
1071
convert = (cb) =>
1072
# initiates the actual conversion via printing.Printer ...
1073
switch @ext
1074
when 'sagews'
1075
output_fn = @filename + '.html'
1076
progress = (percent, mesg) =>
1077
d_content.text(mesg)
1078
d_progress(percent)
1079
progress = _.debounce(progress, 5)
1080
progress(.01, "Loading ...")
1081
done = (err) =>
1082
#console.log 'Printer.print_html is done: err = ', err
1083
if err
1084
progress(0, "Problem printing to HTML: #{err}")
1085
else
1086
progress(1, 'Printing finished.')
1087
# enable open & download buttons
1088
dialog.find('button.btn').removeClass('disabled')
1089
printing.Printer(@, output_fn).print(done, progress)
1090
cb(); return
1091
1092
# fallback
1093
cb("err -- unable to convert files with extension '@ext'")
1094
1095
async.series([show_dialog, convert], (err) =>
1096
if err
1097
msg = "problem printing -- #{err.message}"
1098
alert_message
1099
type : "error"
1100
message : msg
1101
dialog.content.text(msg)
1102
)
1103
1104
# WARNING: this "print" is actually for printing Sage worksheets, not arbitrary files.
1105
print_sagews: =>
1106
dialog = templates.find(".webapp-file-print-dialog").clone()
1107
dialog.processIcons()
1108
p = misc.path_split(@filename)
1109
v = p.tail.split('.')
1110
if v.length <= 1
1111
ext = ''
1112
base = p.tail
1113
else
1114
ext = v[v.length-1]
1115
base = v.slice(0,v.length-1).join('.')
1116
1117
ext = ext.toLowerCase()
1118
if ext != 'sagews'
1119
console.error("editor.print called on file with extension '#{ext}' but only supports 'sagews'.")
1120
return
1121
1122
submit = () =>
1123
dialog.find(".webapp-file-printing-progress").show()
1124
dialog.find(".webapp-file-printing-link").hide()
1125
$print_tempdir = dialog.find(".smc-file-printing-tempdir")
1126
$print_tempdir.hide()
1127
is_subdir = dialog.find(".webapp-file-print-keepfiles").is(":checked")
1128
dialog.find(".btn-submit").icon_spin(start:true)
1129
pdf = undefined
1130
async.series([
1131
(cb) =>
1132
@save(cb)
1133
(cb) =>
1134
# get info from the UI and attempt to convert the sagews to pdf
1135
options =
1136
title : dialog.find(".webapp-file-print-title").text()
1137
author : dialog.find(".webapp-file-print-author").text()
1138
date : dialog.find(".webapp-file-print-date").text()
1139
contents : dialog.find(".webapp-file-print-contents").is(":checked")
1140
subdir : is_subdir
1141
base_url : require('./misc').BASE_URL # really is a base url (not base path)
1142
extra_data : misc.to_json(@syncdoc.print_to_pdf_data()) # avoid de/re-json'ing
1143
1144
printing.Printer(@, @filename + '.pdf').print
1145
project_id : @project_id
1146
path : @filename
1147
options : options
1148
cb : (err, _pdf) =>
1149
if err and not is_subdir
1150
cb(err)
1151
else
1152
pdf = _pdf
1153
cb()
1154
(cb) =>
1155
file_nonzero_size_cb(@project_id, pdf, cb)
1156
(cb) =>
1157
if is_subdir or not pdf?
1158
cb(); return
1159
# pdf file exists -- show it in the UI
1160
url = webapp_client.project_client.read_file
1161
project_id : @project_id
1162
path : pdf
1163
dialog.find(".webapp-file-printing-link").attr('href', url).text(pdf).show()
1164
cb()
1165
(cb) =>
1166
if not is_subdir
1167
cb(); return
1168
{join} = require('path')
1169
subdir_texfile = join(p.head, "#{base}-sagews2pdf", "tmp.tex")
1170
# check if generated tmp.tex exists and has nonzero size
1171
file_nonzero_size_cb @project_id, subdir_texfile, (err) =>
1172
if err
1173
cb("Unable to create directory of temporary Latex files. -- #{err}")
1174
return
1175
tempdir_link = $('<a>').text('Click to open temporary file')
1176
tempdir_link.click =>
1177
redux.getProjectActions(@project_id).open_file
1178
path : subdir_texfile
1179
foreground : true
1180
dialog.modal('hide')
1181
return false
1182
$print_tempdir.html(tempdir_link)
1183
$print_tempdir.show()
1184
cb()
1185
(cb) =>
1186
# if there is no subdirectory of temporary files, print generated pdf file
1187
if not is_subdir
1188
redux.getProjectActions(@project_id).print_file(path: pdf)
1189
cb()
1190
], (err) =>
1191
dialog.find(".btn-submit").icon_spin(false)
1192
dialog.find(".webapp-file-printing-progress").hide()
1193
if err
1194
alert_message(type:"error", message:"problem printing '#{p.tail}' -- #{err.message}")
1195
)
1196
return false
1197
1198
dialog.find(".webapp-file-print-filename").text(@filename)
1199
dialog.find(".webapp-file-print-title").text(base)
1200
dialog.find(".webapp-file-print-author").text(redux.getStore('account').get_fullname())
1201
dialog.find(".webapp-file-print-date").text((new Date()).toLocaleDateString())
1202
dialog.find(".btn-submit").click(submit)
1203
dialog.find(".btn-close").click(() -> dialog.modal('hide'); return false)
1204
if ext == "sagews"
1205
dialog.find(".webapp-file-options-sagews").show()
1206
dialog.modal('show')
1207
1208
init_save_button: () =>
1209
@save_button = @element.find("a[href=\"#save\"]").tooltip().click(@click_save_button)
1210
@save_button.find(".spinner").hide()
1211
1212
init_uncommitted_element: () =>
1213
@uncommitted_element = @element.find(".smc-uncommitted")
1214
1215
init_history_button: () =>
1216
if not @opts.public_access and @filename.slice(@filename.length-13) != '.sage-history'
1217
@history_button = @element.find(".webapp-editor-history-button")
1218
@history_button.click(@click_history_button)
1219
@history_button.show()
1220
@history_button.css
1221
display: 'inline-block' # this is needed due to subtleties of jQuery show().
1222
1223
click_save_button: () =>
1224
if @opts.read_only
1225
return
1226
if not @save? # not implemented...
1227
return
1228
if @_saving
1229
return
1230
@_saving = true
1231
@syncdoc?.delete_trailing_whitespace?() # only delete trailing whitespace on explicit save -- never on AUTOSAVE.
1232
@save_button.icon_spin(start:true, delay:8000)
1233
@save (err) =>
1234
# WARNING: As far as I can tell, this doesn't call FileEditor.save
1235
if err
1236
if redux.getProjectStore(@project_id).is_file_open(@filename) # only show error if file actually opened
1237
alert_message(type:"error", message:"Error saving '#{@filename}' (#{err}) -- (you might need to close and open this file or restart this project)")
1238
else
1239
@emit('saved')
1240
@save_button.icon_spin(false)
1241
@_saving = false
1242
return false
1243
1244
click_history_button: () =>
1245
redux.getProjectActions(@project_id).open_file
1246
path : misc.history_path(@filename)
1247
foreground : true
1248
1249
_get: () =>
1250
return @codemirror?.getValue()
1251
1252
_set: (content) =>
1253
if not @codemirror?
1254
# document is already closed and freed up.
1255
return
1256
{from} = @codemirror.getViewport()
1257
@codemirror.setValue(content)
1258
@codemirror.scrollIntoView(from)
1259
# even better -- fully restore cursors, if available in localStorage
1260
setTimeout((()=>@restore_cursor_position()),1) # do in next round, so that both editors get set by codemirror first (including the linked one)
1261
1262
# save/restore view state -- hooks used by React editor wrapper.
1263
save_view_state: =>
1264
state =
1265
scroll : (cm.getScrollInfo() for cm in @codemirrors())
1266
@_view_state = state
1267
return state
1268
1269
restore_view_state: (second_try) =>
1270
state = @_view_state
1271
if not state?
1272
return
1273
cms = @codemirrors()
1274
i = 0
1275
for v in state.scroll
1276
cm = cms[i]
1277
if cm?
1278
cm.scrollTo(v.left, v.top)
1279
info = cm.getScrollInfo()
1280
# THIS IS HORRIBLE and SUCKS, but I can't understand what is going on sufficiently
1281
# well to remove this. Sometimes scrollTo fails (due to the document being reported as much
1282
# smaller than it is for a few ms) **and** it's then not possible to scroll,
1283
# so we just try again. See https://github.com/sagemathinc/cocalc/issues/1327
1284
if not second_try and info.top != v.top
1285
# didn't work -- not fully visible; try again one time when rendering is presumably done.
1286
setTimeout((=>@restore_view_state(true)), 250)
1287
i += 1
1288
1289
restore_cursor_position: () =>
1290
for i, cm of @codemirrors()
1291
if cm?
1292
pos = @local_storage("cursor#{cm.name}")
1293
if pos?
1294
cm.setCursor(pos)
1295
#console.log("#{@filename}: setting view #{cm.name} to cursor pos -- #{misc.to_json(pos)}")
1296
info = cm.getScrollInfo()
1297
try
1298
cm.scrollIntoView(pos, info.clientHeight/2)
1299
catch e
1300
#console.log("#{@filename}: failed to scroll view #{cm.name} into view -- #{e}")
1301
@codemirror?.focus()
1302
1303
# set background color of active line in editor based on background color (which depends on the theme)
1304
_style_active_line: () =>
1305
if not @opts.style_active_line
1306
return
1307
rgb = $(@codemirror.getWrapperElement()).css('background-color')
1308
v = (parseInt(x) for x in rgb.slice(4,rgb.length-1).split(','))
1309
amount = @opts.style_active_line
1310
for i in [0..2]
1311
if v[i] >= 128
1312
v[i] -= amount
1313
else
1314
v[i] += amount
1315
$("body").remove("#webapp-cm-activeline")
1316
$("body").append("<style id='webapp-cm-activeline' type=text/css>.CodeMirror-activeline{background:rgb(#{v[0]},#{v[1]},#{v[2]});}</style>") # this is a memory leak!
1317
1318
_show_codemirror_editors: (height) =>
1319
# console.log("_show_codemirror_editors: #{@_layout}")
1320
if not @codemirror?
1321
# already closed so can't show (in syncdoc, .codemirorr is deleted on close)
1322
return
1323
switch @_layout
1324
when 0
1325
p = 1
1326
when 1
1327
p = @_layout1_split_pos ? 0.5
1328
when 2
1329
p = @_layout2_split_pos ? 0.5
1330
1331
# Change the height of the *top* div that contain the editors; the bottom one then
1332
# uses of all remaining vertical height.
1333
if @_layout > 0
1334
p = Math.max(MIN_SPLIT, Math.min(MAX_SPLIT, p))
1335
1336
# We set only the default size of the *first* div -- everything else expands accordingly.
1337
elt = @element.find(".webapp-editor-codemirror-input-container-layout-#{@_layout}").show()
1338
1339
if @_layout == 1
1340
@element.find(".webapp-editor-resize-bar-layout-1").css(top:0)
1341
else if @_layout == 2
1342
@element.find(".webapp-editor-resize-bar-layout-2").css(left:0)
1343
1344
c = elt.find(".webapp-editor-codemirror-input-box")
1345
if @_layout == 0
1346
c.css('flex', 1) # use the full vertical height
1347
else
1348
c.css('flex-basis', "#{p*100}%")
1349
1350
if @_last_layout != @_layout
1351
# The layout has changed
1352
btn = @element.find('a[href="#split-view"]')
1353
1354
if @_last_layout?
1355
# Hide previous
1356
btn.find(".webapp-editor-layout-#{@_last_layout}").hide()
1357
@element.find(".webapp-editor-codemirror-input-container-layout-#{@_last_layout}").hide()
1358
1359
# Show current
1360
btn.find(".webapp-editor-layout-#{@_layout}").show()
1361
1362
# Put editors in their place -- in the div inside of each box
1363
elt.find(".webapp-editor-codemirror-input-box div").empty().append($(@codemirror.getWrapperElement()))
1364
elt.find(".webapp-editor-codemirror-input-box-1 div").empty().append($(@codemirror1.getWrapperElement()))
1365
1366
# Save for next time
1367
@_last_layout = @_layout
1368
1369
refresh = (cm) =>
1370
return if not cm?
1371
cm_refresh(cm)
1372
# See https://github.com/sagemathinc/cocalc/issues/1327#issuecomment-265488872
1373
setTimeout((=>cm_refresh(cm)), 1)
1374
1375
for cm in @codemirrors()
1376
refresh(cm)
1377
1378
@emit('show')
1379
1380
_show: (opts={}) =>
1381
# show the element that contains this editor
1382
#@element.show()
1383
# show the codemirror editors, resizing as needed
1384
@_show_codemirror_editors()
1385
1386
focus: () =>
1387
if not @codemirror?
1388
return
1389
@show()
1390
if not (IS_MOBILE or feature.IS_TOUCH)
1391
@codemirror_with_last_focus?.focus()
1392
1393
############
1394
# Editor button bar support code
1395
############
1396
textedit_command: (cm, cmd, args) =>
1397
# ATTN when adding more cases, also edit textedit_only_show_known_buttons
1398
switch cmd
1399
when "link"
1400
cm.insert_link(cb:() => @syncdoc?.sync())
1401
return false # don't return true or get an infinite recurse
1402
when "image"
1403
cm.insert_image(cb:() => @syncdoc?.sync())
1404
return false # don't return true or get an infinite recurse
1405
when "SpecialChar"
1406
cm.insert_special_char(cb:() => @syncdoc?.sync())
1407
return false # don't return true or get an infinite recurse
1408
else
1409
cm.edit_selection
1410
cmd : cmd
1411
args : args
1412
@syncdoc?.sync()
1413
# needed so that dropdown menu closes when clicked.
1414
return true
1415
1416
example_insert_handler: (insert) =>
1417
# insert : {lang: string, descr: string, code: string[]}
1418
{code, lang} = insert
1419
cm = @focused_codemirror()
1420
line = cm.getCursor().line
1421
# ATTN: to make this work properly, code and descr need to have a newline at the end (stripped by default)
1422
if insert.descr?
1423
@syncdoc?.insert_new_cell(line)
1424
# insert a "hidden" markdown cell and evaluate it
1425
cm.replaceRange("%md(hide=True)\n#{insert.descr}\n", {line : line+1, ch:0})
1426
@action_key(execute: true, advance:false, split:false)
1427
1428
# inserting one or more code cells
1429
for c in code
1430
line = cm.getCursor().line
1431
# next, we insert the code cell and prefix it with a mode change,
1432
# iff the mode is different from the current one
1433
@syncdoc?.insert_new_cell(line)
1434
cell = "#{c}\n"
1435
if lang != @_current_mode
1436
# special case: %sh for bash language
1437
if lang == 'bash' then lang = 'sh'
1438
cell = "%#{lang}\n#{cell}"
1439
cm.replaceRange(cell, {line : line+1, ch:0})
1440
# and we evaluate and sync all this, too…
1441
@action_key(execute: true, advance:false, split:false)
1442
@syncdoc?.sync()
1443
1444
# add a textedit toolbar to the editor
1445
init_sagews_edit_buttons: () =>
1446
if @opts.read_only # no editing button bar needed for read-only files
1447
return
1448
1449
if IS_MOBILE # no edit button bar on mobile either -- too big (for now at least)
1450
return
1451
1452
if not redux.getStore('account').get_editor_settings().extra_button_bar
1453
# explicitly disabled by user
1454
return
1455
1456
NAME_TO_MODE = {xml:'html', markdown:'md', mediawiki:'wiki'}
1457
for x in sagews_decorator_modes
1458
mode = x[0]
1459
name = x[1]
1460
v = name.split('-')
1461
if v.length > 1
1462
name = v[1]
1463
NAME_TO_MODE[name] = "#{mode}"
1464
1465
name_to_mode = (name) ->
1466
n = NAME_TO_MODE[name]
1467
if n?
1468
return n
1469
else
1470
return "#{name}"
1471
1472
# add the text editing button bar
1473
e = @element.find(".webapp-editor-codemirror-textedit-buttons")
1474
@textedit_buttons = templates.find(".webapp-editor-textedit-buttonbar").clone().hide()
1475
e.append(@textedit_buttons).show()
1476
1477
# add the code editing button bar
1478
@codeedit_buttons = templates.find(".webapp-editor-codeedit-buttonbar").clone()
1479
e.append(@codeedit_buttons)
1480
1481
# the r-editing button bar
1482
@redit_buttons = templates.find(".webapp-editor-redit-buttonbar").clone()
1483
e.append(@redit_buttons)
1484
1485
# the Julia-editing button bar
1486
@julia_edit_buttons = templates.find(".webapp-editor-julia-edit-buttonbar").clone()
1487
e.append(@julia_edit_buttons)
1488
1489
# the sh-editing button bar
1490
@sh_edit_buttons = templates.find(".webapp-editor-sh-edit-buttonbar").clone()
1491
e.append(@sh_edit_buttons)
1492
1493
@cython_buttons = templates.find(".webapp-editor-cython-buttonbar").clone()
1494
e.append(@cython_buttons)
1495
1496
@fallback_buttons = templates.find(".webapp-editor-fallback-edit-buttonbar").clone()
1497
e.append(@fallback_buttons)
1498
1499
all_edit_buttons = [@textedit_buttons, @codeedit_buttons, @redit_buttons,
1500
@cython_buttons, @julia_edit_buttons, @sh_edit_buttons, @fallback_buttons]
1501
1502
# activite the buttons in the bar
1503
that = @
1504
edit_button_click = (e) ->
1505
e.preventDefault()
1506
args = $(this).data('args')
1507
cmd = $(this).attr('href').slice(1)
1508
if cmd == 'todo'
1509
return
1510
if args? and typeof(args) != 'object'
1511
args = "#{args}"
1512
if args.indexOf(',') != -1
1513
args = args.split(',')
1514
return that.textedit_command(that.focused_codemirror(), cmd, args)
1515
1516
# FUTURE: activate color editing buttons -- for now just hide them
1517
@element.find(".sagews-output-editor-foreground-color-selector").hide()
1518
@element.find(".sagews-output-editor-background-color-selector").hide()
1519
1520
@fallback_buttons.find('a[href="#todo"]').click () =>
1521
bootbox.alert("<i class='fa fa-wrench' style='font-size: 18pt;margin-right: 1em;'></i> Button bar not yet implemented in <code>#{mode_display.text()}</code> cells.")
1522
return false
1523
1524
for edit_buttons in all_edit_buttons
1525
edit_buttons.find("a").click(edit_button_click)
1526
edit_buttons.find("*[title]").tooltip(TOOLTIP_DELAY)
1527
1528
@mode_display = mode_display = @element.find(".webapp-editor-codeedit-buttonbar-mode")
1529
@_current_mode = "sage"
1530
@mode_display.show()
1531
1532
# not all textedit buttons are known
1533
textedit_only_show_known_buttons = (name) =>
1534
EDIT_COMMANDS = require('./editors/editor-button-bar').commands
1535
default_mode = @focused_codemirror()?.get_edit_mode() ? 'sage'
1536
mode = sagews_canonical_mode(name, default_mode)
1537
#if DEBUG then console.log "textedit_only_show_known_buttons: mode #{name} → #{mode}"
1538
known_commands = misc.keys(EDIT_COMMANDS[mode] ? {})
1539
# see special cases in 'textedit_command' and codemirror/extensions: 'edit_selection'
1540
known_commands = known_commands.concat(['link', 'image', 'SpecialChar', 'font_size'])
1541
for button in @textedit_buttons.find('a')
1542
button = $(button)
1543
cmd = button.attr('href').slice(1)
1544
# in theory, this should also be done for html&md, but there are many more special cases
1545
# therefore we just make sure they're all activated again
1546
button.toggle((mode != 'tex') or (cmd in known_commands))
1547
1548
set_mode_display = (name) =>
1549
#console.log("set_mode_display: #{name}")
1550
if name?
1551
mode = name_to_mode(name)
1552
else
1553
mode = ""
1554
mode_display.text("%" + mode)
1555
@_current_mode = mode
1556
1557
show_edit_buttons = (which_one, name) =>
1558
for edit_buttons in all_edit_buttons
1559
edit_buttons.toggle(edit_buttons == which_one)
1560
if which_one == @textedit_buttons
1561
textedit_only_show_known_buttons(name)
1562
set_mode_display(name)
1563
1564
# this is deprecated
1565
@element.find('.webapp-editor-codeedit-buttonbar-assistant').hide()
1566
1567
# The code below changes the bar at the top depending on where the cursor
1568
# is located. We only change the edit bar if the cursor hasn't moved for
1569
# a while, to be more efficient, avoid noise, and be less annoying to the user.
1570
# Replaced by http://underscorejs.org/#debounce
1571
#bar_timeout = undefined
1572
#f = () =>
1573
# if bar_timeout?
1574
# clearTimeout(bar_timeout)
1575
# bar_timeout = setTimeout(update_context_sensitive_bar, 250)
1576
1577
update_context_sensitive_bar = () =>
1578
cm = @focused_codemirror()
1579
if not cm?
1580
return
1581
pos = cm.getCursor()
1582
name = cm.getModeAt(pos).name
1583
#console.log("update_context_sensitive_bar, pos=#{misc.to_json(pos)}, name=#{name}")
1584
if name in ['xml', 'stex', 'markdown', 'mediawiki']
1585
show_edit_buttons(@textedit_buttons, name)
1586
else if name == "r"
1587
show_edit_buttons(@redit_buttons, name)
1588
else if name == "julia"
1589
show_edit_buttons(@julia_edit_buttons, name)
1590
else if name == "cython" # doesn't work yet, since name=python still
1591
show_edit_buttons(@cython_buttons, name)
1592
else if name == "python" # doesn't work yet, since name=python still
1593
show_edit_buttons(@codeedit_buttons, "sage")
1594
else if name == "shell"
1595
show_edit_buttons(@sh_edit_buttons, name)
1596
else
1597
show_edit_buttons(@fallback_buttons, name)
1598
1599
for cm in @codemirrors()
1600
cm.on('cursorActivity', _.debounce(update_context_sensitive_bar, 250))
1601
1602
update_context_sensitive_bar()
1603
@element.find(".webapp-editor-codemirror-textedit-buttons").katex({preProcess:true})
1604
1605
1606
exports.codemirror_editor = (project_id, filename, extra_opts) ->
1607
return new CodeMirrorEditor(project_id, filename, "", extra_opts)
1608
1609
codemirror_session_editor = exports.codemirror_session_editor = (project_id, filename, extra_opts) ->
1610
#console.log("codemirror_session_editor '#{filename}'")
1611
ext = filename_extension_notilde(filename).toLowerCase()
1612
1613
E = new CodeMirrorEditor(project_id, filename, "", extra_opts)
1614
# Enhance the editor with synchronized session capabilities.
1615
opts =
1616
cursor_interval : E.opts.cursor_interval
1617
sync_interval : E.opts.sync_interval
1618
1619
switch ext
1620
when "sagews"
1621
# temporary.
1622
opts =
1623
cursor_interval : 2000
1624
sync_interval : 250
1625
E.syncdoc = new (sagews.SynchronizedWorksheet)(E, opts)
1626
E.action_key = E.syncdoc.action
1627
E.interrupt_key = E.syncdoc.interrupt
1628
E.tab_nothing_selected = () => E.syncdoc.introspect()
1629
when "sage-history"
1630
# no syncdoc
1631
else
1632
E.syncdoc = new (syncdoc.SynchronizedDocument2)(E, opts)
1633
1634
E.save = E.syncdoc?.save
1635
return E
1636
1637
1638
class FileEditorWrapper extends FileEditor
1639
constructor: (project_id, filename, content, opts) ->
1640
super(project_id, filename)
1641
@content = content
1642
@opts = opts
1643
@init_wrapped(@project_id, @filename, @content, @opts)
1644
1645
init_wrapped: () =>
1646
# Define @element and @wrapped in derived class
1647
throw Error('must define in derived class')
1648
1649
save: (cb) =>
1650
if @wrapped?.save?
1651
@wrapped.save(cb)
1652
else
1653
cb?()
1654
1655
has_unsaved_changes: (val) =>
1656
return @wrapped?.has_unsaved_changes?(val)
1657
1658
has_uncommitted_changes: (val) =>
1659
return @wrapped?.has_uncommitted_changes?(val)
1660
1661
_get: () =>
1662
# FUTURE
1663
return 'history saving not yet implemented'
1664
1665
_set: (content) =>
1666
# FUTURE ???
1667
1668
focus: () =>
1669
1670
terminate_session: () =>
1671
1672
disconnect_from_session: () =>
1673
@wrapped?.destroy?()
1674
1675
remove: () =>
1676
super()
1677
@wrapped?.destroy?()
1678
delete @filename; delete @content; delete @opts
1679
1680
show: () =>
1681
if not @is_active()
1682
return
1683
if not @element?
1684
return
1685
@element.show()
1686
1687
if IS_MOBILE
1688
@element.css(position:'relative')
1689
1690
@wrapped?.show?()
1691
1692
hide: () =>
1693
@element?.hide()
1694
@wrapped?.hide?()
1695
1696
1697
1698
exports.register_nonreact_editors = ->
1699
1700
# Make non-react editors available in react rewrite
1701
reg = require('./editors/react-wrapper').register_nonreact_editor
1702
1703
# wrapper for registering private and public editors
1704
exports.register = register = (is_public, cls, extensions) ->
1705
icon = file_icon_class(extensions[0])
1706
reg
1707
ext : extensions
1708
is_public : is_public
1709
icon : icon
1710
f : (project_id, path, opts) ->
1711
e = new cls(project_id, path, undefined, opts)
1712
if not e.ext?
1713
console.error('You have to call super(@project_id, @filename) in the constructor to properly initialize this FileEditor instance.')
1714
return e
1715
1716
# Editing Sage worksheets
1717
reg
1718
ext : 'sagews'
1719
f : (project_id, path, opts) -> codemirror_session_editor(project_id, path, opts)
1720
is_public : false
1721
1722
1723
1724
# See https://github.com/sagemathinc/cocalc/issues/3538
1725
cm_refresh = (cm) ->
1726
if not cm?
1727
return
1728
try
1729
cm.refresh()
1730
catch err
1731
console.warn("cm refresh err", err)
1732
1733
1734