First pass for suggest-as-you-type for linking.
This commit is contained in:
parent
599971ba01
commit
fe139cc749
|
@ -904,6 +904,52 @@ class Notebooks( object ):
|
||||||
storage_bytes = user.storage_bytes,
|
storage_bytes = user.storage_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@expose( view = Json )
|
||||||
|
@strongly_expire
|
||||||
|
@end_transaction
|
||||||
|
@grab_user_id
|
||||||
|
@validate(
|
||||||
|
notebook_id = Valid_id(),
|
||||||
|
search_text = unicode,
|
||||||
|
user_id = Valid_id( none_okay = True ),
|
||||||
|
)
|
||||||
|
def search_titles( self, notebook_id, search_text, user_id ):
|
||||||
|
"""
|
||||||
|
Search the note titles within the given notebook for the given search text, and return matching
|
||||||
|
notes. The search is case-insensitive. The returned notes include title summaries with the
|
||||||
|
search term highlighted and are ordered by descending revision timestamp.
|
||||||
|
|
||||||
|
@type notebook_id: unicode
|
||||||
|
@param notebook_id: id of notebook to search
|
||||||
|
@type search_text: unicode
|
||||||
|
@param search_text: search term
|
||||||
|
@type user_id: unicode or NoneType
|
||||||
|
@param user_id: id of current logged-in user (if any), determined by @grab_user_id
|
||||||
|
@rtype: json dict
|
||||||
|
@return: { 'notes': [ matching notes ] }
|
||||||
|
@raise Access_error: the current user doesn't have access to the given notebook
|
||||||
|
@raise Validation_error: one of the arguments is invalid
|
||||||
|
@raise Search_error: the provided search_text is invalid
|
||||||
|
"""
|
||||||
|
if not self.__users.check_access( user_id, notebook_id ):
|
||||||
|
raise Access_error()
|
||||||
|
|
||||||
|
MAX_SEARCH_TEXT_LENGTH = 256
|
||||||
|
if len( search_text ) > MAX_SEARCH_TEXT_LENGTH:
|
||||||
|
raise Validation_error( u"search_text", None, unicode, message = u"is too long" )
|
||||||
|
|
||||||
|
if len( search_text ) == 0:
|
||||||
|
raise Validation_error( u"search_text", None, unicode, message = u"is missing" )
|
||||||
|
|
||||||
|
notes = self.__database.select_many( Note, Notebook.sql_search_titles( notebook_id, search_text ) )
|
||||||
|
|
||||||
|
for note in notes:
|
||||||
|
note.summary = note.summary.replace( search_text, u"<b>%s</b>" % search_text )
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
notes = notes,
|
||||||
|
)
|
||||||
|
|
||||||
@expose( view = Json )
|
@expose( view = Json )
|
||||||
@strongly_expire
|
@strongly_expire
|
||||||
@end_transaction
|
@end_transaction
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import re
|
import re
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from Note import Note
|
from Note import Note
|
||||||
from Persistent import Persistent, quote
|
from Persistent import Persistent, quote, quote_fuzzy
|
||||||
|
|
||||||
|
|
||||||
class Notebook( Persistent ):
|
class Notebook( Persistent ):
|
||||||
|
@ -200,6 +200,33 @@ class Notebook( Persistent ):
|
||||||
""" % ( quote( search_text ), quote( user_id ),
|
""" % ( quote( search_text ), quote( user_id ),
|
||||||
quote( first_notebook_id ) )
|
quote( first_notebook_id ) )
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sql_search_titles( notebook_id, search_text ):
|
||||||
|
"""
|
||||||
|
Return a SQL string to perform a search for notes within the given notebook whose titles contain
|
||||||
|
the given search_text. This is a case-insensitive search.
|
||||||
|
|
||||||
|
@type search_text: unicode
|
||||||
|
@param search_text: text to search for within the notes
|
||||||
|
"""
|
||||||
|
# strip out all search operators
|
||||||
|
search_text = Notebook.SEARCH_OPERATORS.sub( u"", search_text ).strip()
|
||||||
|
|
||||||
|
return \
|
||||||
|
"""
|
||||||
|
select id, revision, title, contents, notebook_id, startup, deleted_from_id, rank, user_id, null,
|
||||||
|
title as summary
|
||||||
|
from
|
||||||
|
note_current
|
||||||
|
where
|
||||||
|
notebook_id = %s and
|
||||||
|
deleted_from_id is null and
|
||||||
|
title ilike %s
|
||||||
|
order by
|
||||||
|
revision desc limit 20;
|
||||||
|
""" % ( quote( notebook_id ),
|
||||||
|
quote_fuzzy( search_text ) )
|
||||||
|
|
||||||
def sql_highest_note_rank( self ):
|
def sql_highest_note_rank( self ):
|
||||||
"""
|
"""
|
||||||
Return a SQL string to determine the highest numbered rank of all notes in this notebook."
|
Return a SQL string to determine the highest numbered rank of all notes in this notebook."
|
||||||
|
|
|
@ -85,3 +85,13 @@ def quote( value ):
|
||||||
|
|
||||||
value = unicode( value )
|
value = unicode( value )
|
||||||
return "'%s'" % value.replace( "'", "''" ).replace( "\\", "\\\\" )
|
return "'%s'" % value.replace( "'", "''" ).replace( "\\", "\\\\" )
|
||||||
|
|
||||||
|
|
||||||
|
def quote_fuzzy( value ):
|
||||||
|
if value is None:
|
||||||
|
return "null"
|
||||||
|
|
||||||
|
value = unicode( value )
|
||||||
|
|
||||||
|
value = value.replace( "'", "''" ).replace( "\\", "\\\\" )
|
||||||
|
return "'%" + value + "%'"
|
||||||
|
|
|
@ -514,6 +514,7 @@ h1 {
|
||||||
|
|
||||||
.pulldown {
|
.pulldown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
font-size: 72%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
max-height: 20em;
|
max-height: 20em;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
@ -532,6 +533,14 @@ h1 {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.suggestion {
|
||||||
|
padding: 0.25em 0.5em 0.25em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected_suggestion {
|
||||||
|
background-color: #d0e0f0;
|
||||||
|
}
|
||||||
|
|
||||||
.pulldown_label {
|
.pulldown_label {
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
|
@ -445,7 +445,7 @@ Wiki.prototype.load_editor = function ( note_title, note_id, revision, previous_
|
||||||
if ( link ) {
|
if ( link ) {
|
||||||
var pulldown = link.pulldown;
|
var pulldown = link.pulldown;
|
||||||
var pulldown_title = undefined;
|
var pulldown_title = undefined;
|
||||||
if ( pulldown ) {
|
if ( pulldown && pulldown.title_field ) {
|
||||||
pulldown_title = strip( pulldown.title_field.value );
|
pulldown_title = strip( pulldown.title_field.value );
|
||||||
if ( pulldown_title )
|
if ( pulldown_title )
|
||||||
note_title = pulldown_title;
|
note_title = pulldown_title;
|
||||||
|
@ -823,16 +823,44 @@ Wiki.prototype.display_link_pulldown = function ( editor, link, ephemeral ) {
|
||||||
if ( !link )
|
if ( !link )
|
||||||
link = editor.find_link_at_cursor();
|
link = editor.find_link_at_cursor();
|
||||||
|
|
||||||
// if there's no link at the current cursor location, or there is a link but it was just started,
|
// if there's no link at the current cursor location, bail
|
||||||
// bail
|
if ( !link ) {
|
||||||
if ( !link || link == editor.link_started ) {
|
|
||||||
this.clear_pulldowns();
|
this.clear_pulldowns();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var pulldown = link.pulldown;
|
var pulldown = link.pulldown;
|
||||||
if ( pulldown )
|
var query = parse_query( link );
|
||||||
pulldown.update_position();
|
var title = link_title( link, query );
|
||||||
|
|
||||||
|
// display a Suggest_pulldown once something is typed for the link title, as long as the link
|
||||||
|
// doesn't yet have a note_id
|
||||||
|
if ( !pulldown && title.length > 0 && query.note_id == "new" ) {
|
||||||
|
this.clear_pulldowns();
|
||||||
|
var self = this;
|
||||||
|
var suggest_pulldown = new Suggest_pulldown( this, this.notebook_id, this.invoker, editor, link, title, editor.document );
|
||||||
|
connect( suggest_pulldown, "suggestion_selected", function ( note ) {
|
||||||
|
self.update_link_with_suggestion( editor, link, note )
|
||||||
|
} );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is a link but it was just started, bail
|
||||||
|
if ( link == editor.link_started && !pulldown ) {
|
||||||
|
this.clear_pulldowns();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( pulldown ) {
|
||||||
|
// if a Suggest_pulldown is open for the link, update the pulldown and bail
|
||||||
|
if ( pulldown.update_suggestions ) {
|
||||||
|
pulldown.update_suggestions( title );
|
||||||
|
return;
|
||||||
|
// otherwise, just update the pulldown's position
|
||||||
|
} else {
|
||||||
|
pulldown.update_position();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var link_contains_image = getElementsByTagAndClassName( "img", null, link );
|
var link_contains_image = getElementsByTagAndClassName( "img", null, link );
|
||||||
|
|
||||||
|
@ -853,6 +881,26 @@ Wiki.prototype.display_link_pulldown = function ( editor, link, ephemeral ) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Wiki.prototype.update_link_with_suggestion = function ( editor, link, note ) {
|
||||||
|
link.innerHTML = note.title;
|
||||||
|
|
||||||
|
// manually position the text cursor at the end of the link title
|
||||||
|
if ( editor.iframe.contentWindow && editor.iframe.contentWindow.getSelection ) { // browsers such as Firefox
|
||||||
|
var selection = editor.iframe.contentWindow.getSelection();
|
||||||
|
selection.selectAllChildren( link );
|
||||||
|
selection.collapseToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
link.href = "/notebooks/" + this.notebook_id + "?note_id=" + note.object_id;
|
||||||
|
|
||||||
|
link.pulldown.shutdown();
|
||||||
|
link.pulldown = null;
|
||||||
|
editor.focus();
|
||||||
|
editor.end_link();
|
||||||
|
|
||||||
|
this.display_link_pulldown( editor, link );
|
||||||
|
}
|
||||||
|
|
||||||
Wiki.prototype.editor_focused = function ( editor, synchronous ) {
|
Wiki.prototype.editor_focused = function ( editor, synchronous ) {
|
||||||
if ( editor )
|
if ( editor )
|
||||||
addElementClass( editor.iframe, "focused_note_frame" );
|
addElementClass( editor.iframe, "focused_note_frame" );
|
||||||
|
@ -1181,6 +1229,7 @@ Wiki.prototype.toggle_attach_button = function ( event ) {
|
||||||
|
|
||||||
if ( existing_div ) {
|
if ( existing_div ) {
|
||||||
existing_div.pulldown.shutdown();
|
existing_div.pulldown.shutdown();
|
||||||
|
existing_div.pulldown = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1477,7 +1526,6 @@ Wiki.prototype.submit_form = function ( form ) {
|
||||||
} else if ( url == "/users/signup_group_member" ) {
|
} else if ( url == "/users/signup_group_member" ) {
|
||||||
callback = function ( result ) {
|
callback = function ( result ) {
|
||||||
var group_id = getFirstElementByTagAndClassName( "input", "group_id", form ).value;
|
var group_id = getFirstElementByTagAndClassName( "input", "group_id", form ).value;
|
||||||
console.log( form, group_id );
|
|
||||||
self.invoker.invoke( "/groups/load_users", "GET", {
|
self.invoker.invoke( "/groups/load_users", "GET", {
|
||||||
"group_id": group_id
|
"group_id": group_id
|
||||||
}, function ( result ) {
|
}, function ( result ) {
|
||||||
|
@ -2189,6 +2237,7 @@ Wiki.prototype.clear_pulldowns = function ( ephemeral_only ) {
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
result.pulldown.shutdown();
|
result.pulldown.shutdown();
|
||||||
|
result.pulldown = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2443,6 +2492,7 @@ Wiki.prototype.toggle_editor_changes = function ( event, editor ) {
|
||||||
var existing_div = getElement( pulldown_id );
|
var existing_div = getElement( pulldown_id );
|
||||||
if ( existing_div ) {
|
if ( existing_div ) {
|
||||||
existing_div.pulldown.shutdown();
|
existing_div.pulldown.shutdown();
|
||||||
|
existing_div.pulldown = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2475,6 +2525,7 @@ Wiki.prototype.toggle_editor_options = function ( event, editor ) {
|
||||||
var existing_div = getElement( pulldown_id );
|
var existing_div = getElement( pulldown_id );
|
||||||
if ( existing_div ) {
|
if ( existing_div ) {
|
||||||
existing_div.pulldown.shutdown();
|
existing_div.pulldown.shutdown();
|
||||||
|
existing_div.pulldown = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3306,6 +3357,149 @@ File_link_pulldown.prototype.shutdown = function () {
|
||||||
disconnectAll( this.right_justify_radio );
|
disconnectAll( this.right_justify_radio );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Suggest_pulldown( wiki, notebook_id, invoker, editor, anchor, search_text, key_press_node ) {
|
||||||
|
anchor.pulldown = this;
|
||||||
|
this.anchor = anchor;
|
||||||
|
this.previous_search_text = "";
|
||||||
|
|
||||||
|
Pulldown.call( this, wiki, notebook_id, "suggest_" + editor.id, anchor, editor.iframe );
|
||||||
|
|
||||||
|
this.invoker = invoker;
|
||||||
|
this.editor = editor;
|
||||||
|
this.update_suggestions( search_text );
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
this.key_handler = connect( key_press_node, "onkeydown", function ( event ) { self.key_pressed( event ); } );
|
||||||
|
|
||||||
|
Pulldown.prototype.update_position.call( this );
|
||||||
|
}
|
||||||
|
|
||||||
|
Suggest_pulldown.prototype = new function () { this.prototype = Pulldown.prototype; };
|
||||||
|
Suggest_pulldown.prototype.constructor = Suggest_pulldown;
|
||||||
|
|
||||||
|
Suggest_pulldown.prototype.update_suggestions = function ( search_text ) {
|
||||||
|
// if the search text hasn't changed since last time, bail
|
||||||
|
if ( this.previous_search_text == search_text )
|
||||||
|
return;
|
||||||
|
|
||||||
|
// if there is no search text, hide the pulldown and bail
|
||||||
|
if ( !search_text ) {
|
||||||
|
addElementClass( this.div, "invisible" );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
this.previous_search_text = search_text;
|
||||||
|
|
||||||
|
this.invoker.invoke( "/notebooks/search_titles", "GET", {
|
||||||
|
"notebook_id": this.notebook_id,
|
||||||
|
"search_text": search_text
|
||||||
|
},
|
||||||
|
function( result ) { self.display_suggestions( result, search_text ); }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Suggest_pulldown.prototype.display_suggestions = function ( result, search_text ) {
|
||||||
|
if ( result.notes.length == 0 ) {
|
||||||
|
addElementClass( this.div, "invisible" );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeElementClass( this.div, "invisible" );
|
||||||
|
var results_list = createDOM( "div" );
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
function connect_link( suggest_link, note ) {
|
||||||
|
connect( suggest_link, "onclick", function ( event ) { self.suggestion_selected( event, note ); } );
|
||||||
|
}
|
||||||
|
|
||||||
|
for ( var i in result.notes ) {
|
||||||
|
var note = result.notes[ i ];
|
||||||
|
if ( !note.title ) continue;
|
||||||
|
|
||||||
|
var suggest_link = createDOM( "a", { "href": "#", "class": "pulldown_link" } );
|
||||||
|
suggest_link.innerHTML = note.summary;
|
||||||
|
suggest_link.note = note;
|
||||||
|
|
||||||
|
appendChildNodes( results_list, createDOM( "div", { "class": "suggestion" }, suggest_link ) );
|
||||||
|
connect_link( suggest_link, note );
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceChildNodes( this.div, results_list );
|
||||||
|
}
|
||||||
|
|
||||||
|
Suggest_pulldown.prototype.suggestion_selected = function ( event, note ) {
|
||||||
|
event.stop();
|
||||||
|
|
||||||
|
signal( this, "suggestion_selected", note );
|
||||||
|
}
|
||||||
|
|
||||||
|
Suggest_pulldown.prototype.key_pressed = function ( event ) {
|
||||||
|
// an invisible Suggest_pulldown shouldn't grab keypresses
|
||||||
|
if ( hasElementClass( this.div, "invisible" ) )
|
||||||
|
return;
|
||||||
|
|
||||||
|
var code = event.key().code;
|
||||||
|
|
||||||
|
// up arrow: move up to the previous suggestion
|
||||||
|
if ( code == 38 ) {
|
||||||
|
var selected = getFirstElementByTagAndClassName( "div", "selected_suggestion", this.div );
|
||||||
|
|
||||||
|
// if something is selected and there's a previous suggestion in the list, move the selection up
|
||||||
|
if ( selected && selected.previousSibling ) {
|
||||||
|
removeElementClass( selected, "selected_suggestion" );
|
||||||
|
addElementClass( selected.previousSibling, "selected_suggestion" );
|
||||||
|
// otherwise, hide the Suggest_pulldown
|
||||||
|
} else {
|
||||||
|
addElementClass( this.div, "invisible" );
|
||||||
|
}
|
||||||
|
// down arrow: move down to the previous suggestion
|
||||||
|
} else if ( code == 40 ) {
|
||||||
|
var selected = getFirstElementByTagAndClassName( "div", "selected_suggestion", this.div );
|
||||||
|
|
||||||
|
// if something is selected and there's a next suggestion in the list, move the selection down
|
||||||
|
if ( selected ) {
|
||||||
|
if ( selected.nextSibling ) {
|
||||||
|
removeElementClass( selected, "selected_suggestion" );
|
||||||
|
addElementClass( selected.nextSibling, "selected_suggestion" );
|
||||||
|
}
|
||||||
|
// if nothing is selected yet, then just select the first link
|
||||||
|
} else {
|
||||||
|
var suggest_link = getFirstElementByTagAndClassName( "a", "pulldown_link", this.div );
|
||||||
|
addElementClass( suggest_link.parentNode, "selected_suggestion" );
|
||||||
|
}
|
||||||
|
// enter: select current suggestion
|
||||||
|
} else if ( code == 13 ) {
|
||||||
|
var selected = getFirstElementByTagAndClassName( "div", "selected_suggestion", this.div );
|
||||||
|
var suggest_link = getFirstElementByTagAndClassName( "a", "pulldown_link", selected );
|
||||||
|
|
||||||
|
if ( selected )
|
||||||
|
this.suggestion_selected( event, suggest_link.note );
|
||||||
|
// escape: hide the suggestions
|
||||||
|
} else if ( code == 27 ) {
|
||||||
|
addElementClass( this.div, "invisible" );
|
||||||
|
// otherwise, not a key this method handles
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Suggest_pulldown.prototype.update_position = function ( anchor, relative_to ) {
|
||||||
|
Pulldown.prototype.update_position.call( this, anchor, relative_to );
|
||||||
|
}
|
||||||
|
|
||||||
|
Suggest_pulldown.prototype.shutdown = function () {
|
||||||
|
Pulldown.prototype.shutdown.call( this );
|
||||||
|
|
||||||
|
this.anchor.pulldown = null;
|
||||||
|
disconnectAll( this );
|
||||||
|
disconnect( this.key_handler );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function Note_tree( wiki, notebook_id, invoker ) {
|
function Note_tree( wiki, notebook_id, invoker ) {
|
||||||
this.wiki = wiki;
|
this.wiki = wiki;
|
||||||
this.notebook_id = notebook_id;
|
this.notebook_id = notebook_id;
|
||||||
|
|
Reference in New Issue