├── LICENSE ├── README.md ├── TagBehavior.php ├── TagEditor.php ├── TagEditorAsset.php ├── TagSuggestAction.php ├── TaggableBehavior.php ├── assets ├── jquery.caret.min.js ├── jquery.tag-editor.css └── jquery.tag-editor.min.js └── composer.json /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Sjaak Priester 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | yii2-taggable 2 | ============= 3 | 4 | #### Manage tags of ActiveRecords in PHP-framework Yii 2.0 #### 5 | 6 | [![Latest Stable Version](https://poser.pugx.org/sjaakp/yii2-taggable/v/stable)](https://packagist.org/packages/sjaakp/yii2-taggable) 7 | [![Total Downloads](https://poser.pugx.org/sjaakp/yii2-taggable/downloads)](https://packagist.org/packages/sjaakp/yii2-taggable) 8 | [![License](https://poser.pugx.org/sjaakp/yii2-taggable/license)](https://packagist.org/packages/sjaakp/yii2-taggable) 9 | 10 | This package contains five classes to handle the tagging of ActiveRecords with keywords or similar. The tags can be associated with or decoupled from a model (ActiveRecord), and can be sorted. Tags are manipulated with the excellent [jQuery tagEditor developed by Pixabay](http://goodies.pixabay.com/jquery/tag-editor/demo.html). 11 | 12 | The four main classes of **yii2-taggable** are: 13 | 14 | - **TagBehavior** - makes an ActiveRecord behave like a tag; 15 | - **TaggableBehavior** - adds the handling of tags to an ActiveRecord; 16 | - **TagEditor** - widget to manipulate tags; 17 | - **TagSuggestAction** - feeds the autocomplete function of TagEditor with data. 18 | 19 | There is also a class **TagEditorAsset**, which is a helper class for TagEditor. 20 | 21 | A demonstration of the **yii2-taggable** suit is [here](https://sjaakpriester.nl/software/taggable). 22 | 23 | Notice that the API for version 2 is slightly different from that of version 1. 24 | 25 | ## Installation ## 26 | 27 | The preferred way to install **yii2-taggable** is through [Composer](https://getcomposer.org/). Either add the following to the require section of your `composer.json` file: 28 | 29 | `"sjaakp/yii2-taggable": "*"` 30 | 31 | Or run: 32 | 33 | `composer require sjaakp/yii2-taggable "*"` 34 | 35 | You can manually install **yii2-taggable** by [downloading the source in ZIP-format](https://github.com/sjaakp/yii2-taggable/archive/master.zip). 36 | 37 | ## Setup ## 38 | 39 | Suppose we have a class (ActiveRecord) `Article` of articles which can be tagged, and another class `Tag` to hold the tags. 40 | 41 | Tag has at least the following attributes: 42 | 43 | - `id`: primary key; 44 | - `name`: to hold the actual tag keyword; 45 | 46 | ### Junction table ### 47 | 48 | `Article` and `Tag` are linked with a junction table in a many-to-many relation. 49 | Let's call the table `article_tag`. It has the following fields: 50 | 51 | - `model_id`: holds the primary key value of an `Article`; 52 | - `tag_id`: holds the primary key value of a `Tag`; 53 | - `ord`: holds the sorting order of a `Tag`. 54 | 55 | The junction table doesn't need to have a primary key. 56 | It's a good idea to set indexes on both `model_id` and `tag_id`. 57 | 58 | ### TaggableBehavior ### 59 | 60 | The class `Article` is *taggable*, and should be set up like this: 61 | 62 | [ 74 | 'class' => TaggableBehavior::class, 75 | 'junctionTable' => 'article_tag', 76 | 'tagClass' => Tag::class, 77 | ] 78 | ]; 79 | } 80 | // ... 81 | } 82 | 83 | ### TagBehavior ### 84 | 85 | Class `Tag` behaves as a *tag*, and looks something like this: 86 | 87 | [ 99 | 'class' => TagBehavior::class, 100 | 'junctionTable' => 'article_tag', 101 | 'modelClass' => Article::class, 102 | ] 103 | ]; 104 | } 105 | 106 | // ... 107 | } 108 | 109 | After attaching **TagBehavior** the class `Tag`. gains a few new properties and methods. 110 | A link to the Tag view can be obtained by: 111 | 112 | $link = $tag->link; 113 | 114 | `models` gets all Articles tagged by `$tag`: 115 | 116 | $taggedArticles = $tag->models; 117 | 118 | If you'd rather have an [ActiveDataProvider](https://www.yiiframework.com/doc/api/2.0/yii-data-activedataprovider) 119 | to access them, use: 120 | 121 | $providerOfTaggedArticles = $tag->getModels(); 122 | 123 | The count of tagged Articles: 124 | 125 | $countTaggedArticles = $tag->modelCount; 126 | 127 | ### Article view ### 128 | 129 | In the `Article` view we can now display the tags like so: 130 | 131 | 138 | 139 | 140 | 141 |

