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,
|
||||
)
|
||||
|
||||
@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 )
|
||||
@strongly_expire
|
||||
@end_transaction
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import re
|
||||
from copy import copy
|
||||
from Note import Note
|
||||
from Persistent import Persistent, quote
|
||||
from Persistent import Persistent, quote, quote_fuzzy
|
||||
|
||||
|
||||
class Notebook( Persistent ):
|
||||
|
@ -200,6 +200,33 @@ class Notebook( Persistent ):
|
|||
""" % ( quote( search_text ), quote( user_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 ):
|
||||
"""
|
||||
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 )
|
||||
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 {
|
||||
position: absolute;
|
||||
font-size: 72%;
|
||||
text-align: left;
|
||||
max-height: 20em;
|
||||
overflow: auto;
|
||||
|
@ -532,6 +533,14 @@ h1 {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
padding: 0.25em 0.5em 0.25em 0.5em;
|
||||
}
|
||||
|
||||
.selected_suggestion {
|
||||
background-color: #d0e0f0;
|
||||
}
|
||||
|
||||
.pulldown_label {
|
||||
color: #000000;
|
||||
}
|
||||
|
|
|
@ -445,7 +445,7 @@ Wiki.prototype.load_editor = function ( note_title, note_id, revision, previous_
|
|||
if ( link ) {
|
||||
var pulldown = link.pulldown;
|
||||
var pulldown_title = undefined;
|
||||
if ( pulldown ) {
|
||||
if ( pulldown && pulldown.title_field ) {
|
||||
pulldown_title = strip( pulldown.title_field.value );
|
||||
if ( pulldown_title )
|
||||
note_title = pulldown_title;
|
||||
|
@ -823,16 +823,44 @@ Wiki.prototype.display_link_pulldown = function ( editor, link, ephemeral ) {
|
|||
if ( !link )
|
||||
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,
|
||||
// bail
|
||||
if ( !link || link == editor.link_started ) {
|
||||
// if there's no link at the current cursor location, bail
|
||||
if ( !link ) {
|
||||
this.clear_pulldowns();
|
||||
return;
|
||||
}
|
||||
|
||||
var pulldown = link.pulldown;
|
||||
if ( pulldown )
|
||||
pulldown.update_position();
|
||||
var query = parse_query( link );
|
||||
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 );
|
||||
|
||||
|
@ -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 ) {
|
||||
if ( editor )
|
||||
addElementClass( editor.iframe, "focused_note_frame" );
|
||||
|
@ -1181,6 +1229,7 @@ Wiki.prototype.toggle_attach_button = function ( event ) {
|
|||
|
||||
if ( existing_div ) {
|
||||
existing_div.pulldown.shutdown();
|
||||
existing_div.pulldown = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1477,7 +1526,6 @@ Wiki.prototype.submit_form = function ( form ) {
|
|||
} else if ( url == "/users/signup_group_member" ) {
|
||||
callback = function ( result ) {
|
||||
var group_id = getFirstElementByTagAndClassName( "input", "group_id", form ).value;
|
||||
console.log( form, group_id );
|
||||
self.invoker.invoke( "/groups/load_users", "GET", {
|
||||
"group_id": group_id
|
||||
}, function ( result ) {
|
||||
|
@ -2189,6 +2237,7 @@ Wiki.prototype.clear_pulldowns = function ( ephemeral_only ) {
|
|||
continue;
|
||||
|
||||
result.pulldown.shutdown();
|
||||
result.pulldown = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2443,6 +2492,7 @@ Wiki.prototype.toggle_editor_changes = function ( event, editor ) {
|
|||
var existing_div = getElement( pulldown_id );
|
||||
if ( existing_div ) {
|
||||
existing_div.pulldown.shutdown();
|
||||
existing_div.pulldown = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -2475,6 +2525,7 @@ Wiki.prototype.toggle_editor_options = function ( event, editor ) {
|
|||
var existing_div = getElement( pulldown_id );
|
||||
if ( existing_div ) {
|
||||
existing_div.pulldown.shutdown();
|
||||
existing_div.pulldown = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -3306,6 +3357,149 @@ File_link_pulldown.prototype.shutdown = function () {
|
|||
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 ) {
|
||||
this.wiki = wiki;
|
||||
this.notebook_id = notebook_id;
|
||||
|
|
Reference in New Issue