# AppWorks Personal Project #### Brief: 復刻[Church Notes](https://www.churchnotesapp.com/),加上[Obsidian](https://help.obsidian.md/Plugins/Graph+view)的GraphView功能 ## unit test [TDD](https://medium.com/%E6%88%91%E6%83%B3%E8%A6%81%E8%AE%8A%E5%BC%B7/tdd-test-driven-development-%E6%B8%AC%E8%A9%A6%E9%A9%85%E5%8B%95%E9%96%8B%E7%99%BC-%E5%85%A5%E9%96%80%E7%AF%87-e3f6f15c6651)測試導向開發:先把測試&預期結果寫好,再去開發function 用[jest](https://jestjs.io/docs/getting-started)框架,[test檔名命名方式](https://create-react-app.dev/docs/running-tests) * describe:測試描述 * test:測試內容,可以改為語意化的寫法(describe it ...) ``` describe('add function', () => { it('should sum 1 + 2 to equal 3', () => { expect(sum(1, 2)).toBe(3); }); }); ``` * 加x的可以跳過 ``` describe('add function', () => { xit('should sum 1 + 2 to equal 3', () => { //這邊 expect(sum(1, 2)).toBe(3); }); }); xdescribe('add function', () => { //這邊 it('should sum 1 + 2 to equal 3', () => { expect(sum(1, 2)).toBe(3); }); }); ``` ![](https://hackmd.io/_uploads/S1sb87o42.png) * 門檻:需熟悉測試的語法跟方法 [mock/stub/spy/fake](https://medium.com/@henry-chou/%E5%96%AE%E5%85%83%E6%B8%AC%E8%A9%A6%E4%B9%8B-mock-stub-spy-fake-%E5%82%BB%E5%82%BB%E6%90%9E%E4%B8%8D%E6%B8%85%E6%A5%9A-ba3dc4e86d86) * `npm test -- --coverage`可以知道測試覆蓋率 * 比較常做的UI測試:[Snapshot Testing](https://jestjs.io/docs/tutorial-react#snapshot-testing):把當下的畫面快照下來,去對照 * [測試使用者行為](https://jestjs.io/docs/tutorial-react) ## 互測 [互測重點](https://www.notion.so/d1cd99dccca943c98442bbf0e826ef90) ## Custom Hook 其實就是單純的拆function,並且function裡有用到react的原生hook,就是Custom Hook ``` //很像useRef function useHashtags = () { const [hashTags, setHashtags] = useState(); useEffect(()=>{...},[]); ... return hashTags; } const Component = () => { const hashTags = useHashTags(); ... } ``` ``` //很像useState function useHashtags = () { const [hashTags, setHashtags] = useState(); ... return [hashTags, setHashtags]; } const Component = () => { const [hashTags, setHashtags] = useHashTags(); ... } ``` ``` //可傳參數 function useHashtags = (arg) { const [hashTags, setHashtags] = useState(); const [isLoading, setIsLoading] = useState(); ... return {hashTags, setHashtags, isLoading}; } const Component = () => { const {hashTags, setHashtags,isLoading} = useHashTags(arg); ... } ``` * 官方建議hook名稱**用use開頭**,如果有用,react會幫你檢查有沒有符合hook規則 ## [Presentational and Container Components](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) 共用元件管理分類 UI越偏向Presentational越好 * [classnames](https://www.npmjs.com/package/classnames): className管理套件 * [tailwindcss-classnames](https://www.npmjs.com/package/tailwindcss-classnames): tailwind className管理套件 ## 專案管理 [Trello](https://trello.com/b/h5AdKvKx/yuchien-%E5%97%8E%E5%93%AA%E7%BD%90%E5%AD%90) [plany way](https://planyway.com/) ## 開發紀錄 ### redux + TypeScript 開發 研究官方文件和counter範例花不少時間,而且看不到進度會很緊張。reducerSlice裡同步的有弄懂,但非同步(Thunk)有點卡關。 **卡關**:後來找業界的朋友幫忙教學,發現有useContext + useRerducer加上對redux有初步研究,被教的時候很快就能理解asyncThunk(redux的code style蠻抽象的) ### Auto Verse 手刻功能搭配open api,利用chatGPT生成regex語法開發很順利,並且很幸運能兼容react-Quill ### Backlinks 使用react-Quill/quill-mention套件搭配redux,專案中最困難的功能,原理如下: 1. 先從redux拿到使用者所有的筆記並生成quill-mention能使用的data,再將quill-mention模組匯入react-Quill實現#轉換a連結功能 **卡關**:因為quill-mention模組是動態生成的,而react-Quill沒辦法接受變動的module參數(編輯器會跑不出來)而卡關,後來谷哥提議可使用useMemo來避免component re-render時重新生成quill-mention模組,再判斷如果redux資料尚未從firestore取得時,先避免渲染,待資料取得並生成模組後再渲染react-Quill編輯器。 **待解bug**:目前有使用者帳戶不能沒有筆記的bug,可能是模組不能沒有選項 || 我條件沒設好,暫時先用創建帳戶時就先塞一篇“**教學筆記**”給使用者解決 2. 生成link_notes資料給graphView使用 因Quill生成的資料為html格式,送出筆記時需透過regex先判斷有多少link,再把id存入link_notes array。 **待解bug**:link_notes資料結構現在是`{id:string,title:string}[]`,但其實用不到`title`,所以應該要改為`string[]`(為了抓title還跟chatGPT吵架,結果沒用到) ### GraphView 使用react-graph-vis套件搭配redux開發,分為full-graph跟note-graph(共用component) 1. full-graph:從redux先抓出所有的筆記title跟id生成nodes資料,再抓出所有筆記的link_notes生成edge資料,再根據node被連結的次數來計算node的大小(被連越多次越大顆) 2. note-graph:先從網址取得當前筆記的id,再過濾nodes資料,把跟此id沒任何關聯的筆記排除 react-graph-vis保留不少彈性,可以客製化style和針對不同事件的行為,而且edges資料與nodes資料不對應時也不會報錯,單純不顯示,非常好用! ### CSS [flex子元素宽度超出父元素]會造成推擠其他內容的問題(https://juejin.cn/post/6974356682574921765) #### 解法 设置min-width:0可以解决当flex子元素的子元素大小为auto的情况; 设置overflow不为visible可以解决所有情况下的麻烦; * grow的計算原理: ``` 子元素宽度 = 子元素内容占据宽度(即content宽度) + flex: 1属性分配的宽度 ``` ![](https://i.imgur.com/IiilDV9.png) ### canva算graphView尺寸調整會拉伸的問題 可以用key={toggle}解: component偵測到key改變會重新un-mount,會重新做一次 ## 好用的html tag * progress 可以做進度條 * `<input required>` 可以指定必填 ## Sue小波浪 [Creating an animated wave line with Tailwind CSS](https://daily-dev-tips.com/posts/creating-an-animated-wave-line-with-tailwind-css/) [CSS Wavy Line Animation Effect For Website](https://www.youtube.com/watch?v=BaYd9Y_J71o) ## 功能spec: ### 輸入經節可自動插入經文: 1.[聖經api](https://bible.fhl.net/json/qb.php) 2.研究技術如何做到 ### 分類整理筆記: 1.拖拉筆記位置 ### 心智圖: 1.展示出有link的筆記 2.[Zoom-in Zoom-out](https://www.upbeatcode.com/react/how-to-implement-zoom-image-in-react/) ## 需研究套件: [React-Graph-Vis](https://github.com/crubier/react-graph-vis/tree/master/example) [React-Markdown](https://github.com/remarkjs/react-markdown) [學長姐作品](https://bear-plus.yenchenkuo.com/home) ## API: [信望愛站聖經 JSON API](https://bible.fhl.net/json/) ## [架構圖](https://www.figma.com/file/ztRMfXj4ADbe1Po9FWqVjQ/%E5%80%8B%E4%BA%BA%E5%B0%88%E6%A1%88%E6%9E%B6%E6%A7%8B?node-id=0-1&t=03uwxZJzWMBM6fnf-0) ## Chrome exetention [校友作品](https://chrome.google.com/webstore/detail/catalyst/jhpdbbakbcmoaondmjjjbojlakgkeilo) [校友作品](https://chrome.google.com/webstore/detail/tabshaker/eigbinbgomoampdljpjabchdempfileg?hl=zh-TW&authuser=0) ## 技術選型 #### firebase cloud function 1. next.js 2. firebase cloud function(可以很快地寫出api/不用管理server) 3. 幫你把cors的api導回自己的firebase的server #### firebase cloud storage 1. 注意容量 2. demo帳號/限制檔案大小 #### build -> extention 1. fuzzy search ->cloud function會去監聽firestore的資料,放到 [algolia](https://www.algolia.com/) search(第三方)裡,帳號要先升級 #### authentication 第三方登入 * 可以在前端寫第一步的阻擋`if(!userid){ return }` * 或是在firestore的rule裡寫阻擋規則 #### firestore * 如果有兩個where,會是複合式索引,如果撈不到資料,錯誤訊息關鍵字有索引,有可能是這個原因 * 可以利用索引找分類(key的概念) * 設計資料結構: 看wirefram,看畫面需要哪些資料,再建立資料庫,我們會知道怎樣的格式最容易做渲染 * 是否容易query? * firebase的search只會query那一筆的流量,前端search就是所有的 * [搜尋的運算子](https://firebase.google.com/docs/firestore/query-data/queries#query_operators) * 關鍵字where in可以去bars裡抓某個array裡所有的id * array contains * [onAuthStateChanged](https://firebase.google.com/docs/auth/web/manage-users): 監聽登入狀態保持登入功能 #### firebase config ``` const firebaseConfig = { apiKey: "AIzaSyAizA6LfYHyWib7-7cZb0LVZmpu0pa2qAo", authDomain: "manna-jar.firebaseapp.com", projectId: "manna-jar", storageBucket: "manna-jar.appspot.com", messagingSenderId: "806344207818", appId: "1:806344207818:web:e892e0e500e7748ed2f333", measurementId: "G-NB0SG7Q4HY" }; ``` ## POC紀錄 ### redux: reducer/dispatch [RTK](https://redux-toolkit.js.org/rtk-query/overview)的API [Thunk](https://pjchender.dev/react/redux-thunk/):處理async事件 | React Redux:provider/Hook|[複數reducer處理方法](https://ithelp.ithome.com.tw/articles/10275697) | [官網基礎](https://redux.js.org/tutorials/essentials/part-1-overview-concepts) | [Redux & RTK](https://www.freecodecamp.org/news/redux-and-redux-toolkit-for-beginners/) ### [Firebase Authentication](https://javascript.plainenglish.io/firebase-authentication-with-firestore-database-78e6e4f348c6) with Firestore ``` export const signupUser = (userDetails) => { //deconstruct the users details we will need these later const {firstName, lastName, birthday, email, password} = userDetails return () => { //user firebase using the appropriate firebase method firebase.auth().createUserWithEmailAndPassword(email, password) .then(() => { //Once the user creation has happened successfully, we can add the currentUser into firestore //with the appropriate details. firebase.firestore().collection('users').doc(firebase.auth().currentUser.uid) .set({ firstName: firstName, lastName: lastName, birthday: birthday, email: email }) //ensure we catch any errors at this stage to advise us if something does go wrong .catch(error => { console.log('Something went wrong with added user to firestore: ', error); }) }) //we need to catch the whole sign up process if it fails too. .catch(error => { console.log('Something went wrong with sign up: ', error); } }, }; ``` ``` import {GoogleSignin} from '@react-native-community/google-signin'; import auth, {firebase} from '@react-native-firebase/auth'; import firestore from '@react-native-firebase/firestore'; GoogleSignin.signIn() .then(data => { // data provides us with an idToken and accessToke, we use these to set up a credential const googleCredential = auth.GoogleAuthProvider.credential(data.idToken,data.accessToken); try { firebase.auth().signInWithCredential(googleCredential) .then(user => { //after we have the credential - lets check if the user exists in firestore var docRef = firestore().collection('users').doc(auth().currentUser.uid); docRef.get() .then(doc => { if (doc.exists) { //user exists then just update the login time return user } else { //user doesn't exist - create a new user in firestore resolve(addNewUserToFirestore(user)); } }) .catch(error => { console.error('Checking if customer exists failed" ' + error); }); }) .catch(error => { console.error('GoogleSignIn to firebase Failed ' + error); }) } catch (error) { console.log("Something generic went wrong, ", error ) } }) .catch(error => { console.error('GoogleSignIn to firebase Failed ' + error); }) function addNewUserToFirestore(user) { const collection = firestore().collection('users'); const {profile} = user.additionalUserInfo; const details = { firstName: profile.given_name, lastName: profile.family_name, fullName: profile.name, email: profile.email, picture: profile.picture, createdDtm: firestore.FieldValue.serverTimestamp(), lastLoginTime: firestore.FieldValue.serverTimestamp(), }; collection.doc(auth().currentUser.uid).set(details); return {user, details}; } ``` ### Auto Verse [Auto Verse poc](https://codesandbox.io/s/autoverse-umzlk4) ``` import "./styles.css"; import React, { useState } from "react"; const BibleReference = async (book, chapter, verse) => { try { const response = await fetch( `https://bible.fhl.net/json/qb.php?chineses=${book}&chap=${chapter}&sec=${verse}` ); const data = await response.json(); let verses = `${book} ${chapter}:${verse}\n`; data.record.map((sec) => { verses += `[${sec.sec}]${sec.bible_text}`; }); return verses; } catch (error) { console.error(error); } }; function App() { const [noteContent, setNoteContent] = useState(""); const handleKeyDown = async (event) => { // Check if the pressed key is the space bar if (event.key === " ") { // Get the current cursor position in the text area const cursorPosition = event.target.selectionStart; // Split the text content into two parts: // 1. text before the cursor position // 2. text after the cursor position const beforeCursor = noteContent.slice(0, cursorPosition); const afterCursor = noteContent.slice(cursorPosition); // Find the last Bible reference before the cursor position const lastReference = beforeCursor.match( /([\u4E00-\u9FFF]{1,2})(\d+):([\d,-]+)/ ); if (lastReference) { // If there is a Bible reference, insert the BibleReference component const referenceStart = beforeCursor.lastIndexOf(lastReference[0]); const referenceEnd = referenceStart + lastReference[0].length; const book = lastReference[1]; const chapter = lastReference[2]; const verse = lastReference[3]; await BibleReference(book, chapter, verse) // Call BibleReference component with reference prop .then((text) => { setNoteContent( beforeCursor.slice(0, referenceStart) + text + afterCursor.slice(referenceEnd) ); }); // Prevent the space key from being inserted into the text area event.preventDefault(); } } }; return ( <div> <textarea value={noteContent} onChange={(event) => setNoteContent(event.target.value)} onKeyDown={handleKeyDown} /> </div> ); } export default App; ``` ### backlink實作 #### Quill-mention [quill-mention](https://github.com/quill-mention/quill-mention) [參考轉成link的方案](https://stackoverflow.com/questions/69704201/how-to-insert-link-for-hashtags-and-mentions-in-react-quill) [poc轉成`<a href="">`](https://codesandbox.io/s/quill-mentions-bian-cheng-lian-jie-lftx1i) [markdown parser poc](https://codesandbox.io/s/zi-ding-yi-react-markdown-parser-s1u4g5) ![](https://i.imgur.com/e5tAw1M.png) * [Tailwind處理Quill h1大小方法](https://tools.wingzero.tw/article/sn/973) #### 自定義markdown parser 客製化可以解讀`#title(url?id=note.id)`的parser ``` md = md.replace(/#(\w+)\(([^)]+)\)/g, (match, title, url) => { return `<a value=`${url}` class="text-red" href=${url}>${title}</a>`; }); ``` * 一定要透過dangerouslySetInnerHTML,所以不能吃component, style也要用tailwind * 可以透過bubbling事件來達到`<Link>`的功能 ``` <div dangerouslySetInnerHTML={{ __html: note }} style={{ border: "1px solid #006888", padding: "0 10px" }} onClick={//TODO:bubbling的事件,用nevigate to來實現link的功能} ></div> ``` #### 編輯器 1.先把notes全部抓下來,title/id存成state ``` useEffect(() => { const unsubscribe = db.collection("notes").onSnapshot(snapshot => { const notesData = snapshot.docs.map(doc => { const { title } = doc.data(); return //TODO要製作graph{nodes:[...],edges:[...]}還有抓出跟當前筆記有關的Links { id: doc.id, title }; }); setNotes(notesData); }); return () => unsubscribe(); }, []); ``` 2.onKeyPress事件(e.target.value === #)跳出輸入框,根據輸入搜尋state裡的id,下方會顯示dropdown供點擊 3.點擊選項(id)後會自動置入#note.title(url?id=note.id) 4.點擊submit時會抓取符合#note.title(url?id=note.id)規則的資料,加入link-notes[]裡存到firestore ``` const regex = /#(\w+)\(([^)]+)\)/g; const text = 'This is a #Note1(/note?id=1) and #Note2(/note?id=2).'; const matches = text.matchAll(regex); for (const match of matches) { console.log(match); } ``` 在使用matchAll()方法後,matches會是一個Iterator物件,可以使用for...of迴圈或是將其轉為陣列後使用。 ``` Array [ "#Note1(/note?id=1)", "Note1", "/note?id=1", index: 10, input: "This is a #Note1(/note?id=1) and #Note2(/note?id=2)." ] Array [ "#Note2(/note?id=2)", "Note2", "/note?id=2", index: 26, input: "This is a #Note1(/note?id=1) and #Note2(/note?id=2)." ] ``` ### [AI助理](https://platform.openai.com/docs/guides/chat/chat-vs-completions) `openai.createChatCompletion()` 方法接受以下参数: 1. **model**:指定要使用的模型。目前支持的模型是 "gpt-3.5-turbo",它是 OpenAI GPT-3.5 Turbo 模型,具有强大的生成能力。 2. **messages**:包含一个数组,其中包含对话中的用户和助手角色的消息。每个消息都有两个属性:**role** 表示角色 和 **content** 表示消息内容。 **role**包括: -**system**:系统角色,用于传递系统级指令或提示,以控制生成模型的行为。 -**user**:用户角色,表示用户的消息或输入。 -**assistant**:助手角色,表示生成模型的回复或生成的文本。 其他系统级参数:您可以在 messages 中使用 system 角色来向模型传递系统级指令,例如更改温度或最大令牌限制,以影响生成的行为。 **Response format:** ``` { 'id': 'chatcmpl-6p9XYPYSTTRi0xEviKjjilqrWU2Ve', 'object': 'chat.completion', 'created': 1677649420, 'model': 'gpt-3.5-turbo', 'usage': {'prompt_tokens': 56, 'completion_tokens': 31, 'total_tokens': 87}, 'choices': [ { 'message': { 'role': 'assistant', 'content': 'The 2020 World Series was played in Arlington, Texas at the Globe Life Field, which was the new home stadium for the Texas Rangers.'}, 'finish_reason': 'stop', 'index': 0 } ] } ``` ### react-graph-vis [typeScript定義檔](https://gist.github.com/ChadJPetersen/2e2587bbd753c6a384c02519183e2031) [graphview的codesandbox]() ### D3 了解如何使用D3.js和React来实现一个简单的图表: ``` import React, { useEffect, useRef } from 'react'; import * as d3 from 'd3'; function GraphView({ data }) { const svgRef = useRef(); useEffect(() => { const svg = d3.select(svgRef.current); const nodes = data.nodes; const links = data.links; // 创建力导向图力学模拟器 const simulation = d3.forceSimulation(nodes) .force('link', d3.forceLink(links).id(d => d.id)) .force('charge', d3.forceManyBody().strength(-100)) .force('center', d3.forceCenter(400, 400)); // 绘制节点 const node = svg.selectAll('.node') .data(nodes) .enter() .append('circle') .attr('r', 10) .attr('fill', '#2196f3') .call(d3.drag() .on('start', dragstart) .on('drag', drag) .on('end', dragend)); // 绘制连线 const link = svg.selectAll('.link') .data(links) .enter() .append('line') .attr('stroke', '#bdbdbd') .attr('stroke-width', 2); // 更新节点和连线位置 function ticked() { link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); node .attr('cx', d => d.x) .attr('cy', d => d.y); } // 开始拖拽节点 function dragstart(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } // 拖拽节点过程中更新节点位置 function drag(event, d) { d.fx = event.x; d.fy = event.y; } // 结束拖拽节点 function dragend(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; } // 每次模拟器更新时调用ticked函数更新节点和连线位置 simulation.on('tick', ticked); // 在组件卸载时停止模拟器 return () => { simulation.stop(); }; }, [data]); return ( <svg ref={svgRef} width="800" height="800"></svg> ); } export default GraphView; ``` Obsidian的Graph View使用了D3.js和webcola这两个开源库来实现。其中,D3.js用于创建可交互的力导向图,而webcola用于布局节点和连线。同时,Obsidian还使用了一些自定义的代码来增强Graph View的功能,例如支持节点标签、过滤器和笔记搜索等。 Logseq的Graph View同样使用了D3.js来创建力导向图,并使用了React和Redux来构建用户界面和管理应用程序的状态。Logseq的Graph View支持对笔记进行标记、搜索和筛选,并且可以根据笔记的重要性和相似性来调整节点的大小和颜色。此外,Logseq还提供了一个强大的插件API,使用户可以自由地扩展和定制Graph View的功能。 ### Link的hover事件POC 1. `dangerouslySetInnerHTML={{ __html: currentNote.content }}`沒辦法做到分辨node,因為置入的html不會生成node 2. console.dir可以印出HTMLnode的對應屬性 3. React的[hover](https://upmostly.com/tutorials/react-onhover-event-handling-with-examples)事件為 * onMouseEnter={() => {}} * onMouseLeave={() => {}} * onMouseOver={() => {}} ``` onMouseEnter={(e) => { const nodeList = e.target.childNodes; nodeList.forEach((node) => console.log(node.childNodes[0].dataset?.id) ); }} ``` 目前可做到取得links的id值,但無法解決位置問題 ### 分頁功能 ``` // state const [currentPage, setCurrentPage] = useState<number>(1); // 變數 const PAGE_SIZE = 10; const totalPages = Math.ceil(articles.length / PAGE_SIZE); // currentPageArticle 這個變數放當前頁面要顯示的文章(displayedArticles 是可以使用搜尋功能的文章,預設會顯示所有文章) const currentPageArticles = displayedArticles // 依時間排序 .sort((a, b) => { if (a.date && b.date) { return new Date(b.date).getTime() - new Date(a.date).getTime(); } else { return 0; } }) // 用 slice 切出點到的分頁要顯示的文章 .slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); const handlePageChange = (pageNumber: number) => { setCurrentPage(pageNumber); }; // return 出 pagination 的元件 <Pagination> {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( <button key={page} onClick={() => handlePageChange(page)} disabled={page === currentPage} > {page} </button> ))} </Pagination> ```