Skip to content

Commit

Permalink
Adds tag / link autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
mgmeyers committed Apr 24, 2021
1 parent 9f1a985 commit 2ee1d4a
Show file tree
Hide file tree
Showing 9 changed files with 320 additions and 17 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 22 additions & 3 deletions src/components/Item/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<HTMLTextAreaElement>();
const autocompleteVisibilityRef = React.useRef<boolean>(false);
const outputRef = React.useRef<HTMLDivElement>();

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]);

Expand Down Expand Up @@ -82,15 +95,21 @@ export function ItemContent({
className={c("item-input")}
value={item.title}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyDown={(e) => {
if (autocompleteVisibilityRef.current) return;
onKeyDown(e);
}}
/>
</div>
);
}

return (
<div onClick={onClick} className={c("item-title")}>
<div className={c("item-markdown")} ref={outputRef} />
<div
className={`markdown-preview-view ${c("item-markdown")}`}
ref={outputRef}
/>
</div>
);
}
Expand Down
38 changes: 28 additions & 10 deletions src/components/Item/ItemForm.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);
const inputRef = React.useRef<HTMLTextAreaElement>();

React.useEffect(() => {
if (isInputVisible) {
inputRef.current?.focus();
if (isInputVisible && inputRef.current) {
inputRef.current.focus();

return constructAutocomplete({
inputRef,
autocompleteVisibilityRef,
obsidianContext,
});
}
}, [isInputVisible]);

Expand All @@ -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) {
Expand All @@ -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();
Expand Down
94 changes: 94 additions & 0 deletions src/components/Item/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement>;
autocompleteVisibilityRef: React.MutableRefObject<boolean>;
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<string>[]) => void
) => {
callback(tagSearch.search(term));
},
template: (result: Fuse.FuseResult<string>) => {
return result.item;
},
replace: (result: Fuse.FuseResult<string>): string => `${result.item} `,
},
{
id: "link",
match: /\B\[\[(.*)$/,
index: 1,
template: (res: Fuse.FuseResult<TFile>) => {
return view.app.metadataCache.fileToLinktext(res.item, filePath);
},
search: async (
term: string,
callback: (results: Fuse.FuseResult<TFile>[]) => void
) => {
callback(fileSearch.search(term));
},
replace: (result: Fuse.FuseResult<TFile>): 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();
};
}
2 changes: 1 addition & 1 deletion src/components/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
63 changes: 62 additions & 1 deletion src/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 0 additions & 1 deletion src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
declare module "react-trello";
Loading

0 comments on commit 2ee1d4a

Please sign in to comment.