From 69c26f06709e871bab0d162670a09450c03f89a4 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sat, 26 Oct 2024 13:05:21 +0200 Subject: [PATCH 01/15] Use match-sorter for tag queries --- package-lock.json | 19 +++++++++++++++++-- package.json | 1 + src/scripts/db/TagService.ts | 17 ++++++++++++----- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9aa9108..7ee323e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", "lucide-react": "^0.446.0", + "match-sorter": "^7.0.0", "preline": "^2.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -2366,7 +2367,6 @@ "version": "7.25.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.4.tgz", "integrity": "sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==", - "dev": true, "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" @@ -11470,6 +11470,16 @@ "react": ">= 0.14.0" } }, + "node_modules/match-sorter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-7.0.0.tgz", + "integrity": "sha512-J1370vFVhvn81QrUYv54y3IZbsaG1X4otKSwtGZbyfZxgWgjVxdRkASY+uaT2IlQUGeWFBEPbGFVKv1DNnHYKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.8", + "remove-accents": "0.5.0" + } + }, "node_modules/mdast-util-definitions": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", @@ -14137,7 +14147,6 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, "license": "MIT" }, "node_modules/regenerator-transform": { @@ -14375,6 +14384,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/request-light": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", diff --git a/package.json b/package.json index ad1bc3e..2508c40 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", "lucide-react": "^0.446.0", + "match-sorter": "^7.0.0", "preline": "^2.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/scripts/db/TagService.ts b/src/scripts/db/TagService.ts index 092206a..1b9b7eb 100644 --- a/src/scripts/db/TagService.ts +++ b/src/scripts/db/TagService.ts @@ -2,6 +2,7 @@ import type { PlainTag, Tag } from "../../interfaces/Tag"; import { stringToHue, toLowerCase } from "../utils/stringUtils"; import type AppDB from "./AppDB"; import { db } from "./AppDB"; +import { matchSorter } from "match-sorter"; type TagToHueMap = Record; @@ -51,11 +52,17 @@ export class TagService { } public async find(query: string, limit = 20) { - return await this.db.tags - .where("name") - .startsWith(query) - .limit(limit) - .toArray(); + const tags = await this.db.tags.toArray(); + const matches = matchSorter(tags, query, { + keys: [ + "name", + { + key: "description", + maxRanking: matchSorter.rankings.STARTS_WITH, + }, + ], + }); + return matches.slice(0, limit); } public async listTags() { From ce397b7e7b46dbf6003f2ef76a108a38bc246ed9 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sat, 26 Oct 2024 15:27:21 +0200 Subject: [PATCH 02/15] Add new icon attribute to tags --- public/tag-icons/alert-triangle.svg | 1 + public/tag-icons/bookmark.svg | 1 + public/tag-icons/calendar.svg | 1 + public/tag-icons/gift.svg | 1 + public/tag-icons/hash.svg | 1 + public/tag-icons/heart.svg | 1 + public/tag-icons/help-circle.svg | 1 + public/tag-icons/info.svg | 1 + public/tag-icons/lock.svg | 1 + public/tag-icons/mail.svg | 1 + public/tag-icons/map-pin.svg | 1 + public/tag-icons/shield.svg | 1 + public/tag-icons/star.svg | 1 + public/tag-icons/user.svg | 1 + public/tag-icons/users.svg | 1 + public/tag-icons/zap.svg | 1 + src/common/components/Tag/Tag.tsx | 6 +- .../components/TextInput/TagList/TagList.tsx | 1 + .../components/TextInput/TextInput.config.tsx | 7 +- src/common/components/TextInput/TextInput.css | 4 - src/container/Settings/Settings.tsx | 1 + src/container/SparkList/SparkList.tsx | 1 + .../TagEditor/TagConfig/TagConfig.tsx | 1 + src/container/TagEditor/TagEditor.tsx | 8 -- src/interfaces/Tag.ts | 22 ++++++ src/scripts/db/AppDB.ts | 35 +++++++++ src/scripts/db/SparkService.ts | 8 +- src/scripts/db/TagService.ts | 20 +++-- src/scripts/utils/sparkUtils.ts | 42 ++++++++++ src/scripts/utils/tagUtils.ts | 16 ++++ src/styles/global.css | 78 +++++++++++++++++++ 31 files changed, 242 insertions(+), 24 deletions(-) create mode 100644 public/tag-icons/alert-triangle.svg create mode 100644 public/tag-icons/bookmark.svg create mode 100644 public/tag-icons/calendar.svg create mode 100644 public/tag-icons/gift.svg create mode 100644 public/tag-icons/hash.svg create mode 100644 public/tag-icons/heart.svg create mode 100644 public/tag-icons/help-circle.svg create mode 100644 public/tag-icons/info.svg create mode 100644 public/tag-icons/lock.svg create mode 100644 public/tag-icons/mail.svg create mode 100644 public/tag-icons/map-pin.svg create mode 100644 public/tag-icons/shield.svg create mode 100644 public/tag-icons/star.svg create mode 100644 public/tag-icons/user.svg create mode 100644 public/tag-icons/users.svg create mode 100644 public/tag-icons/zap.svg create mode 100644 src/scripts/utils/sparkUtils.ts create mode 100644 src/scripts/utils/tagUtils.ts diff --git a/public/tag-icons/alert-triangle.svg b/public/tag-icons/alert-triangle.svg new file mode 100644 index 0000000..567f8ce --- /dev/null +++ b/public/tag-icons/alert-triangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/bookmark.svg b/public/tag-icons/bookmark.svg new file mode 100644 index 0000000..7abf500 --- /dev/null +++ b/public/tag-icons/bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/calendar.svg b/public/tag-icons/calendar.svg new file mode 100644 index 0000000..5e3e58a --- /dev/null +++ b/public/tag-icons/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/gift.svg b/public/tag-icons/gift.svg new file mode 100644 index 0000000..14f3e2e --- /dev/null +++ b/public/tag-icons/gift.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/hash.svg b/public/tag-icons/hash.svg new file mode 100644 index 0000000..9a8f611 --- /dev/null +++ b/public/tag-icons/hash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/heart.svg b/public/tag-icons/heart.svg new file mode 100644 index 0000000..a3040ae --- /dev/null +++ b/public/tag-icons/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/help-circle.svg b/public/tag-icons/help-circle.svg new file mode 100644 index 0000000..5f4f00f --- /dev/null +++ b/public/tag-icons/help-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/info.svg b/public/tag-icons/info.svg new file mode 100644 index 0000000..5ac18ab --- /dev/null +++ b/public/tag-icons/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/lock.svg b/public/tag-icons/lock.svg new file mode 100644 index 0000000..a2e0434 --- /dev/null +++ b/public/tag-icons/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/mail.svg b/public/tag-icons/mail.svg new file mode 100644 index 0000000..8f65422 --- /dev/null +++ b/public/tag-icons/mail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/map-pin.svg b/public/tag-icons/map-pin.svg new file mode 100644 index 0000000..dfcd560 --- /dev/null +++ b/public/tag-icons/map-pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/shield.svg b/public/tag-icons/shield.svg new file mode 100644 index 0000000..35802ed --- /dev/null +++ b/public/tag-icons/shield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/star.svg b/public/tag-icons/star.svg new file mode 100644 index 0000000..f5d947f --- /dev/null +++ b/public/tag-icons/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/user.svg b/public/tag-icons/user.svg new file mode 100644 index 0000000..7e5330b --- /dev/null +++ b/public/tag-icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/users.svg b/public/tag-icons/users.svg new file mode 100644 index 0000000..2a0b7a2 --- /dev/null +++ b/public/tag-icons/users.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tag-icons/zap.svg b/public/tag-icons/zap.svg new file mode 100644 index 0000000..9ba28c6 --- /dev/null +++ b/public/tag-icons/zap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/components/Tag/Tag.tsx b/src/common/components/Tag/Tag.tsx index db49333..25ae7cd 100644 --- a/src/common/components/Tag/Tag.tsx +++ b/src/common/components/Tag/Tag.tsx @@ -3,20 +3,22 @@ import type React from "react"; export type TagProps = { name: string; hue: number; + icon?: string; }; export const Tag = (props: TagProps) => { - const { name, hue } = props; + const { name, hue, icon } = props; return ( - #{name} + {name} ); }; diff --git a/src/common/components/TextInput/TagList/TagList.tsx b/src/common/components/TextInput/TagList/TagList.tsx index 993caf8..0df133f 100644 --- a/src/common/components/TextInput/TagList/TagList.tsx +++ b/src/common/components/TextInput/TagList/TagList.tsx @@ -123,6 +123,7 @@ export const TagList = forwardRef((props, ref) => {
{ class: "tag", }, renderHTML({ options, node }) { + const tagName = node.attrs.id; return [ "span", mergeAttributes( { - style: `--tag-color: ${tagService.getTagHueFromCache(`${node.attrs.id}`)}`, + style: `--tag-color: ${tagService.getTagHueFromCache(`${tagName}`)}`, + "data-icon": + tagService.getTagIconFromCache(tagName), }, options.HTMLAttributes, ), - `${options.suggestion.char}${node.attrs.id}`, + `${tagName}`, ]; }, suggestion: { diff --git a/src/common/components/TextInput/TextInput.css b/src/common/components/TextInput/TextInput.css index 99c9879..297ee62 100644 --- a/src/common/components/TextInput/TextInput.css +++ b/src/common/components/TextInput/TextInput.css @@ -4,8 +4,4 @@ float: left; height: 0; pointer-events: none; -} - -.tiptap .tag { - display: inline-block; } \ No newline at end of file diff --git a/src/container/Settings/Settings.tsx b/src/container/Settings/Settings.tsx index 254b36e..cc40e14 100644 --- a/src/container/Settings/Settings.tsx +++ b/src/container/Settings/Settings.tsx @@ -52,6 +52,7 @@ export const Settings = () => { ], id: "manual-export", excludeAcceptAllOption: true, + suggestedName: `${format(new Date(), "yyyy-MM-dd")} Sparks Backup.json`, }); worker.postMessage(fileHandle); diff --git a/src/container/SparkList/SparkList.tsx b/src/container/SparkList/SparkList.tsx index ed536a0..ffede71 100644 --- a/src/container/SparkList/SparkList.tsx +++ b/src/container/SparkList/SparkList.tsx @@ -130,6 +130,7 @@ export const SparkList = () => { key={tag.name} name={tag.name} hue={tag.hue} + icon={tag.icon} /> ))}
diff --git a/src/container/TagEditor/TagConfig/TagConfig.tsx b/src/container/TagEditor/TagConfig/TagConfig.tsx index f3303df..388c761 100644 --- a/src/container/TagEditor/TagConfig/TagConfig.tsx +++ b/src/container/TagEditor/TagConfig/TagConfig.tsx @@ -42,6 +42,7 @@ export const TagConfig = (props: Props) => {
diff --git a/src/container/TagEditor/TagEditor.tsx b/src/container/TagEditor/TagEditor.tsx index bc8046a..31bb018 100644 --- a/src/container/TagEditor/TagEditor.tsx +++ b/src/container/TagEditor/TagEditor.tsx @@ -12,15 +12,7 @@ import { import { TagIcon } from "../../assets/icons/TagIcon"; import { useLiveQuery } from "dexie-react-hooks"; import { tagService } from "../../scripts/db/TagService"; -import React from "react"; -import { Tag } from "../../common/components/Tag/Tag"; import { TagConfig } from "./TagConfig/TagConfig"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "../../common/components/shadcn/popover"; -import { HueSlider } from "../../common/components/HueSlider/HueSlider"; export const TagEditor = () => { const tags = useLiveQuery(() => { diff --git a/src/interfaces/Tag.ts b/src/interfaces/Tag.ts index 5ab78d7..e955425 100644 --- a/src/interfaces/Tag.ts +++ b/src/interfaces/Tag.ts @@ -1,10 +1,32 @@ import { Entity, type InsertType } from "dexie"; import type AppDB from "../scripts/db/AppDB"; +export const TagIcons = [ + "alert-triangle", + "bookmark", + "calendar", + "gift", + "hash", + "heart", + "help-circle", + "info", + "lock", + "mail", + "map-pin", + "shield", + "star", + "user", + "users", + "zap", +] as const; + +type TagIcon = (typeof TagIcons)[number]; + export class Tag extends Entity { name!: string; hue!: number; description?: string; + icon?: TagIcon; } export type PlainTag = InsertType; diff --git a/src/scripts/db/AppDB.ts b/src/scripts/db/AppDB.ts index abccc07..54ea574 100644 --- a/src/scripts/db/AppDB.ts +++ b/src/scripts/db/AppDB.ts @@ -3,6 +3,7 @@ import { Spark } from "../../interfaces/Spark"; import type { FileSystemHandleStore } from "../../interfaces/FileSystemHandleStore"; import type { Tag } from "../../interfaces/Tag"; import { removeHash, stringToHue, toLowerCase } from "../utils/stringUtils"; +import { updateHtmlTagsOfSpark } from "../utils/sparkUtils"; export default class AppDB extends Dexie { tags!: Table>; @@ -11,6 +12,38 @@ export default class AppDB extends Dexie { constructor() { super("SparksDB"); + // nothing really changes in this version, but we need to update the existing tags in the html + this.version(9) + .stores({ + sparks: "++id, creationDate, plainText, tags, contextTags", + // because name is the first index, it is the primary key and unique + tags: "name, description", + // this is needed to store the FileSystemHandle for automatic backups + fileHandles: "++id, fileHandle", + }) + .upgrade(async (transaction) => { + console.debug("Upgrading Dexie Table to version 7"); + + const tagMap = new Map( + (await transaction.table("tags").toArray()).map( + (t: Tag) => [t.name, t] as [Tag["name"], Tag], + ), + ); + // remove '#' char from existing tags and make them lowercase + // and tags should now contain all tags, including the contextTags + transaction + .table("sparks") + .toCollection() + .modify((spark: Spark) => { + const { html, originalHtml } = updateHtmlTagsOfSpark( + spark, + tagMap, + ); + console.debug({ html, originalHtml }); + spark.html = html; + spark.originalHtml = originalHtml; + }); + }); this.version(6) .stores({ sparks: "++id, creationDate, plainText, tags, contextTags", @@ -73,3 +106,5 @@ export default class AppDB extends Dexie { } export const db = new AppDB(); +// biome-ignore lint/suspicious/noExplicitAny: +(window as any).db = db; diff --git a/src/scripts/db/SparkService.ts b/src/scripts/db/SparkService.ts index add421f..853234f 100644 --- a/src/scripts/db/SparkService.ts +++ b/src/scripts/db/SparkService.ts @@ -7,9 +7,15 @@ import { } from "../../interfaces/Spark"; import { parseSpark, stringToHue } from "../utils/stringUtils"; import { tagService } from "./TagService"; +import type { Tag } from "../../interfaces/Tag"; export class SparkService { - constructor(private db: AppDB) {} + constructor(private db: AppDB) { + // (async () => { + // const spark = await db.sparks.get(57); + // console.log(await this.updateHtmlTagsOfSpark(spark)); + // })(); + } public async addSpark(plainText: string, html: string) { const { tags, prefixTags, strippedPlainText, strippedHtml } = diff --git a/src/scripts/db/TagService.ts b/src/scripts/db/TagService.ts index 1b9b7eb..9093496 100644 --- a/src/scripts/db/TagService.ts +++ b/src/scripts/db/TagService.ts @@ -1,18 +1,23 @@ import type { PlainTag, Tag } from "../../interfaces/Tag"; import { stringToHue, toLowerCase } from "../utils/stringUtils"; +import { buildTagMap } from "../utils/tagUtils"; import type AppDB from "./AppDB"; import { db } from "./AppDB"; import { matchSorter } from "match-sorter"; -type TagToHueMap = Record; +export type TagMap = Map; export class TagService { - private tagToHueCache: TagToHueMap = {}; + private tagMap: TagMap = new Map(); constructor(private db: AppDB) { this.updateCache(); } + public async get(name: string) { + return await this.db.tags.get(name); + } + public async addIfNonExistent( name: string, hue: number, @@ -48,7 +53,11 @@ export class TagService { } public getTagHueFromCache(name: string) { - return this.tagToHueCache[name] ?? stringToHue(name); + return this.tagMap.get(name)?.hue ?? stringToHue(name); + } + + public getTagIconFromCache(name: string) { + return this.tagMap.get(name)?.icon ?? "hash"; } public async find(query: string, limit = 20) { @@ -71,10 +80,7 @@ export class TagService { private async updateCache() { const tags = await this.listTags(); - this.tagToHueCache = tags.reduce((tagToHue, tag) => { - tagToHue[tag.name] = tag.hue; - return tagToHue; - }, {}); + this.tagMap = buildTagMap(tags); } public async CAREFUL_deleteAllData() { diff --git a/src/scripts/utils/sparkUtils.ts b/src/scripts/utils/sparkUtils.ts new file mode 100644 index 0000000..e3c87e2 --- /dev/null +++ b/src/scripts/utils/sparkUtils.ts @@ -0,0 +1,42 @@ +import type { Spark } from "../../interfaces/Spark"; +import type { TagMap } from "../db/TagService"; + +export const updateHtmlTagsOfSpark = (spark: Spark, tagMap: TagMap) => { + const containerOriginalHtml = document.createElement("div"); + containerOriginalHtml.innerHTML = spark.originalHtml; + const updatedOriginalHtml = updateHtmlTags(containerOriginalHtml, tagMap); + + const containerHtml = document.createElement("div"); + containerHtml.innerHTML = spark.html; + const updatedHtml = updateHtmlTags(containerHtml, tagMap); + + return { + html: updatedHtml, + originalHtml: updatedOriginalHtml, + }; +}; + +const updateHtmlTags = (container: HTMLElement, tagMap: TagMap) => { + const tagNodes = Array.from( + container.querySelectorAll(".tag[data-type=tags]"), + ); + + for (const tagNode of tagNodes) { + if (tagNode.innerText.trim().startsWith("#")) { + tagNode.innerText = tagNode.innerText.replace("#", ""); + } + + const tagName = tagNode.dataset.id; + + if (!tagName) { + continue; + } + const icon = tagMap.get(tagName)?.icon ?? "hash"; + const hue = tagMap.get(tagName)?.hue ?? 25; + tagNode.dataset.icon = icon; + + tagNode.style.setProperty("--tag-color", `${hue}`); + } + + return container.innerHTML; +}; diff --git a/src/scripts/utils/tagUtils.ts b/src/scripts/utils/tagUtils.ts new file mode 100644 index 0000000..ec8c2ab --- /dev/null +++ b/src/scripts/utils/tagUtils.ts @@ -0,0 +1,16 @@ +import type { Spark } from "../../interfaces/Spark"; +import type { Tag } from "../../interfaces/Tag"; +import { tagService } from "../db/TagService"; + +export const buildTagMap = (tags: Tag[]) => + new Map(tags.map((t) => [t.name, t])); + +export const buildTagMapForSpark = async (spark: Spark) => { + return new Map( + buildTagMap( + ( + await Promise.all(spark.tags.map((t) => tagService.get(t))) + ).filter((t) => typeof t !== "undefined"), + ), + ); +}; diff --git a/src/styles/global.css b/src/styles/global.css index 9b4b00d..f809086 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -73,7 +73,85 @@ html { .tag { + display: inline-flex; + align-items: baseline; +} + +.tag::before { + content: ""; display: inline-block; + -webkit-mask-image: url(../../public/tag-icons/hash.svg); + mask-image: url(../../public/tag-icons/hash.svg); + background: currentColor; + width: 16px; + height: 16px; + margin-inline-end: 0px; + align-self: center; +} + +.tag[data-icon=alert-triangle]::before { + -webkit-mask-image: url(../../public/tag-icons/alert-triangle.svg); + mask-image: url(../../public/tag-icons/alert-triangle.svg); +} +.tag[data-icon=bookmark]::before { + -webkit-mask-image: url(../../public/tag-icons/bookmark.svg); + mask-image: url(../../public/tag-icons/bookmark.svg); +} +.tag[data-icon=calendar]::before { + -webkit-mask-image: url(../../public/tag-icons/calendar.svg); + mask-image: url(../../public/tag-icons/calendar.svg); +} +.tag[data-icon=gift]::before { + -webkit-mask-image: url(../../public/tag-icons/gift.svg); + mask-image: url(../../public/tag-icons/gift.svg); +} +.tag[data-icon=hash]::before { + -webkit-mask-image: url(../../public/tag-icons/hash.svg); + mask-image: url(../../public/tag-icons/hash.svg); +} +.tag[data-icon=heart]::before { + -webkit-mask-image: url(../../public/tag-icons/heart.svg); + mask-image: url(../../public/tag-icons/heart.svg); +} +.tag[data-icon=help-circle]::before { + -webkit-mask-image: url(../../public/tag-icons/help-circle.svg); + mask-image: url(../../public/tag-icons/help-circle.svg); +} +.tag[data-icon=info]::before { + -webkit-mask-image: url(../../public/tag-icons/info.svg); + mask-image: url(../../public/tag-icons/info.svg); +} +.tag[data-icon=lock]::before { + -webkit-mask-image: url(../../public/tag-icons/lock.svg); + mask-image: url(../../public/tag-icons/lock.svg); +} +.tag[data-icon=mail]::before { + -webkit-mask-image: url(../../public/tag-icons/mail.svg); + mask-image: url(../../public/tag-icons/mail.svg); +} +.tag[data-icon=map-pin]::before { + -webkit-mask-image: url(../../public/tag-icons/mail.svg); + mask-image: url(../../public/tag-icons/mail.svg); +} +.tag[data-icon=shield]::before { + -webkit-mask-image: url(../../public/tag-icons/shield.svg); + mask-image: url(../../public/tag-icons/shield.svg); +} +.tag[data-icon=star]::before { + -webkit-mask-image: url(../../public/tag-icons/star.svg); + mask-image: url(../../public/tag-icons/star.svg); +} +.tag[data-icon=user]::before { + -webkit-mask-image: url(../../public/tag-icons/user.svg); + mask-image: url(../../public/tag-icons/user.svg); +} +.tag[data-icon=users]::before { + -webkit-mask-image: url(../../public/tag-icons/users.svg); + mask-image: url(../../public/tag-icons/users.svg); +} +.tag[data-icon=zap]::before { + -webkit-mask-image: url(../../public/tag-icons/mail.svg); + mask-image: url(../../public/tag-icons/mail.svg); } .neon .tag::after { From 65f4c307885edaa9d6d2ebe4b12cee045bf221ef Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sat, 26 Oct 2024 15:27:51 +0200 Subject: [PATCH 03/15] Fix tag unit test --- src/common/components/Tag/Tag.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/components/Tag/Tag.test.tsx b/src/common/components/Tag/Tag.test.tsx index e2db1e1..cde6bbd 100644 --- a/src/common/components/Tag/Tag.test.tsx +++ b/src/common/components/Tag/Tag.test.tsx @@ -12,7 +12,7 @@ describe("Tag", () => { />, ); - const tag = screen.getByText("#world"); + const tag = screen.getByText("world"); expect(tag).toBeInTheDocument(); const style = getComputedStyle(tag); From 9786cdeef0b596290d7afc9e792ed2325ca61c1c Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sat, 26 Oct 2024 15:43:52 +0200 Subject: [PATCH 04/15] Add new tag styles --- src/common/components/Tag/Tag.stories.tsx | 28 +++++++++++++++ src/scripts/theme/tagStyles.ts | 9 ++++- src/styles/global.css | 44 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/common/components/Tag/Tag.stories.tsx b/src/common/components/Tag/Tag.stories.tsx index bb86b1a..e459800 100644 --- a/src/common/components/Tag/Tag.stories.tsx +++ b/src/common/components/Tag/Tag.stories.tsx @@ -70,3 +70,31 @@ export const ChipBorder: Story = { ), ], }; + +export const ChipIconLight: Story = { + args: { + name: "wilderness", + hue: 321, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const ChipIconDark: Story = { + args: { + name: "wilderness", + hue: 321, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/src/scripts/theme/tagStyles.ts b/src/scripts/theme/tagStyles.ts index 74a01e4..7490717 100644 --- a/src/scripts/theme/tagStyles.ts +++ b/src/scripts/theme/tagStyles.ts @@ -1,4 +1,11 @@ -export const tagStyles = ["neon", "chip-light", "chip-dark", "chip-border"]; +export const tagStyles = [ + "neon", + "chip-light", + "chip-dark", + "chip-border", + "chip-icon-light", + "chip-icon-dark", +]; export type TagStyle = (typeof tagStyles)[number]; export const DEFAULT_TAG_STYLE: TagStyle = "neon"; const LS_TAG_STYLE_NAME = "CUSTOM_TAG_STYLE"; diff --git a/src/styles/global.css b/src/styles/global.css index f809086..78cbf94 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -199,6 +199,50 @@ html { font-weight: 700; } +.chip-icon-light .tag { + position: relative; + padding-inline: 4px; +} + +.chip-icon-light .tag::before { + margin-inline-end: 4px; + background: hsl(var(--tag-color) 100% 20% / 100%); + z-index: 1; +} + +.chip-icon-light .tag::after { + align-self: center; + content: ''; + width: 20px; + height: 20px; + border-radius: 99px; + background: hsl(var(--tag-color) 50% 90% / 100%); + position: absolute; + inset-inline-start: 2px; +} + +.chip-icon-dark .tag { + position: relative; + padding-inline: 4px; +} + +.chip-icon-dark .tag::before { + margin-inline-end: 4px; + background: hsl(var(--tag-color) 50% 90% / 100%); + z-index: 1; +} + +.chip-icon-dark .tag::after { + align-self: center; + content: ''; + width: 20px; + height: 20px; + border-radius: 99px; + background: hsl(var(--tag-color) 100% 20% / 100%); + position: absolute; + inset-inline-start: 2px; +} + .spark-extension { @apply bg-stone-700 text-white px-2 py-1 rounded; } \ No newline at end of file From de7ae85cced029a58465ae96d6e0be89c1135a5f Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sat, 26 Oct 2024 16:04:33 +0200 Subject: [PATCH 05/15] Add dummy configuration for tag icons --- .../TagEditor/TagConfig/TagConfig.css | 13 ++++ .../TagEditor/TagConfig/TagConfig.tsx | 42 +++++++++- src/interfaces/Tag.ts | 4 +- src/styles/global.css | 78 +++++++++++-------- 4 files changed, 102 insertions(+), 35 deletions(-) create mode 100644 src/container/TagEditor/TagConfig/TagConfig.css diff --git a/src/container/TagEditor/TagConfig/TagConfig.css b/src/container/TagEditor/TagConfig/TagConfig.css new file mode 100644 index 0000000..b0b1a5c --- /dev/null +++ b/src/container/TagEditor/TagConfig/TagConfig.css @@ -0,0 +1,13 @@ +.icon-preview { + display: flex; +} + +.icon-preview::before { + content: ""; + display: inline-block; + background: black; + width: 16px; + height: 16px; + margin-inline-end: 4px; + align-self: center; +} \ No newline at end of file diff --git a/src/container/TagEditor/TagConfig/TagConfig.tsx b/src/container/TagEditor/TagConfig/TagConfig.tsx index 388c761..62839db 100644 --- a/src/container/TagEditor/TagConfig/TagConfig.tsx +++ b/src/container/TagEditor/TagConfig/TagConfig.tsx @@ -1,4 +1,4 @@ -import type { Tag } from "../../../interfaces/Tag"; +import { tagIcons, type Tag, type TagIcon } from "../../../interfaces/Tag"; import { Tag as TagElement } from "../../../common/components/Tag/Tag"; import { useState } from "react"; import { HueSlider } from "../../../common/components/HueSlider/HueSlider"; @@ -11,6 +11,14 @@ import { import { Button } from "../../../common/components/shadcn/button"; import { debounce } from "../../../scripts/utils/debounce"; import { TextInput } from "../../../common/components/TextInput/TextInput"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../common/components/shadcn/select"; +import "./TagConfig.css"; type Props = { tag: Tag; @@ -30,6 +38,7 @@ const updateDescriptionDebounced = debounce( export const TagConfig = (props: Props) => { const { tag } = props; const [hue, setHue] = useState(tag.hue); + const [icon, setIcon] = useState(tag.icon ?? "hash"); const handleHueChange = (value: number) => { setHue(value); @@ -42,7 +51,7 @@ export const TagConfig = (props: Props) => {
@@ -57,6 +66,35 @@ export const TagConfig = (props: Props) => { } />
+
+ +
diff --git a/src/interfaces/Tag.ts b/src/interfaces/Tag.ts index e955425..214f663 100644 --- a/src/interfaces/Tag.ts +++ b/src/interfaces/Tag.ts @@ -1,7 +1,7 @@ import { Entity, type InsertType } from "dexie"; import type AppDB from "../scripts/db/AppDB"; -export const TagIcons = [ +export const tagIcons = [ "alert-triangle", "bookmark", "calendar", @@ -20,7 +20,7 @@ export const TagIcons = [ "zap", ] as const; -type TagIcon = (typeof TagIcons)[number]; +export type TagIcon = (typeof tagIcons)[number]; export class Tag extends Entity { name!: string; diff --git a/src/styles/global.css b/src/styles/global.css index 78cbf94..b5aa0ca 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -75,6 +75,7 @@ html { .tag { display: inline-flex; align-items: baseline; + position: relative; } .tag::before { @@ -87,71 +88,88 @@ html { height: 16px; margin-inline-end: 0px; align-self: center; + margin-inline-end: 4px; } -.tag[data-icon=alert-triangle]::before { + +[data-icon=alert-triangle]::before { -webkit-mask-image: url(../../public/tag-icons/alert-triangle.svg); mask-image: url(../../public/tag-icons/alert-triangle.svg); } -.tag[data-icon=bookmark]::before { + +[data-icon=bookmark]::before { -webkit-mask-image: url(../../public/tag-icons/bookmark.svg); mask-image: url(../../public/tag-icons/bookmark.svg); } -.tag[data-icon=calendar]::before { + +[data-icon=calendar]::before { -webkit-mask-image: url(../../public/tag-icons/calendar.svg); mask-image: url(../../public/tag-icons/calendar.svg); } -.tag[data-icon=gift]::before { + +[data-icon=gift]::before { -webkit-mask-image: url(../../public/tag-icons/gift.svg); mask-image: url(../../public/tag-icons/gift.svg); } -.tag[data-icon=hash]::before { + +[data-icon=hash]::before { -webkit-mask-image: url(../../public/tag-icons/hash.svg); mask-image: url(../../public/tag-icons/hash.svg); } -.tag[data-icon=heart]::before { + +[data-icon=heart]::before { -webkit-mask-image: url(../../public/tag-icons/heart.svg); mask-image: url(../../public/tag-icons/heart.svg); } -.tag[data-icon=help-circle]::before { + +[data-icon=help-circle]::before { -webkit-mask-image: url(../../public/tag-icons/help-circle.svg); mask-image: url(../../public/tag-icons/help-circle.svg); } -.tag[data-icon=info]::before { + +[data-icon=info]::before { -webkit-mask-image: url(../../public/tag-icons/info.svg); mask-image: url(../../public/tag-icons/info.svg); } -.tag[data-icon=lock]::before { + +[data-icon=lock]::before { -webkit-mask-image: url(../../public/tag-icons/lock.svg); mask-image: url(../../public/tag-icons/lock.svg); } -.tag[data-icon=mail]::before { + +[data-icon=mail]::before { -webkit-mask-image: url(../../public/tag-icons/mail.svg); mask-image: url(../../public/tag-icons/mail.svg); } -.tag[data-icon=map-pin]::before { - -webkit-mask-image: url(../../public/tag-icons/mail.svg); - mask-image: url(../../public/tag-icons/mail.svg); + +[data-icon=map-pin]::before { + -webkit-mask-image: url(../../public/tag-icons/map-pin.svg); + mask-image: url(../../public/tag-icons/map-pin.svg); } -.tag[data-icon=shield]::before { + +[data-icon=shield]::before { -webkit-mask-image: url(../../public/tag-icons/shield.svg); mask-image: url(../../public/tag-icons/shield.svg); } -.tag[data-icon=star]::before { + +[data-icon=star]::before { -webkit-mask-image: url(../../public/tag-icons/star.svg); mask-image: url(../../public/tag-icons/star.svg); } -.tag[data-icon=user]::before { + +[data-icon=user]::before { -webkit-mask-image: url(../../public/tag-icons/user.svg); mask-image: url(../../public/tag-icons/user.svg); } -.tag[data-icon=users]::before { + +[data-icon=users]::before { -webkit-mask-image: url(../../public/tag-icons/users.svg); mask-image: url(../../public/tag-icons/users.svg); } -.tag[data-icon=zap]::before { - -webkit-mask-image: url(../../public/tag-icons/mail.svg); - mask-image: url(../../public/tag-icons/mail.svg); + +[data-icon=zap]::before { + -webkit-mask-image: url(../../public/tag-icons/zap.svg); + mask-image: url(../../public/tag-icons/zap.svg); } .neon .tag::after { @@ -164,18 +182,20 @@ html { lightness, chroma and alpha but keep the hue */ background: hsl(var(--tag-color) 100% 50% / 90%); - background: oklch(from hsl(var(--tag-color) 100% 50%) 0.5 0.5 h / 0.4); - background: linear-gradient(0deg, rgba(255,255,255,0) 0%, oklch(from hsl(var(--tag-color) 100% 50%) 0.5 0.5 h) 80%, rgba(255,255,255,0) 100%); + /* background: oklch(from hsl(var(--tag-color) 100% 50%) 0.5 0.5 h / 0.9); */ + /* background: linear-gradient(0deg, rgba(255,255,255,0) 0%, oklch(from hsl(var(--tag-color) 100% 50%) 0.5 0.5 h) 80%, rgba(255,255,255,0) 100%); */ width: 100%; height: 1px; - filter: blur(3px); - margin-top: -5px; + filter: blur(2px); display: block; content: ""; + position: absolute; + bottom: 3px; + padding-inline: 6px; } .chip-light .tag { - padding-inline: 10px; + padding-inline: 6px; border-radius: 15px; background: hsl(var(--tag-color) 50% 90% / 100%); color: hsl(var(--tag-color) 100% 20% / 100%); @@ -183,7 +203,7 @@ html { } .chip-dark .tag { - padding-inline: 10px; + padding-inline: 6px; border-radius: 15px; color: hsl(var(--tag-color) 50% 90% / 100%); background-color: hsl(var(--tag-color) 100% 25% / 100%); @@ -191,7 +211,7 @@ html { } .chip-border .tag { - padding-inline: 10px; + padding-inline: 6px; border-radius: 15px; border-width: 1px; border-color: hsl(var(--tag-color) 50% 80% / 100%); @@ -200,12 +220,10 @@ html { } .chip-icon-light .tag { - position: relative; padding-inline: 4px; } .chip-icon-light .tag::before { - margin-inline-end: 4px; background: hsl(var(--tag-color) 100% 20% / 100%); z-index: 1; } @@ -222,12 +240,10 @@ html { } .chip-icon-dark .tag { - position: relative; padding-inline: 4px; } .chip-icon-dark .tag::before { - margin-inline-end: 4px; background: hsl(var(--tag-color) 50% 90% / 100%); z-index: 1; } From 2b91412426d03ad955ec06720f1c9f22add34ac3 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sat, 26 Oct 2024 16:19:05 +0200 Subject: [PATCH 06/15] Fix icon path url --- src/styles/global.css | 68 +++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/styles/global.css b/src/styles/global.css index b5aa0ca..d109543 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -81,8 +81,8 @@ html { .tag::before { content: ""; display: inline-block; - -webkit-mask-image: url(../../public/tag-icons/hash.svg); - mask-image: url(../../public/tag-icons/hash.svg); + -webkit-mask-image: url(/sparktag/tag-icons/hash.svg); + mask-image: url(/sparktag/tag-icons/hash.svg); background: currentColor; width: 16px; height: 16px; @@ -93,83 +93,83 @@ html { [data-icon=alert-triangle]::before { - -webkit-mask-image: url(../../public/tag-icons/alert-triangle.svg); - mask-image: url(../../public/tag-icons/alert-triangle.svg); + -webkit-mask-image: url(/sparktag/tag-icons/alert-triangle.svg); + mask-image: url(/sparktag/tag-icons/alert-triangle.svg); } [data-icon=bookmark]::before { - -webkit-mask-image: url(../../public/tag-icons/bookmark.svg); - mask-image: url(../../public/tag-icons/bookmark.svg); + -webkit-mask-image: url(/sparktag/tag-icons/bookmark.svg); + mask-image: url(/sparktag/tag-icons/bookmark.svg); } [data-icon=calendar]::before { - -webkit-mask-image: url(../../public/tag-icons/calendar.svg); - mask-image: url(../../public/tag-icons/calendar.svg); + -webkit-mask-image: url(/sparktag/tag-icons/calendar.svg); + mask-image: url(/sparktag/tag-icons/calendar.svg); } [data-icon=gift]::before { - -webkit-mask-image: url(../../public/tag-icons/gift.svg); - mask-image: url(../../public/tag-icons/gift.svg); + -webkit-mask-image: url(/sparktag/tag-icons/gift.svg); + mask-image: url(/sparktag/tag-icons/gift.svg); } [data-icon=hash]::before { - -webkit-mask-image: url(../../public/tag-icons/hash.svg); - mask-image: url(../../public/tag-icons/hash.svg); + -webkit-mask-image: url(/sparktag/tag-icons/hash.svg); + mask-image: url(/sparktag/tag-icons/hash.svg); } [data-icon=heart]::before { - -webkit-mask-image: url(../../public/tag-icons/heart.svg); - mask-image: url(../../public/tag-icons/heart.svg); + -webkit-mask-image: url(/sparktag/tag-icons/heart.svg); + mask-image: url(/sparktag/tag-icons/heart.svg); } [data-icon=help-circle]::before { - -webkit-mask-image: url(../../public/tag-icons/help-circle.svg); - mask-image: url(../../public/tag-icons/help-circle.svg); + -webkit-mask-image: url(/sparktag/tag-icons/help-circle.svg); + mask-image: url(/sparktag/tag-icons/help-circle.svg); } [data-icon=info]::before { - -webkit-mask-image: url(../../public/tag-icons/info.svg); - mask-image: url(../../public/tag-icons/info.svg); + -webkit-mask-image: url(/sparktag/tag-icons/info.svg); + mask-image: url(/sparktag/tag-icons/info.svg); } [data-icon=lock]::before { - -webkit-mask-image: url(../../public/tag-icons/lock.svg); - mask-image: url(../../public/tag-icons/lock.svg); + -webkit-mask-image: url(/sparktag/tag-icons/lock.svg); + mask-image: url(/sparktag/tag-icons/lock.svg); } [data-icon=mail]::before { - -webkit-mask-image: url(../../public/tag-icons/mail.svg); - mask-image: url(../../public/tag-icons/mail.svg); + -webkit-mask-image: url(/sparktag/tag-icons/mail.svg); + mask-image: url(/sparktag/tag-icons/mail.svg); } [data-icon=map-pin]::before { - -webkit-mask-image: url(../../public/tag-icons/map-pin.svg); - mask-image: url(../../public/tag-icons/map-pin.svg); + -webkit-mask-image: url(/sparktag/tag-icons/map-pin.svg); + mask-image: url(/sparktag/tag-icons/map-pin.svg); } [data-icon=shield]::before { - -webkit-mask-image: url(../../public/tag-icons/shield.svg); - mask-image: url(../../public/tag-icons/shield.svg); + -webkit-mask-image: url(/sparktag/tag-icons/shield.svg); + mask-image: url(/sparktag/tag-icons/shield.svg); } [data-icon=star]::before { - -webkit-mask-image: url(../../public/tag-icons/star.svg); - mask-image: url(../../public/tag-icons/star.svg); + -webkit-mask-image: url(/sparktag/tag-icons/star.svg); + mask-image: url(/sparktag/tag-icons/star.svg); } [data-icon=user]::before { - -webkit-mask-image: url(../../public/tag-icons/user.svg); - mask-image: url(../../public/tag-icons/user.svg); + -webkit-mask-image: url(/sparktag/tag-icons/user.svg); + mask-image: url(/sparktag/tag-icons/user.svg); } [data-icon=users]::before { - -webkit-mask-image: url(../../public/tag-icons/users.svg); - mask-image: url(../../public/tag-icons/users.svg); + -webkit-mask-image: url(/sparktag/tag-icons/users.svg); + mask-image: url(/sparktag/tag-icons/users.svg); } [data-icon=zap]::before { - -webkit-mask-image: url(../../public/tag-icons/zap.svg); - mask-image: url(../../public/tag-icons/zap.svg); + -webkit-mask-image: url(/sparktag/tag-icons/zap.svg); + mask-image: url(/sparktag/tag-icons/zap.svg); } .neon .tag::after { From f3f2069e9ae13c450c71c06e8cc124ecc1fd5eb8 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sat, 26 Oct 2024 16:22:58 +0200 Subject: [PATCH 07/15] Fix neon tag style padding --- src/styles/global.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/styles/global.css b/src/styles/global.css index d109543..f1506c9 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -172,6 +172,10 @@ html { mask-image: url(/sparktag/tag-icons/zap.svg); } +.neon .tag { + padding-inline: 6px; +} + .neon .tag::after { /* We use hsl to generate the base color because the hue in hsl has a wider color range than oklch. @@ -191,7 +195,7 @@ html { content: ""; position: absolute; bottom: 3px; - padding-inline: 6px; + inset-inline-start: 0px; } .chip-light .tag { From 2272e7c1ccdce3b9749d2d6b527c251bfbd7af98 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sun, 27 Oct 2024 14:24:47 +0100 Subject: [PATCH 08/15] Update tag hue also in inline tags --- .../TagEditor/TagConfig/TagConfig.tsx | 2 +- src/container/TagEditor/TagEditor.tsx | 4 +-- src/scripts/db/SparkService.ts | 15 ++++++++++- src/scripts/db/TagService.ts | 25 ++++++++++++++----- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/container/TagEditor/TagConfig/TagConfig.tsx b/src/container/TagEditor/TagConfig/TagConfig.tsx index 62839db..b056830 100644 --- a/src/container/TagEditor/TagConfig/TagConfig.tsx +++ b/src/container/TagEditor/TagConfig/TagConfig.tsx @@ -46,7 +46,7 @@ export const TagConfig = (props: Props) => { }; return ( -
+
{
-
+
{!tags || tags.length <= 0 ? (
No tags yet.
) : ( -
+
{tags.map((tag) => ( ; @@ -11,7 +12,7 @@ export class TagService { private tagMap: TagMap = new Map(); constructor(private db: AppDB) { - this.updateCache(); + this.createCache(); } public async get(name: string) { @@ -23,21 +24,25 @@ export class TagService { hue: number, description?: string, ) { - const existCheck = await this.db.tags.get(name); + const tagName = toLowerCase(name); + const existCheck = await this.db.tags.get(tagName); if (existCheck) { return; } await this.db.tags.add({ - name: toLowerCase(name), + name: tagName, hue, description, }); - this.updateCache(); + this.updateCache(tagName); } public async updateHue(name: string, hue: number) { await this.db.tags.update(name, { hue }); - this.updateCache(); + + await this.updateCache(name); + + await sparkService.updateTag(name, this.tagMap); } public async updateDescription(name: string, description: string) { @@ -78,11 +83,19 @@ export class TagService { return await this.db.tags.toArray(); } - private async updateCache() { + private async createCache() { const tags = await this.listTags(); this.tagMap = buildTagMap(tags); } + private async updateCache(name: string) { + const newTag = await this.db.tags.get(name); + if (!newTag) { + return; + } + this.tagMap.set(newTag.name, newTag); + } + public async CAREFUL_deleteAllData() { await this.db.tags.clear(); } From 5a0bb3b3516b6530f4caa00f5765910e14fe3052 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sun, 27 Oct 2024 14:41:16 +0100 Subject: [PATCH 09/15] Fix parseSpark function to not remove inline tags that are also used as a context tag --- src/scripts/utils/stringUtils.test.ts | 32 ++++++++++++++++++++++++++- src/scripts/utils/stringUtils.ts | 20 ++++++++--------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/scripts/utils/stringUtils.test.ts b/src/scripts/utils/stringUtils.test.ts index 78f0176..fea5bd1 100644 --- a/src/scripts/utils/stringUtils.test.ts +++ b/src/scripts/utils/stringUtils.test.ts @@ -163,7 +163,8 @@ describe("parseSpark", () => { const expectedPrefixTagsHtml = '#tag1 #tag2 #tag3 '; const expectedTags = ["tag1", "tag2", "tag3"]; - const expectStrippedPlainText = ""; + const expectStrippedPlainText = "#tag1 #tag2 #tag3"; + // it is still the same as `html` because there is not text after the context tags. Otherwise the spark would be just context tags without any text. So we will repeat the context tags so it does not look empty const expectStrippedHtml = '

#tag1 #tag2 #tag3

'; @@ -181,6 +182,35 @@ describe("parseSpark", () => { expect(strippedPlainText).toEqual(expectStrippedPlainText); expect(strippedHtml).toEqual(expectStrippedHtml); }); + + test("does not remove inline tags that are the same as context tags", () => { + const content = + "#tag2 this is just an #tag2 which is also a context tag"; + const html = + '

tag2 this is just an tag2 which is also a context tag

'; + const expectedPrefixTags: string[] = ["tag2"]; + const expectedPrefixTagsHtml = + 'tag2 '; + const expectedTags = ["tag2"]; + const expectStrippedPlainText = + "this is just an #tag2 which is also a context tag"; + const expectStrippedHtml = + '

this is just an tag2 which is also a context tag

'; + + const { + prefixTags, + prefixTagsHtml, + strippedHtml, + strippedPlainText, + tags, + } = parseSpark(content, html); + + expect(prefixTags).toEqual(expectedPrefixTags); + expect(tags).toEqual(expectedTags); + expect(prefixTagsHtml).toEqual(expectedPrefixTagsHtml); + expect(strippedPlainText).toEqual(expectStrippedPlainText); + expect(strippedHtml).toEqual(expectStrippedHtml); + }); }); describe("stringToHue", () => { diff --git a/src/scripts/utils/stringUtils.ts b/src/scripts/utils/stringUtils.ts index 53c127d..50df518 100644 --- a/src/scripts/utils/stringUtils.ts +++ b/src/scripts/utils/stringUtils.ts @@ -30,8 +30,8 @@ export const parseSpark = (plainText: string, html: string) => { renderedHtml.querySelectorAll(".tag[data-type=tags]"), ); - const tags: string[] = []; - const prefixTags: string[] = []; + const tags: Set = new Set(); + const prefixTags: Set = new Set(); let prefixTagsHtml = ""; let isCollectPrefixTags = true; @@ -75,27 +75,25 @@ export const parseSpark = (plainText: string, html: string) => { isCollectPrefixTags = false; } - tags.push(tagName); + tags.add(tagName); if (isCollectPrefixTags) { - prefixTags.push(tagName); + prefixTags.add(tagName); prefixTagsHtml += `${tagNode.outerHTML} `; } } - const prefixTagsString = `${prefixTags.map((t) => `#${t}`).join(" ")} `; + const prefixTagsString = `${[...prefixTags].map((t) => `#${t}`).join(" ")} `; const strippedPlainText = prefixTagsString.length > 1 - ? plainText.split( - `${prefixTags.map((t) => `#${t}`).join(" ")} `, - )[1] ?? "" + ? plainText.replace(prefixTagsString, "") : plainText; const strippedHtml = - prefixTagsHtml.length > 1 ? html.split(prefixTagsHtml).join("") : html; + prefixTagsHtml.length > 1 ? html.replace(prefixTagsHtml, "") : html; return { - tags, - prefixTags, + tags: [...tags], + prefixTags: [...prefixTags], strippedPlainText, strippedHtml, prefixTagsHtml, From 9af51ab51c560bc4bd4210a39db177b9b1756b80 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sun, 27 Oct 2024 14:48:24 +0100 Subject: [PATCH 10/15] Edit, store and update icon of tags --- src/container/TagEditor/TagConfig/TagConfig.tsx | 5 +++++ src/container/TagEditor/TagEditor.tsx | 2 +- src/scripts/db/TagService.ts | 10 +++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/container/TagEditor/TagConfig/TagConfig.tsx b/src/container/TagEditor/TagConfig/TagConfig.tsx index b056830..b48e2c5 100644 --- a/src/container/TagEditor/TagConfig/TagConfig.tsx +++ b/src/container/TagEditor/TagConfig/TagConfig.tsx @@ -35,6 +35,10 @@ const updateDescriptionDebounced = debounce( 1000, ); +const updateIconDebounced = debounce((name: string, icon: TagIcon) => { + tagService.updateIcon(name, icon); +}, 1000); + export const TagConfig = (props: Props) => { const { tag } = props; const [hue, setHue] = useState(tag.hue); @@ -71,6 +75,7 @@ export const TagConfig = (props: Props) => { defaultValue={icon} onValueChange={(value: TagIcon) => { setIcon(value); + updateIconDebounced(tag.name, value); }} > diff --git a/src/container/TagEditor/TagEditor.tsx b/src/container/TagEditor/TagEditor.tsx index d8bf6e4..a92e9eb 100644 --- a/src/container/TagEditor/TagEditor.tsx +++ b/src/container/TagEditor/TagEditor.tsx @@ -49,7 +49,7 @@ export const TagEditor = () => { {!tags || tags.length <= 0 ? (
No tags yet.
) : ( -
+
{tags.map((tag) => ( Date: Sun, 27 Oct 2024 15:19:40 +0100 Subject: [PATCH 11/15] Add escape key functionality to quit editing a spark --- src/common/components/TextInput/TextInput.tsx | 16 +++++++++------- src/container/SparkList/SparkItem/SparkItem.tsx | 5 +++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/common/components/TextInput/TextInput.tsx b/src/common/components/TextInput/TextInput.tsx index 1ab8f28..94130c7 100644 --- a/src/common/components/TextInput/TextInput.tsx +++ b/src/common/components/TextInput/TextInput.tsx @@ -4,7 +4,6 @@ import "./TextInput.css"; import { parseSpark } from "../../../scripts/utils/stringUtils"; import { isUserSelectingTag } from "./TagList/TagList"; import { isUserSelectingExtension } from "./SparkExtensionList/SparkExtensionList"; -import { useEffect } from "react"; export type TextInputProps = { onSubmit?: (plainText: string, html: string) => void; @@ -16,10 +15,9 @@ export type TextInputProps = { style?: keyof typeof styleMap; placeholder?: string; content?: string; + onEscape?: () => void; }; -let editor: Editor | null = null; - const styleMap = { spark: "p-4 min-h-full block w-full bg-white border border-blue-300 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600", search: "px-4 py-2 block w-full bg-transparent hover:bg-white focus:bg-white border-b border-b-stone-200 rounded", @@ -38,8 +36,9 @@ export const TextInput = (props: TextInputProps) => { placeholder, enableTags, enableExtension, + onEscape, } = props; - editor = useEditor({ + const editor = useEditor({ content, extensions: getExtensions({ parentWindow: parentWindow ?? window, @@ -58,13 +57,16 @@ export const TextInput = (props: TextInputProps) => { if (!onSubmit) { return false; } - if (event.key !== "Enter" || event.shiftKey) { - return false; - } if (isUserSelectingTag || isUserSelectingExtension) { // user currently has the selection open and might have pressed enter to select an item return false; } + if (event.key !== "Enter" || event.shiftKey) { + if (event.key === "Escape") { + onEscape?.(); + } + return false; + } const html = editor?.getHTML().trim() ?? ""; const plainText = editor?.getText().trim() ?? ""; if (html === "") { diff --git a/src/container/SparkList/SparkItem/SparkItem.tsx b/src/container/SparkList/SparkItem/SparkItem.tsx index c839f0d..8730a7e 100644 --- a/src/container/SparkList/SparkItem/SparkItem.tsx +++ b/src/container/SparkList/SparkItem/SparkItem.tsx @@ -32,6 +32,10 @@ export const SparkItem = (props: Props) => { setIsEditing(false); }; + const handleEscape = () => { + setIsEditing(false); + }; + const handleTrashClicked = async () => { setIsDeleting(true); }; @@ -50,6 +54,7 @@ export const SparkItem = (props: Props) => {
) : ( From 3c08b11773a5efe8c85f6107302b7ba1f0bae507 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sun, 27 Oct 2024 15:46:32 +0100 Subject: [PATCH 12/15] Delete tags and search if not possible --- src/common/components/Tag/Tag.tsx | 3 +- src/common/components/TextInput/TextInput.tsx | 106 ++++++++++-------- src/container/SearchInput/SearchInput.tsx | 9 +- src/container/SparkList/SparkList.tsx | 2 +- .../TagEditor/TagConfig/TagConfig.tsx | 67 ++++++++++- src/scripts/db/TagService.ts | 4 + src/scripts/store/queryStore.ts | 30 +++-- 7 files changed, 163 insertions(+), 58 deletions(-) diff --git a/src/common/components/Tag/Tag.tsx b/src/common/components/Tag/Tag.tsx index 25ae7cd..8a24925 100644 --- a/src/common/components/Tag/Tag.tsx +++ b/src/common/components/Tag/Tag.tsx @@ -10,8 +10,9 @@ export const Tag = (props: TagProps) => { const { name, hue, icon } = props; return ( void; @@ -16,11 +17,12 @@ export type TextInputProps = { placeholder?: string; content?: string; onEscape?: () => void; + globalAccessorId?: string; }; const styleMap = { spark: "p-4 min-h-full block w-full bg-white border border-blue-300 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600", - search: "px-4 py-2 block w-full bg-transparent hover:bg-white focus:bg-white border-b border-b-stone-200 rounded", + search: "px-4 py-2 block w-full bg-transparent text-sm hover:bg-white focus:bg-white border-b border-b-stone-200 rounded", invisible: "p-1 w-full bg-transparent rounded-sm text-stone-500 focus:text-stone-700 text-sm max-w-72", }; @@ -37,53 +39,69 @@ export const TextInput = (props: TextInputProps) => { enableTags, enableExtension, onEscape, + globalAccessorId, } = props; - const editor = useEditor({ - content, - extensions: getExtensions({ - parentWindow: parentWindow ?? window, - allowAddingTags, - placeholder, - enableTags, - enableExtension, - }), - editorProps: { - attributes: { - "aria-label": "Add a spark", - role: "textbox", - class: `${styleMap[style]}`, - }, - handleKeyDown: (_view, event) => { - if (!onSubmit) { - return false; - } - if (isUserSelectingTag || isUserSelectingExtension) { - // user currently has the selection open and might have pressed enter to select an item - return false; - } - if (event.key !== "Enter" || event.shiftKey) { - if (event.key === "Escape") { - onEscape?.(); + const editor = useEditor( + { + content, + extensions: getExtensions({ + parentWindow: parentWindow ?? window, + allowAddingTags, + placeholder, + enableTags, + enableExtension, + }), + editorProps: { + attributes: { + "aria-label": "Add a spark", + role: "textbox", + class: `${styleMap[style]}`, + }, + handleKeyDown: (_view, event) => { + if (!onSubmit) { + return false; + } + if (isUserSelectingTag || isUserSelectingExtension) { + // user currently has the selection open and might have pressed enter to select an item + return false; + } + if (event.key !== "Enter" || event.shiftKey) { + if (event.key === "Escape") { + onEscape?.(); + } + return false; + } + const html = editor?.getHTML().trim() ?? ""; + const plainText = editor?.getText().trim() ?? ""; + if (html === "") { + return false; } - return false; - } - const html = editor?.getHTML().trim() ?? ""; - const plainText = editor?.getText().trim() ?? ""; - if (html === "") { - return false; - } - onSubmit(plainText, html); - const { prefixTagsHtml } = parseSpark(plainText, html); - editor?.commands.setContent(prefixTagsHtml, false, { - preserveWhitespace: true, - }); - return true; + onSubmit(plainText, html); + const { prefixTagsHtml } = parseSpark(plainText, html); + editor?.commands.setContent(prefixTagsHtml, false, { + preserveWhitespace: true, + }); + return true; + }, + }, + onUpdate: ({ editor }) => { + onChange?.(editor.getHTML()); }, }, - onUpdate: ({ editor }) => { - onChange?.(editor.getHTML()); - }, - }); + [content], + ); + + useEffect(() => { + if (!globalAccessorId) { + return; + } + // biome-ignore lint/suspicious/noExplicitAny: + const win = window as any; + if (!win.editor) { + win.editor = {}; + } + win.editor[globalAccessorId] = editor; + }, [editor, globalAccessorId]); return ( { const handleChange = (htmlString: string) => { @@ -9,6 +13,7 @@ export const SearchInput = () => { return (
{ - const queryTags = useQueryStore((state) => state.context.query); + const queryTags = useQueryStore((state) => state.context.tags); const sparksWithTags = useLiveQuery( () => sparkService.find(queryTags), [queryTags], diff --git a/src/container/TagEditor/TagConfig/TagConfig.tsx b/src/container/TagEditor/TagConfig/TagConfig.tsx index b48e2c5..251bd63 100644 --- a/src/container/TagEditor/TagConfig/TagConfig.tsx +++ b/src/container/TagEditor/TagConfig/TagConfig.tsx @@ -19,6 +19,13 @@ import { SelectValue, } from "../../../common/components/shadcn/select"; import "./TagConfig.css"; +import { IconButton } from "../../../common/components/IconButton/IconButton"; +import { TrashIcon } from "../../../assets/icons/TrashIcon"; +import { sparkService } from "../../../scripts/db/SparkService"; +import { useToast } from "../../../common/hooks/use-toast"; +import { ToastAction } from "../../../common/components/shadcn/toast"; +import { SearchInputEditorAccessorId } from "../../SearchInput/SearchInput"; +import type { Editor } from "@tiptap/react"; type Props = { tag: Tag; @@ -43,14 +50,64 @@ export const TagConfig = (props: Props) => { const { tag } = props; const [hue, setHue] = useState(tag.hue); const [icon, setIcon] = useState(tag.icon ?? "hash"); + const { toast } = useToast(); const handleHueChange = (value: number) => { setHue(value); updateHueDebounced(tag.name, value); }; + const searchTag = () => { + // biome-ignore lint/suspicious/noExplicitAny: + const searchEditor: Editor | undefined = (window as any).editor?.[ + SearchInputEditorAccessorId + ]; + + if (!searchEditor) { + return; + } + + searchEditor.commands.setContent( + ` + ${tag.name} + `, + true, + { preserveWhitespace: false }, + ); + }; + + const handleDelete = async () => { + const sparksWithTag = await sparkService.find([tag.name]); + + if (sparksWithTag.length > 0) { + toast({ + title: `Could not delete tag "${tag.name}"`, + description: `The tag is still being used by ${sparksWithTag.length} sparks. Edit these sparks and remove the tag on each of them to delete this tag.`, + variant: "destructive", + action: ( + + View + + ), + }); + return; + } + + await tagService.deleteTag(tag.name); + }; + return ( -
+
{
+
+ + + +
); }; diff --git a/src/scripts/db/TagService.ts b/src/scripts/db/TagService.ts index 0c281f9..a6520f1 100644 --- a/src/scripts/db/TagService.ts +++ b/src/scripts/db/TagService.ts @@ -104,6 +104,10 @@ export class TagService { this.tagMap.set(newTag.name, newTag); } + public async deleteTag(name: string) { + await this.db.tags.delete(name); + } + public async CAREFUL_deleteAllData() { await this.db.tags.clear(); } diff --git a/src/scripts/store/queryStore.ts b/src/scripts/store/queryStore.ts index 5ce7168..3d5ddfe 100644 --- a/src/scripts/store/queryStore.ts +++ b/src/scripts/store/queryStore.ts @@ -5,29 +5,37 @@ import { useSelector } from "@xstate/store/react"; export const queryStore = createStore({ // Initial context - context: { query: [] } as { query: string[] }, + context: { tags: [], queryHtml: "" } as { + tags: string[]; + queryHtml: string; + }, // Transitions on: { - update: { - query: (_context, event: { value: string[] }) => event.value, + updateTags: { + tags: (_context, event: { html: string }) => { + return extractTags(event.html); + }, + }, + updateQueryHtml: { + queryHtml: (_context, event: { html: string }) => { + return event.html; + }, }, clear: { - query: () => [], + tags: () => [], + queryHtml: () => "", }, }, }); const extractTagsAndUpdateDebounced = debounce((queryString: string) => { - const newQuery = extractTags(queryString); - console.log("extract", queryString, newQuery); queryStore.send({ - type: "update", - value: newQuery, + type: "updateTags", + html: queryString, }); }, 300); export const updateQueryDebounced = (queryString?: string) => { - console.log("u", queryString); if (!queryString || queryString.trim() === "") { queryStore.send({ type: "clear", @@ -35,6 +43,10 @@ export const updateQueryDebounced = (queryString?: string) => { return; } + queryStore.send({ + type: "updateQueryHtml", + html: queryString, + }); extractTagsAndUpdateDebounced(queryString); }; From aa37a01fa25bf8145b86e795545b4e2a250ed515 Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sun, 27 Oct 2024 15:53:11 +0100 Subject: [PATCH 13/15] TagEditor filter feature --- src/container/TagEditor/TagEditor.tsx | 30 ++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/container/TagEditor/TagEditor.tsx b/src/container/TagEditor/TagEditor.tsx index a92e9eb..31c9476 100644 --- a/src/container/TagEditor/TagEditor.tsx +++ b/src/container/TagEditor/TagEditor.tsx @@ -13,12 +13,27 @@ import { TagIcon } from "../../assets/icons/TagIcon"; import { useLiveQuery } from "dexie-react-hooks"; import { tagService } from "../../scripts/db/TagService"; import { TagConfig } from "./TagConfig/TagConfig"; +import { Input } from "../../common/components/shadcn/input"; +import { useState } from "react"; +import { matchSorter } from "match-sorter"; export const TagEditor = () => { const tags = useLiveQuery(() => { return tagService.listTags(); }); + const [filter, setFilter] = useState(""); + + const filteredTags = matchSorter(tags ?? [], filter, { + keys: [ + "name", + { + key: "description", + maxRanking: matchSorter.rankings.STARTS_WITH, + }, + ], + }); + return ( <> @@ -45,12 +60,25 @@ export const TagEditor = () => {
+
+
+
+ +
+
+
{!tags || tags.length <= 0 ? (
No tags yet.
) : (
- {tags.map((tag) => ( + {filteredTags.map((tag) => ( Date: Sun, 27 Oct 2024 16:06:41 +0100 Subject: [PATCH 14/15] Show usage count per tag --- .../TagEditor/TagConfig/TagConfig.tsx | 18 +++++++++++++--- src/container/TagEditor/TagEditor.tsx | 21 ++++++++++++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/container/TagEditor/TagConfig/TagConfig.tsx b/src/container/TagEditor/TagConfig/TagConfig.tsx index 251bd63..d3aa395 100644 --- a/src/container/TagEditor/TagConfig/TagConfig.tsx +++ b/src/container/TagEditor/TagConfig/TagConfig.tsx @@ -1,6 +1,6 @@ import { tagIcons, type Tag, type TagIcon } from "../../../interfaces/Tag"; import { Tag as TagElement } from "../../../common/components/Tag/Tag"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { HueSlider } from "../../../common/components/HueSlider/HueSlider"; import { tagService } from "../../../scripts/db/TagService"; import { @@ -29,6 +29,7 @@ import type { Editor } from "@tiptap/react"; type Props = { tag: Tag; + showUsageCount: boolean; }; const updateHueDebounced = debounce((name: string, hue: number) => { @@ -47,10 +48,20 @@ const updateIconDebounced = debounce((name: string, icon: TagIcon) => { }, 1000); export const TagConfig = (props: Props) => { - const { tag } = props; + const { tag, showUsageCount } = props; const [hue, setHue] = useState(tag.hue); const [icon, setIcon] = useState(tag.icon ?? "hash"); const { toast } = useToast(); + const [usageCount, setUsageCount] = useState(0); + + useEffect(() => { + if (!showUsageCount) { + return; + } + (async () => { + setUsageCount((await sparkService.find([tag.name])).length); + })(); + }, [showUsageCount, tag.name]); const handleHueChange = (value: number) => { setHue(value); @@ -107,7 +118,8 @@ export const TagConfig = (props: Props) => { }; return ( -
+
+
{showUsageCount ? usageCount : ""}
{ + const [showUsageCount, setShowUsageCount] = useState(false); const tags = useLiveQuery(() => { return tagService.listTags(); }); const [filter, setFilter] = useState(""); + const handleFilterChange = (event: React.ChangeEvent) => { + setFilter(event.target.value); + }; + const filteredTags = matchSorter(tags ?? [], filter, { keys: [ "name", @@ -62,14 +68,22 @@ export const TagEditor = () => {
-
+
+
@@ -77,11 +91,12 @@ export const TagEditor = () => { {!tags || tags.length <= 0 ? (
No tags yet.
) : ( -
+
{filteredTags.map((tag) => ( ))}
From 6b200c753c9c56e727531bb19d2ea96800af231e Mon Sep 17 00:00:00 2001 From: Marvin Becker Date: Sun, 27 Oct 2024 16:23:59 +0100 Subject: [PATCH 15/15] Fix scroll into view of tags in pip window --- src/common/components/TextInput/TagList/TagList.tsx | 10 ++++++---- src/common/components/TextInput/TextInput.config.tsx | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/common/components/TextInput/TagList/TagList.tsx b/src/common/components/TextInput/TagList/TagList.tsx index 0df133f..1dd5673 100644 --- a/src/common/components/TextInput/TagList/TagList.tsx +++ b/src/common/components/TextInput/TagList/TagList.tsx @@ -13,14 +13,16 @@ export const getNewTagPhrase = (tag: string) => export let isUserSelectingTag = false; -type Props = SuggestionProps; +export type TagListProps = SuggestionProps & { + parentWindow: Window; +}; export type TagListRef = { onKeyDown: (data: SuggestionKeyDownProps) => boolean; }; -export const TagList = forwardRef((props, ref) => { - const { items } = props; +export const TagList = forwardRef((props, ref) => { + const { items, parentWindow } = props; const [selectedIndex, setSelectedIndex] = useState(1); useEffect(() => { @@ -51,7 +53,7 @@ export const TagList = forwardRef((props, ref) => { }; const scrollIntoView = (index: number) => { - const itemElement = document.querySelector( + const itemElement = parentWindow.document.querySelector( `[data-selector=tags-dropdown]>:nth-child(${index + 1})`, ); itemElement?.scrollIntoView({ diff --git a/src/common/components/TextInput/TextInput.config.tsx b/src/common/components/TextInput/TextInput.config.tsx index ac163bc..4856918 100644 --- a/src/common/components/TextInput/TextInput.config.tsx +++ b/src/common/components/TextInput/TextInput.config.tsx @@ -131,7 +131,10 @@ export const getExtensions = (settings: Settings) => { return { onStart: (props) => { component = new ReactRenderer(TagList, { - props, + props: { + ...props, + parentWindow, + }, editor: props.editor, });