Tags

142 |

tagLinks ?>

143 | 144 | `tagLinks` is a new virtual attribute, added to `Article` by **TaggableBehavior**. 145 | 146 | All the `Tag`s can be retrieved with: 147 | 148 | $allTags = $article->tagModels; 149 | 150 | To get an [`ActiveQuery`](https://www.yiiframework.com/doc/api/2.0/yii-db-activequery), 151 | for instance to use in an [`ActiveDataProvider`](https://www.yiiframework.com/doc/api/2.0/yii-data-activedataprovider), 152 | use: 153 | 154 | $provider = new ActiveDataProvider([ 155 | 'query' => $article->getTagModels() 156 | ]); 157 | 158 | If you want to know whether an `Article` has a `Tag` with a certain name, say 'politics', 159 | you can query the class like so: 160 | 161 | if ($article->hasTag('politics')) { 162 | // ... 163 | } 164 | 165 | ### Article update ### 166 | 167 | To make creating and updating `Tag`s easy, we also have to set up `TagController`: 168 | 169 | [ 182 | 'class' => TagSuggestAction::class, 183 | 'tagClass' => Tag::class, 184 | ], 185 | ]; 186 | } 187 | 188 | // ... 189 | } 190 | 191 | ### TagEditor ### 192 | 193 | In the `Article`'s update and create views we can now use the **TagEditor** widget. Add something like this to `views\article\_form.php`: 194 | 195 | 206 | ... 207 | 208 | field($model, 'tags')->widget(TagEditor::class, [ 209 | 'clientOptions' => [ 210 | 'autocomplete' => [ 211 | 'source' => Url::toRoute(['tag/suggest']) 212 | ], 213 | ] 214 | ]) ?> 215 | ... 216 | 217 | `tags` is also a new virtual attribute of `Article`, added to it by **TaggableBehavior**. 218 | `'tag/suggest'` is the base of the route to the `suggest` action in `TagController`, 219 | which we defined before. Learn more about the `clientOptions` from [Pixabay](https://goodies.pixabay.com/jquery/tag-editor/demo.html). 220 | 221 | ## Modifications ## 222 | 223 | The basic setup of **yii2-taggable** can be modified in a number of ways. 224 | Refer to the source files to see which other options are available. Some are: 225 | 226 | - **nameAttribute**: name attribute of the tag class. 227 | Defined in TagBehavior, TaggableBehavior, and TagSuggestAction. Default: `'name'`. 228 | - **tagKeyColumn** and **modelKeyColumn**: foreign key fields in the junction table. 229 | Defined in TagBehavior and TaggableBehavior. 230 | Defaults: `'tag_id'` and `'model_id'` respectively. 231 | - **orderColumn**: holds order information in the junction table. 232 | Defined in TaggableBehavior. 233 | - **renderLink**: callable, `function($tag, $options)`, returning the HTML code for a single 234 | tag link. Defined by TagBehavior. If not set (default), TagBehaviour renders 235 | tag link as a simple HTML a. 236 | -------------------------------------------------------------------------------- /TagBehavior.php: -------------------------------------------------------------------------------- 1 | junctionTable)) { 73 | throw new InvalidConfigException('TagBehavior: property "junctionTable" is not set.'); 74 | } 75 | if (is_null($this->modelClass)) { 76 | throw new InvalidConfigException('TagBehavior: property "modelClass" is not set.'); 77 | } 78 | parent::init(); 79 | } 80 | 81 | /** 82 | * @return \yii\db\ActiveQuery 83 | * @throws \yii\base\InvalidConfigException 84 | */ 85 | public function getModels() 86 | { 87 | /** @var $owner ActiveRecord */ 88 | $owner = $this->owner; 89 | $tagPk = $owner->primaryKey()[0]; // tag pk name 90 | 91 | $mc = $this->modelClass; 92 | $modelPk = $mc::primaryKey()[0]; // model pk name 93 | 94 | return $owner->hasMany($this->modelClass, [ $modelPk => $this->modelKeyColumn ])->where($this->condition) 95 | ->viaTable($this->junctionTable, [ $this->tagKeyColumn => $tagPk ]); 96 | } 97 | 98 | /** 99 | * @return int 100 | * @throws \yii\db\Exception 101 | * @throws \yii\base\InvalidConfigException 102 | */ 103 | public function getModelCount() 104 | { 105 | /* @var $owner ActiveRecord */ 106 | $owner = $this->owner; 107 | $db = $owner->db; 108 | if (empty($this->condition)) { // if no condition, just count junctions (more efficient, I guess) 109 | $tagPk = $owner->primaryKey; // value 110 | 111 | $sql = $db->quoteSql("SELECT COUNT(*) FROM {{%{$this->junctionTable}}} WHERE [[{$this->tagKeyColumn}]] = $tagPk"); 112 | return $db->createCommand($sql)->queryScalar(); 113 | } 114 | return $this->getModels()->count('*', $db); 115 | } 116 | 117 | /** 118 | * @param array $options HTML options for link; may contain 'encode' => false 119 | * @return string tag name as link, HTML encoded 120 | * @throws \ReflectionException 121 | */ 122 | public function getLink($options = []) 123 | { 124 | /* @var $owner ActiveRecord */ 125 | $owner = $this->owner; 126 | 127 | if (is_null($this->renderLink)) { 128 | $name = $owner->getAttribute($this->nameAttribute); 129 | $encode = ArrayHelper::remove($options, 'encode', true); 130 | if ($encode) $name = Html::encode($name); 131 | $ctrl = Inflector::camel2id((new \ReflectionClass($owner))->getShortName()); 132 | 133 | return Html::a($name, [ "/$ctrl/view", 'id' => $owner->primaryKey ], $options); 134 | } 135 | return call_user_func($this->renderLink, $owner, $options); 136 | } 137 | 138 | /** 139 | * Remove tag's model links from junction table 140 | * @throws \yii\db\Exception 141 | * @return int number of rows affected 142 | */ 143 | protected function removeModels() 144 | { 145 | /* @var $owner ActiveRecord */ 146 | $owner = $this->owner; 147 | 148 | return $owner->db->createCommand()->delete($this->junctionTable, [ 149 | $this->tagKeyColumn => $owner->primaryKey // value 150 | ])->execute(); 151 | } 152 | 153 | /** 154 | * @inheritDoc 155 | */ 156 | public function events() 157 | { 158 | return [ 159 | ActiveRecord::EVENT_BEFORE_DELETE => 'beforeDelete', 160 | ]; 161 | } 162 | 163 | /** 164 | * @param $event 165 | * @throws \yii\db\Exception 166 | */ 167 | public function beforeDelete($event) 168 | { 169 | $this->removeModels(); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /TagEditor.php: -------------------------------------------------------------------------------- 1 | getView(); 40 | 41 | $asset = new TagEditorAsset(); 42 | $asset->register($view); 43 | 44 | $id = $this->getId(); 45 | $this->options['id'] = $id; 46 | 47 | $teOpts = count($this->clientOptions) ? Json::encode($this->clientOptions) : ''; 48 | $view->registerJs("jQuery('#$id').tagEditor($teOpts);"); 49 | 50 | return $this->hasModel() ? Html::activeTextInput($this->model, $this->attribute, $this->options) 51 | : Html::textInput($this->name, $this->value, $this->options); 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /TagEditorAsset.php: -------------------------------------------------------------------------------- 1 | tagClass; 58 | $r = ArrayHelper::getColumn($tc::find()->where(['like', $this->nameAttribute, 59 | strtr($this->like, ['{term}' => $term]), false])->all(), 60 | $this->nameAttribute); 61 | 62 | return Json::encode($r); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /TaggableBehavior.php: -------------------------------------------------------------------------------- 1 | junctionTable)) { 76 | throw new InvalidConfigException('TaggableBehavior: property "junctionTable" is not set.'); 77 | } 78 | if (is_null($this->tagClass)) { 79 | throw new InvalidConfigException('TaggableBehavior: property "tagClass" is not set.'); 80 | } 81 | parent::init(); 82 | } 83 | 84 | /** 85 | * @inheritdoc 86 | * @param $owner ActiveRecord 87 | * @link https://github.com/yiisoft/yii2/issues/5438 88 | * Actually, we should have a detach() too... 89 | */ 90 | public function attach($owner) 91 | { 92 | parent::attach($owner); 93 | $validators = $owner->validators; 94 | $validator = Validator::createValidator('safe', $owner, 'tags'); 95 | $validators[] = $validator; 96 | } 97 | 98 | /** 99 | * @return \yii\db\ActiveQuery 100 | */ 101 | public function getTagModels() 102 | { 103 | // Cannot use hasMany()->viaTable() here because the result cannot be ordered. 104 | /* @var $owner ActiveRecord */ 105 | $owner = $this->owner; 106 | $modelPk = $owner->primaryKey; // value 107 | 108 | $tc = $this->tagClass; 109 | $tpk = 't.' . $tc::primaryKey()[0]; // tag pk name 110 | 111 | $tkn = new Expression($owner->db->quoteSql("{{j}}.[[{$this->tagKeyColumn}]]")); 112 | 113 | return $tc::find()->alias('t') 114 | ->innerJoin($this->junctionTable . ' j', [ $tpk => $tkn ]) 115 | ->where([ "j.{$this->modelKeyColumn}" => $modelPk ]) 116 | ->orderBy("j.{$this->orderKeyColumn}"); 117 | } 118 | 119 | /** 120 | * @param $name 121 | * @return bool 122 | */ 123 | public function hasTag($name) 124 | { 125 | /* @var $owner ActiveRecord */ 126 | $owner = $this->owner; 127 | 128 | return ! is_null($this->getTagModels()->andWhere([ "t.{$this->nameAttribute}" => $name ])->one($owner->db)); 129 | } 130 | 131 | /** 132 | * @param array $linkOptions HTML options for links, may contain 'encoded' => false 133 | * @return string tag names as links, HTML encoded, separated by separator 134 | */ 135 | public function getTagLinks($linkOptions = []) 136 | { 137 | /* @var $owner ActiveRecord */ 138 | $owner = $this->owner; 139 | 140 | $links = array_filter(array_map(function($tag) use($linkOptions) { // filter empty links @link https://www.php.net/manual/en/function.array-filter.php 141 | /* @var $tag ActiveRecord */ 142 | return $tag->getLink($linkOptions); 143 | }, $this->getTagModels()->all($owner->db))); 144 | 145 | return implode($this->separator, $links); 146 | } 147 | 148 | /** 149 | * @inheritDoc 150 | */ 151 | public function events() 152 | { 153 | return [ 154 | ActiveRecord::EVENT_AFTER_FIND => 'afterFind', 155 | ActiveRecord::EVENT_AFTER_INSERT => 'afterSave', 156 | ActiveRecord::EVENT_AFTER_UPDATE => 'afterSave', 157 | ActiveRecord::EVENT_BEFORE_DELETE => 'beforeDelete', 158 | ]; 159 | } 160 | 161 | /** 162 | * @param $event \yii\base\Event 163 | * @throws \yii\db\Exception 164 | */ 165 | public function afterFind($event) 166 | { 167 | /* @var $owner ActiveRecord */ 168 | $owner = $this->owner; 169 | 170 | $names = array_map(function($v) { 171 | /* @var $v ActiveRecord */ 172 | return $v->getAttribute($this->nameAttribute); 173 | }, $this->getTagModels()->all($owner->db)); 174 | 175 | $this->tags = implode($this->delimiter, $names); 176 | } 177 | 178 | /** 179 | * @param $event \yii\db\AfterSaveEvent 180 | * @throws \yii\db\Exception 181 | */ 182 | public function afterSave($event) 183 | { 184 | $this->removeTags(); // remove old tags, if any 185 | 186 | $tc = $this->tagClass; 187 | $ids = array_filter(array_map(function($name) use ($tc) { 188 | $tag = $tc::findOne([ $this->nameAttribute => $name ] ); // does tag exist? 189 | 190 | if (is_null($tag)) { // no, create 191 | /* @var $tag ActiveRecord */ 192 | $tag = new $tc(); 193 | $tag->setAttribute($this->nameAttribute, $name); 194 | $tag->save(); 195 | } 196 | return $tag->primaryKey; 197 | }, explode($this->delimiter, $this->tags))); 198 | 199 | $this->insertTags($ids); 200 | } 201 | 202 | /** 203 | * @param $event \yii\base\Event 204 | * @throws \yii\db\Exception 205 | */ 206 | public function beforeDelete($event) 207 | { 208 | $this->removeTags(); 209 | } 210 | 211 | /** 212 | * Remove owner's tag links from junction table, if any 213 | * @throws \yii\db\Exception 214 | * @return int number of rows affected 215 | */ 216 | protected function removeTags() 217 | { 218 | /* @var $owner ActiveRecord */ 219 | $owner = $this->owner; 220 | $db = $owner->db; 221 | $modelPk = $owner->primaryKey; // value 222 | 223 | return $db->createCommand()->delete($this->junctionTable, [ 224 | $this->modelKeyColumn => $modelPk 225 | ])->execute(); 226 | } 227 | 228 | /** 229 | * @param $tagIds 230 | * @throws \yii\db\Exception 231 | * @return int number of rows affected 232 | */ 233 | protected function insertTags($tagIds) 234 | { 235 | /* @var $owner ActiveRecord */ 236 | $owner = $this->owner; 237 | $db = $owner->db; 238 | $modelPk = $owner->primaryKey; // value 239 | 240 | $rows = []; 241 | $ord = 0; 242 | 243 | foreach ($tagIds as $id) { 244 | $rows[] = [ 245 | $modelPk, 246 | $id, 247 | $ord 248 | ]; 249 | $ord++; 250 | } 251 | 252 | $sql = $db->queryBuilder->batchInsert($this->junctionTable, [ 253 | $this->modelKeyColumn, 254 | $this->tagKeyColumn, 255 | $this->orderKeyColumn 256 | ], $rows); 257 | 258 | return $db->createCommand($sql)->execute(); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /assets/jquery.caret.min.js: -------------------------------------------------------------------------------- 1 | // http://code.accursoft.com/caret - 1.3.3 2 | !function(e){e.fn.caret=function(e){var t=this[0],n="true"===t.contentEditable;if(0==arguments.length){if(window.getSelection){if(n){t.focus();var o=window.getSelection().getRangeAt(0),r=o.cloneRange();return r.selectNodeContents(t),r.setEnd(o.endContainer,o.endOffset),r.toString().length}return t.selectionStart}if(document.selection){if(t.focus(),n){var o=document.selection.createRange(),r=document.body.createTextRange();return r.moveToElementText(t),r.setEndPoint("EndToEnd",o),r.text.length}var e=0,c=t.createTextRange(),r=document.selection.createRange().duplicate(),a=r.getBookmark();for(c.moveToBookmark(a);0!==c.moveStart("character",-1);)e++;return e}return t.selectionStart?t.selectionStart:0}if(-1==e&&(e=this[n?"text":"val"]().length),window.getSelection)n?(t.focus(),window.getSelection().collapse(t.firstChild,e)):t.setSelectionRange(e,e);else if(document.body.createTextRange)if(n){var c=document.body.createTextRange();c.moveToElementText(t),c.moveStart("character",e),c.collapse(!0),c.select()}else{var c=t.createTextRange();c.move("character",e),c.select()}return n||t.focus(),e}}(jQuery); 3 | -------------------------------------------------------------------------------- /assets/jquery.tag-editor.css: -------------------------------------------------------------------------------- 1 | /* surrounding tag container */ 2 | .tag-editor { 3 | list-style-type: none; padding: 0 5px 0 0; margin: 0; overflow: hidden; border: 1px solid #eee; cursor: text; 4 | font: normal 14px sans-serif; color: #555; background: #fff; line-height: 20px; 5 | } 6 | 7 | /* core styles usually need no change */ 8 | .tag-editor li { display: block; float: left; overflow: hidden; margin: 3px 0; } 9 | .tag-editor div { float: left; padding: 0 4px; } 10 | .tag-editor .placeholder { padding: 0 8px; color: #bbb; } 11 | .tag-editor .tag-editor-spacer { padding: 0; width: 8px; overflow: hidden; color: transparent; background: none; } 12 | .tag-editor input { 13 | vertical-align: inherit; border: 0; outline: none; padding: 0; margin: 0; cursor: text; 14 | font-family: inherit; font-weight: inherit; font-size: inherit; font-style: inherit; 15 | box-shadow: none; background: none; color: #444; 16 | } 17 | /* hide original input field or textarea visually to allow tab navigation */ 18 | .tag-editor-hidden-src { position: absolute !important; left: -99999px; } 19 | /* hide IE10 "clear field" X */ 20 | .tag-editor ::-ms-clear { display: none; } 21 | 22 | /* tag style */ 23 | .tag-editor .tag-editor-tag { 24 | padding-left: 5px; color: #46799b; background: #e0eaf1; white-space: nowrap; 25 | overflow: hidden; cursor: pointer; border-radius: 2px 0 0 2px; 26 | } 27 | 28 | /* delete icon */ 29 | .tag-editor .tag-editor-delete { background: #e0eaf1; cursor: pointer; border-radius: 0 2px 2px 0; padding-left: 3px; padding-right: 4px; } 30 | .tag-editor .tag-editor-delete i { line-height: 18px; display: inline-block; } 31 | .tag-editor .tag-editor-delete i:before { font-size: 16px; color: #8ba7ba; content: "×"; font-style: normal; } 32 | .tag-editor .tag-editor-delete:hover i:before { color: #d65454; } 33 | .tag-editor .tag-editor-tag.active+.tag-editor-delete, .tag-editor .tag-editor-tag.active+.tag-editor-delete i { visibility: hidden; cursor: text; } 34 | 35 | .tag-editor .tag-editor-tag.active { background: none !important; } 36 | 37 | /* jQuery UI autocomplete - code.jquery.com/ui/1.10.2/themes/smoothness/jquery-ui.css */ 38 | .ui-autocomplete { position: absolute; top: 0; left: 0; cursor: default; font-size: 14px; } 39 | .ui-front { z-index: 9999; } 40 | .ui-menu { list-style: none; padding: 1px; margin: 0; display: block; outline: none; } 41 | .ui-menu .ui-menu-item a { text-decoration: none; display: block; padding: 2px .4em; line-height: 1.4; min-height: 0; /* support: IE7 */ } 42 | .ui-widget-content { border: 1px solid #bbb; background: #fff; color: #555; } 43 | .ui-widget-content a { color: #46799b; } 44 | .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { background: #e0eaf1; } 45 | .ui-helper-hidden-accessible { display: none; } 46 | -------------------------------------------------------------------------------- /assets/jquery.tag-editor.min.js: -------------------------------------------------------------------------------- 1 | // jQuery tagEditor v1.0.21 2 | // https://github.com/Pixabay/jQuery-tagEditor 3 | !function(t){t.fn.tagEditorInput=function(){var e=" ",i=t(this),a=parseInt(i.css("fontSize")),r=t("").css({position:"absolute",top:-9999,left:-9999,width:"auto",fontSize:i.css("fontSize"),fontFamily:i.css("fontFamily"),fontWeight:i.css("fontWeight"),letterSpacing:i.css("letterSpacing"),whiteSpace:"nowrap"}),l=function(){if(e!==(e=i.val())){r.text(e);var t=r.width()+a;20>t&&(t=20),t!=i.width()&&i.width(t)}};return r.insertAfter(i),i.bind("keyup keydown focus",l)},t.fn.tagEditor=function(e,a,r){function l(t){return t.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}var n,o=t.extend({},t.fn.tagEditor.defaults,e),c=this;if(o.dregex=new RegExp("["+o.delimiter.replace("-","-")+"]","g"),"string"==typeof e){var s=[];return c.each(function(){var i=t(this),l=i.data("options"),n=i.next(".tag-editor");if("getTags"==e)s.push({field:i[0],editor:n,tags:n.data("tags")});else if("addTag"==e){if(l.maxTags&&n.data("tags").length>=l.maxTags)return!1;t('
  •  '+l.delimiter[0]+'
  • ').appendTo(n).find(".tag-editor-tag").html('').addClass("active").find("input").val(a).blur(),r?t(".placeholder",n).remove():n.click()}else"removeTag"==e?(t(".tag-editor-tag",n).filter(function(){return t(this).text()==a}).closest("li").find(".tag-editor-delete").click(),r||n.click()):"destroy"==e&&i.removeClass("tag-editor-hidden-src").removeData("options").off("focus.tag-editor").next(".tag-editor").remove()}),"getTags"==e?s:this}return window.getSelection&&t(document).off("keydown.tag-editor").on("keydown.tag-editor",function(e){if(8==e.which||46==e.which||e.ctrlKey&&88==e.which){try{var a=getSelection(),r="INPUT"!=document.activeElement.tagName?t(a.getRangeAt(0).startContainer.parentNode).closest(".tag-editor"):0}catch(e){r=0}if(a.rangeCount>0&&r&&r.length){var l=[],n=a.toString().split(r.prev().data("options").dregex);for(i=0;i
    '+o.placeholder+"
    ")}function i(i){var a=c.toString();c=t(".tag-editor-tag:not(.deleted)",s).map(function(e,i){var a=t.trim(t(this).hasClass("active")?t(this).find("input").val():t(i).text());return a?a:void 0}).get(),s.data("tags",c),r.val(c.join(o.delimiter[0])),i||a!=c.toString()&&o.onChange(r,s,c),e()}function a(e){for(var a,n=e.closest("li"),d=e.val().replace(/ +/," ").split(o.dregex),g=e.data("old_tag"),f=c.slice(0),h=!1,u=0;u
     '+o.delimiter[0]+'
    '+l(v)+'
    '),o.maxTags&&f.length>=o.maxTags)){h=!0;break}e.attr("maxlength",o.maxLength).removeData("old_tag").val(""),h?e.blur():e.focus(),i()}var r=t(this),c=[],s=t("
      ').insertAfter(r);r.addClass("tag-editor-hidden-src").data("options",o).on("focus.tag-editor",function(){s.click()}),s.append('
    •  
    • ');var d='
    •  '+o.delimiter[0]+'
    • ';s.click(function(e,i){var a,r,l=99999;if(!window.getSelection||""==getSelection())return o.maxTags&&s.data("tags").length>=o.maxTags?(s.find("input").blur(),!1):(n=!0,t("input:focus",s).blur(),n?(n=!0,t(".placeholder",s).remove(),i&&i.length?r="before":t(".tag-editor-tag",s).each(function(){var n=t(this),o=n.offset(),c=o.left,s=o.top;e.pageY>=s&&e.pageY<=s+n.height()&&(e.pageXa&&(l=a,i=n))}),"before"==r?t(d).insertBefore(i.closest("li")).find(".tag-editor-tag").click():"after"==r?t(d).insertAfter(i.closest("li")).find(".tag-editor-tag").click():t(d).appendTo(s).find(".tag-editor-tag").click(),!1):!1)}),s.on("click",".tag-editor-delete",function(){if(t(this).prev().hasClass("active"))return t(this).closest("li").find("input").caret(-1),!1;var a=t(this).closest("li"),l=a.find(".tag-editor-tag");return o.beforeTagDelete(r,s,c,l.text())===!1?!1:(l.addClass("deleted").animate({width:0},o.animateDelete,function(){a.remove(),e()}),i(),!1)}),o.clickDelete&&s.on("mousedown",".tag-editor-tag",function(a){if(a.ctrlKey||a.which>1){var l=t(this).closest("li"),n=l.find(".tag-editor-tag");return o.beforeTagDelete(r,s,c,n.text())===!1?!1:(n.addClass("deleted").animate({width:0},o.animateDelete,function(){l.remove(),e()}),i(),!1)}}),s.on("click",".tag-editor-tag",function(e){if(o.clickDelete&&(e.ctrlKey||e.which>1))return!1;if(!t(this).hasClass("active")){var i=t(this).text(),a=Math.abs((t(this).offset().left-e.pageX)/t(this).width()),r=parseInt(i.length*a),n=t(this).html('').addClass("active").find("input");if(n.data("old_tag",i).tagEditorInput().focus().caret(r),o.autocomplete){var c=t.extend({},o.autocomplete),d="select"in c?o.autocomplete.select:"";c.select=function(e,i){d&&d(e,i),setTimeout(function(){s.trigger("click",[t(".active",s).find("input").closest("li").next("li").find(".tag-editor-tag")])},20)},n.autocomplete(c)}}return!1}),s.on("blur","input",function(d){d.stopPropagation();var g=t(this),f=g.data("old_tag"),h=t.trim(g.val().replace(/ +/," ").replace(o.dregex,o.delimiter[0]));if(h){if(h.indexOf(o.delimiter[0])>=0)return void a(g);if(h!=f)if(o.forceLowercase&&(h=h.toLowerCase()),cb_val=o.beforeTagSave(r,s,c,f,h),h=cb_val||h,cb_val===!1){if(f)return g.val(f).focus(),n=!1,void i();try{g.closest("li").remove()}catch(d){}f&&i()}else o.removeDuplicates&&t(".tag-editor-tag:not(.active)",s).each(function(){t(this).text()==h&&t(this).closest("li").remove()})}else{if(f&&o.beforeTagDelete(r,s,c,f)===!1)return g.val(f).focus(),n=!1,void i();try{g.closest("li").remove()}catch(d){}f&&i()}g.parent().html(l(h)).removeClass("active"),h!=f&&i(),e()});var g;s.on("paste","input",function(){t(this).removeAttr("maxlength"),g=t(this),setTimeout(function(){a(g)},30)});var f;s.on("keypress","input",function(e){o.delimiter.indexOf(String.fromCharCode(e.which))>=0&&(f=t(this),setTimeout(function(){a(f)},20))}),s.on("keydown","input",function(e){var i=t(this);if((37==e.which||!o.autocomplete&&38==e.which)&&!i.caret()||8==e.which&&!i.val()){var a=i.closest("li").prev("li").find(".tag-editor-tag");return a.length?a.click().find("input").caret(-1):!i.val()||o.maxTags&&s.data("tags").length>=o.maxTags||t(d).insertBefore(i.closest("li")).find(".tag-editor-tag").click(),!1}if((39==e.which||!o.autocomplete&&40==e.which)&&i.caret()==i.val().length){var l=i.closest("li").next("li").find(".tag-editor-tag");return l.length?l.click().find("input").caret(0):i.val()&&s.click(),!1}if(9==e.which){if(e.shiftKey){var a=i.closest("li").prev("li").find(".tag-editor-tag");if(a.length)a.click().find("input").caret(0);else{if(!i.val()||o.maxTags&&s.data("tags").length>=o.maxTags)return r.attr("disabled","disabled"),void setTimeout(function(){r.removeAttr("disabled")},30);t(d).insertBefore(i.closest("li")).find(".tag-editor-tag").click()}return!1}var l=i.closest("li").next("li").find(".tag-editor-tag");if(l.length)l.click().find("input").caret(0);else{if(!i.val())return;s.click()}return!1}if(!(46!=e.which||t.trim(i.val())&&i.caret()!=i.val().length)){var l=i.closest("li").next("li").find(".tag-editor-tag");return l.length?l.click().find("input").caret(0):i.val()&&s.click(),!1}if(13==e.which)return s.trigger("click",[i.closest("li").next("li").find(".tag-editor-tag")]),o.maxTags&&s.data("tags").length>=o.maxTags&&s.find("input").blur(),!1;if(36!=e.which||i.caret()){if(35==e.which&&i.caret()==i.val().length)s.find(".tag-editor-tag").last().click();else if(27==e.which)return i.val(i.data("old_tag")?i.data("old_tag"):"").blur(),!1}else s.find(".tag-editor-tag").first().click()});for(var h=o.initialTags.length?o.initialTags:r.val().split(o.dregex),u=0;u=o.maxTags);u++){var v=t.trim(h[u].replace(/ +/," "));v&&(o.forceLowercase&&(v=v.toLowerCase()),c.push(v),s.append('
    •  '+o.delimiter[0]+'
      '+l(v)+'
    • '))}i(!0),o.sortable&&t.fn.sortable&&s.sortable({distance:5,cancel:".tag-editor-spacer, input",helper:"clone",update:function(){i()}})})},t.fn.tagEditor.defaults={initialTags:[],maxTags:0,maxLength:50,delimiter:",;",placeholder:"",forceLowercase:!0,removeDuplicates:!0,clickDelete:!1,animateDelete:175,sortable:!0,autocomplete:null,onChange:function(){},beforeTagSave:function(){},beforeTagDelete:function(){}}}(jQuery); -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sjaakp/yii2-taggable", 3 | "description": "Manage tags of ActiveRecord in Yii2.", 4 | "keywords": ["yii", "yii2", "extension", "widget", "behavior", "tag", "tags"], 5 | "type": "yii2-extension", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Sjaak Priester", 10 | "email": "sjaak@sjaakpriester.nl", 11 | "homepage": "https://sjaakpriester.nl" 12 | } 13 | ], 14 | "require": { 15 | "yiisoft/yii2": "*", 16 | "yiisoft/yii2-jui": "*" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "sjaakp\\taggable\\": "" 21 | } 22 | } 23 | } 24 | --------------------------------------------------------------------------------