From 2ee1d4a4bf13dd7112c2ab1b1ad4124a4889612e Mon Sep 17 00:00:00 2001 From: mgmeyers Date: Sat, 24 Apr 2021 15:13:23 -0700 Subject: [PATCH] Adds tag / link autocomplete --- package.json | 3 + src/components/Item/Item.tsx | 25 +++++++- src/components/Item/ItemForm.tsx | 38 +++++++++--- src/components/Item/autocomplete.ts | 94 +++++++++++++++++++++++++++++ src/components/context.ts | 2 +- src/main.css | 63 ++++++++++++++++++- src/types.d.ts | 1 - styles.css | 63 ++++++++++++++++++- yarn.lock | 48 +++++++++++++++ 9 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 src/components/Item/autocomplete.ts diff --git a/package.json b/package.json index 46e6aec0..a253f6d6 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,13 @@ "@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-replace": "^2.4.2", + "@textcomplete/core": "^0.1.9", + "@textcomplete/textarea": "^0.1.9", "@types/react-beautiful-dnd": "^13.0.0", "@types/react-dom": "^17.0.3", "babel": "^6.23.0", "dotenv": "^8.2.0", + "fuse.js": "^6.4.6", "immutability-helper": "^3.1.1", "lodash": "^4.17.21", "monkey-around": "^2.1.0", diff --git a/src/components/Item/Item.tsx b/src/components/Item/Item.tsx index bdf6e3cd..9282d6ae 100644 --- a/src/components/Item/Item.tsx +++ b/src/components/Item/Item.tsx @@ -6,10 +6,14 @@ import { DraggableStateSnapshot, DraggableRubric, } from "react-beautiful-dnd"; +import { Textcomplete } from "@textcomplete/core"; +import { TextareaEditor } from "@textcomplete/textarea"; + import { Item } from "../types"; import { c } from "../helpers"; import { Icon } from "../Icon/Icon"; import { KanbanContext, ObsidianContext } from "../context"; +import { constructAutocomplete } from "./autocomplete"; export interface ItemContentProps { item: Item; @@ -24,16 +28,25 @@ export function ItemContent({ onChange, onKeyDown, }: ItemContentProps) { - const { filePath, view } = React.useContext(ObsidianContext); + const obsidianContext = React.useContext(ObsidianContext); const inputRef = React.useRef(); + const autocompleteVisibilityRef = React.useRef(false); const outputRef = React.useRef(); + const { view, filePath } = obsidianContext; + React.useEffect(() => { if (isSettingsVisible && inputRef.current) { const input = inputRef.current; input.focus(); input.selectionStart = input.selectionEnd = input.value.length; + + return constructAutocomplete({ + inputRef, + autocompleteVisibilityRef, + obsidianContext, + }); } }, [isSettingsVisible]); @@ -82,7 +95,10 @@ export function ItemContent({ className={c("item-input")} value={item.title} onChange={onChange} - onKeyDown={onKeyDown} + onKeyDown={(e) => { + if (autocompleteVisibilityRef.current) return; + onKeyDown(e); + }} /> ); @@ -90,7 +106,10 @@ export function ItemContent({ return (
-
+
); } diff --git a/src/components/Item/ItemForm.tsx b/src/components/Item/ItemForm.tsx index 60170520..8411694b 100644 --- a/src/components/Item/ItemForm.tsx +++ b/src/components/Item/ItemForm.tsx @@ -1,20 +1,32 @@ import React from "react"; + import { Item } from "../types"; import { c, generateInstanceId } from "../helpers"; +import { ObsidianContext } from "../context"; +import { constructAutocomplete } from "./autocomplete"; interface ItemFormProps { addItem: (item: Item) => void; } export function ItemForm({ addItem }: ItemFormProps) { + const obsidianContext = React.useContext(ObsidianContext); + const [isInputVisible, setIsInputVisible] = React.useState(false); const [itemTitle, setItemTitle] = React.useState(""); + const autocompleteVisibilityRef = React.useRef(false); const inputRef = React.useRef(); React.useEffect(() => { - if (isInputVisible) { - inputRef.current?.focus(); + if (isInputVisible && inputRef.current) { + inputRef.current.focus(); + + return constructAutocomplete({ + inputRef, + autocompleteVisibilityRef, + obsidianContext, + }); } }, [isInputVisible]); @@ -24,14 +36,17 @@ export function ItemForm({ addItem }: ItemFormProps) { }; const createItem = () => { - const newItem: Item = { - id: generateInstanceId(), - title: itemTitle, - data: {}, - }; + const title = itemTitle.trim(); - addItem(newItem); - setItemTitle(""); + if (title) { + const newItem: Item = { + id: generateInstanceId(), + title, + data: {}, + }; + + addItem(newItem); + } }; if (isInputVisible) { @@ -46,7 +61,10 @@ export function ItemForm({ addItem }: ItemFormProps) { className={c("item-input")} placeholder="Item title..." onChange={(e) => setItemTitle(e.target.value)} - onKeyDown={(e) => { + onKeyDownCapture={(e) => { + // Using onKeyDownCapture to take precedence over autocomplete + if (autocompleteVisibilityRef.current) return; + if (e.key === "Enter") { e.preventDefault(); createItem(); diff --git a/src/components/Item/autocomplete.ts b/src/components/Item/autocomplete.ts new file mode 100644 index 00000000..e68398cd --- /dev/null +++ b/src/components/Item/autocomplete.ts @@ -0,0 +1,94 @@ +import { TFile } from "obsidian"; +import React from "react"; + +import { Textcomplete } from "@textcomplete/core"; +import { TextareaEditor } from "@textcomplete/textarea"; +import Fuse from "fuse.js"; +import { c } from "../helpers"; +import { ObsidianContextProps } from "../context"; + +export interface ConstructAutocompleteParams { + inputRef: React.MutableRefObject; + autocompleteVisibilityRef: React.MutableRefObject; + obsidianContext: ObsidianContextProps; +} + +export function constructAutocomplete({ + inputRef, + autocompleteVisibilityRef, + obsidianContext, +}: ConstructAutocompleteParams) { + const { view, filePath } = obsidianContext; + + const tagSearch = new Fuse( + Object.keys((view.app.metadataCache as any).getTags()).sort() + ); + + const fileSearch = new Fuse(view.app.vault.getMarkdownFiles(), { + keys: ["name"], + }); + + const editor = new TextareaEditor(inputRef.current); + const autocomplete = new Textcomplete( + editor, + [ + { + id: "tag", + match: /\B#(.*)$/, + index: 1, + search: async ( + term: string, + callback: (results: Fuse.FuseResult[]) => void + ) => { + callback(tagSearch.search(term)); + }, + template: (result: Fuse.FuseResult) => { + return result.item; + }, + replace: (result: Fuse.FuseResult): string => `${result.item} `, + }, + { + id: "link", + match: /\B\[\[(.*)$/, + index: 1, + template: (res: Fuse.FuseResult) => { + return view.app.metadataCache.fileToLinktext(res.item, filePath); + }, + search: async ( + term: string, + callback: (results: Fuse.FuseResult[]) => void + ) => { + callback(fileSearch.search(term)); + }, + replace: (result: Fuse.FuseResult): string => + `[[${view.app.metadataCache.fileToLinktext( + result.item, + filePath + )}]] `, + }, + ], + { + dropdown: { + className: c("autocomplete"), + rotate: true, + item: { + className: c("autocomplete-item"), + activeClassName: c("autocomplete-item-active"), + }, + }, + } + ); + + autocomplete.on("shown", () => { + autocompleteVisibilityRef.current = true; + }); + + autocomplete.on("hide", () => { + autocompleteVisibilityRef.current = false; + }); + + return () => { + autocomplete.destroy(); + editor.destroy(); + }; +} diff --git a/src/components/context.ts b/src/components/context.ts index d6207b4e..14d3c3c1 100644 --- a/src/components/context.ts +++ b/src/components/context.ts @@ -2,7 +2,7 @@ import React from "react"; import { KanbanView } from "src/KanbanView"; import { Board, BoardModifiers } from "./types"; -interface ObsidianContextProps { +export interface ObsidianContextProps { filePath?: string; view?: KanbanView; } diff --git a/src/main.css b/src/main.css index 19038fc9..5d187c72 100644 --- a/src/main.css +++ b/src/main.css @@ -439,10 +439,71 @@ button.kanban-plugin__cancel-action-button { .kanban-plugin__grow-wrap > textarea, .kanban-plugin__grow-wrap::after { - /* Identical styling required!! */ + /* Identical styling required! */ border: 1px solid var(--background-modifier-border); padding: 5px 7px; font: inherit; grid-area: 1 / 1 / 2 / 2; font-size: 0.875rem; } + +.kanban-plugin + .kanban-plugin__item + .markdown-preview-view.kanban-plugin__item-markdown { + padding: unset; + width: unset; + height: unset; + position: unset; + overflow-y: unset; + overflow-wrap: break-word; + color: unset; + user-select: unset; + -webkit-user-select: unset; +} + +.kanban-plugin__autocomplete { + font-size: 0.875rem; + border: 1px solid var(--background-modifier-border); + background-color: var(--background-primary); + list-style: none; + padding: 0; + margin: 0; + min-width: 20ch; + max-width: 40ch; + max-height: 200px; + border-radius: 6px; + box-shadow: 0px 15px 25px rgba(0, 0, 0, 0.2); + overflow-x: hidden; + overflow-y: auto; +} + +.kanban-plugin__autocomplete li { + margin: 0; +} + +.kanban-plugin .textcomplete-footer, +.kanban-plugin .textcomplete-header { + display: none; +} + +.kanban-plugin__autocomplete-item-active, +.kanban-plugin__autocomplete-item { + border-top: 1px solid var(--background-modifier-border); + padding: 4px 6px; + cursor: pointer; +} + +.kanban-plugin__autocomplete + .textcomplete-header + + .kanban-plugin__autocomplete-item, +.kanban-plugin__autocomplete + .textcomplete-header + + .kanban-plugin__autocomplete-item-active { + border-top: none; +} + +.kanban-plugin__autocomplete-item:hover, +.kanban-plugin__autocomplete-item-active { + color: var(--text-on-accent); + background-color: var(--interactive-accent-hover); +} diff --git a/src/types.d.ts b/src/types.d.ts index d2cb8f25..e69de29b 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1 +0,0 @@ -declare module "react-trello"; \ No newline at end of file diff --git a/styles.css b/styles.css index 19038fc9..5d187c72 100644 --- a/styles.css +++ b/styles.css @@ -439,10 +439,71 @@ button.kanban-plugin__cancel-action-button { .kanban-plugin__grow-wrap > textarea, .kanban-plugin__grow-wrap::after { - /* Identical styling required!! */ + /* Identical styling required! */ border: 1px solid var(--background-modifier-border); padding: 5px 7px; font: inherit; grid-area: 1 / 1 / 2 / 2; font-size: 0.875rem; } + +.kanban-plugin + .kanban-plugin__item + .markdown-preview-view.kanban-plugin__item-markdown { + padding: unset; + width: unset; + height: unset; + position: unset; + overflow-y: unset; + overflow-wrap: break-word; + color: unset; + user-select: unset; + -webkit-user-select: unset; +} + +.kanban-plugin__autocomplete { + font-size: 0.875rem; + border: 1px solid var(--background-modifier-border); + background-color: var(--background-primary); + list-style: none; + padding: 0; + margin: 0; + min-width: 20ch; + max-width: 40ch; + max-height: 200px; + border-radius: 6px; + box-shadow: 0px 15px 25px rgba(0, 0, 0, 0.2); + overflow-x: hidden; + overflow-y: auto; +} + +.kanban-plugin__autocomplete li { + margin: 0; +} + +.kanban-plugin .textcomplete-footer, +.kanban-plugin .textcomplete-header { + display: none; +} + +.kanban-plugin__autocomplete-item-active, +.kanban-plugin__autocomplete-item { + border-top: 1px solid var(--background-modifier-border); + padding: 4px 6px; + cursor: pointer; +} + +.kanban-plugin__autocomplete + .textcomplete-header + + .kanban-plugin__autocomplete-item, +.kanban-plugin__autocomplete + .textcomplete-header + + .kanban-plugin__autocomplete-item-active { + border-top: none; +} + +.kanban-plugin__autocomplete-item:hover, +.kanban-plugin__autocomplete-item-active { + color: var(--text-on-accent); + background-color: var(--interactive-accent-hover); +} diff --git a/yarn.lock b/yarn.lock index 3e42df98..bfeaf045 100644 --- a/yarn.lock +++ b/yarn.lock @@ -425,6 +425,29 @@ estree-walker "^1.0.1" picomatch "^2.2.2" +"@textcomplete/core@^0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@textcomplete/core/-/core-0.1.9.tgz#1a09c0f4672fc105064306b0d0c7fcbec2e1686e" + integrity sha512-fiVO93dwfFqgvPdyEL5QgYLAZjaWfvLC4HqgrPLbASqSPHJkFbGy3568aRvCc2EkiU8DAnhtlVoUKqK9oui16w== + dependencies: + eventemitter3 "^4.0.4" + +"@textcomplete/textarea@^0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@textcomplete/textarea/-/textarea-0.1.9.tgz#3f3ab3821a801dbdb6d2251401c6fee816b8ef68" + integrity sha512-3zZjfOyuItafGVvsz68eL1OXxadQ89IO5BAsYRYs3dBW+x1SvaTPgY1bbCIaHmOPWMZnJRDHxKfrK8j4KcIa5Q== + dependencies: + "@textcomplete/core" "^0.1.9" + "@textcomplete/utils" "^0.1.9" + "@types/textarea-caret" "^3.0.0" + textarea-caret "^3.1.0" + undate "^0.3.0" + +"@textcomplete/utils@^0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@textcomplete/utils/-/utils-0.1.9.tgz#0115926cfda8cfbdbdce8c65765e2d223a7803d0" + integrity sha512-3yqiEcYlR0OS15PTpp2G8fVpl+4Zhdh8+VRIrdiC6C1bklVB1q3K29OTAZVB5UNAGV3oUcGs8znWVXPt9LELbw== + "@types/codemirror@0.0.108": version "0.0.108" resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-0.0.108.tgz#e640422b666bf49251b384c390cdeb2362585bde" @@ -532,6 +555,11 @@ dependencies: "@types/estree" "*" +"@types/textarea-caret@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/textarea-caret/-/textarea-caret-3.0.0.tgz#4c5c5e3de5c59511f93ffe929e5383471b828896" + integrity sha512-RNXko6Kl+oQibqxuQZJZ+RgsQAGez4VQxeC4zq+GXjUlHcfjy5EthnsPIQXC5wUSRf5WbyA+4+//mVSc6XwxNw== + ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -733,6 +761,11 @@ estree-walker@^2.0.1: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== +eventemitter3@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + fast-glob@^3.0.3: version "3.2.5" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.5.tgz#7939af2a656de79a4f1901903ee8adcaa7cb9661" @@ -783,6 +816,11 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +fuse.js@^6.4.6: + version "6.4.6" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.4.6.tgz#62f216c110e5aa22486aff20be7896d19a059b79" + integrity sha512-/gYxR/0VpXmWSfZOIPS3rWwU8SHgsRTwWuXhyb2O6s7aRuVtHtxCkR33bNYu3wyLyNx/Wpv0vU7FZy8Vj53VNw== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -1283,6 +1321,11 @@ symbol-observable@^1.2.0: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== +textarea-caret@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/textarea-caret/-/textarea-caret-3.1.0.tgz#5d5a35bb035fd06b2ff0e25d5359e97f2655087f" + integrity sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q== + tiny-invariant@^1.0.6: version "1.1.0" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" @@ -1329,6 +1372,11 @@ typescript@^4.2.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== +undate@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/undate/-/undate-0.3.0.tgz#cbf6b1f179d69ace7393e6d92400c3afdf43d140" + integrity sha512-ssH8QTNBY6B+2fRr3stSQ+9m2NT8qTaun3ExTx5ibzYQvP7yX4+BnX0McNxFCvh6S5ia/DYu6bsCKQx/U4nb/Q== + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"