Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c513be305 | ||
|
|
11d05b303d | ||
|
|
c870dbeac4 | ||
|
|
3acd36e580 | ||
|
|
95d4fb6865 | ||
|
|
30b52a29fd | ||
|
|
a17d6b7eec | ||
|
|
18ed553296 | ||
|
|
7f74fd6149 | ||
|
|
a3e09f6021 | ||
|
|
5e0a0f92ff | ||
|
|
2a2b5fe246 | ||
|
|
f914e7aa33 | ||
|
|
386d3f2ba7 | ||
|
|
88faa0f02a |
No files matched your search
@@ -7,6 +7,13 @@
|
||||
- Unable to copy and paste a word
|
||||
- Some UI issues
|
||||
|
||||
## Version 0.2.4 - 06/23/2023
|
||||
|
||||
### Bug fixes:
|
||||
|
||||
- Unable to copy and paste a word
|
||||
- Some UI issues
|
||||
|
||||
## Version 0.2.3 - 06/21/2023
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -23,7 +23,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
|
||||
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
|
||||
CARGO_MAKE_CRATE_NAME = "dart-ffi"
|
||||
LIB_NAME = "dart_ffi"
|
||||
CURRENT_APP_VERSION = "0.2.4"
|
||||
CURRENT_APP_VERSION = "0.2.5"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
# CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html
|
||||
|
||||
@@ -90,7 +90,8 @@
|
||||
"name": "Debug Info",
|
||||
"success": "Copied debug info to clipboard!",
|
||||
"fail": "Unable to copy debug info to clipboard"
|
||||
}
|
||||
},
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"menuAppHeader": {
|
||||
"addPageTooltip": "Quickly add a page inside",
|
||||
@@ -384,6 +385,9 @@
|
||||
"createANewCalendar": "Create a new Calendar"
|
||||
}
|
||||
},
|
||||
"selectionMenu": {
|
||||
"outline": "Outline"
|
||||
},
|
||||
"plugins": {
|
||||
"referencedBoard": "Referenced Board",
|
||||
"referencedGrid": "Referenced Grid",
|
||||
@@ -448,6 +452,12 @@
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"defaultColor": "Default"
|
||||
},
|
||||
"image": {
|
||||
"copiedToPasteBoard": "The image link has been copied to the clipboard"
|
||||
},
|
||||
"outline": {
|
||||
"addHeadingToCreateOutline": "Add headings to create a table of contents."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -120,14 +120,14 @@
|
||||
"oAuth": {
|
||||
"err": {
|
||||
"failedTitle": "Sikertelen bejelentkezés.",
|
||||
"failedMsg": "Kérjük győződj meg róla, hogy elvégezted a bejelentkezési folyamatot a böngésződben"
|
||||
"failedMsg": "Kérjük győződj meg róla, hogy elvégezted a bejelentkezési folyamatot a böngésződben!"
|
||||
},
|
||||
"google": {
|
||||
"title": "Bejelentkezés Google-al",
|
||||
"title": "Bejelentkezés Google-lal",
|
||||
"instruction1": "Ahhoz, hogy hozzáférj a Google Kontaktjaidhoz, kérjük hatalmazd fel ezt az alkalmazást a böngésződben.",
|
||||
"instruction2": "Másold ezt a kódot a vágólapra az ikonra kattintással vagy a szöveg kijelölésével:",
|
||||
"instruction3": "Nyisd meg ezt a linket a böngésződben, és írjd be a fenti kódot:",
|
||||
"instruction4": "Nyomd meg az alábbi gombot, ha elvégezted a registrációt:"
|
||||
"instruction3": "Nyisd meg ezt a linket a böngésződben, és írd be a fenti kódot:",
|
||||
"instruction4": "Nyomd meg az alábbi gombot, ha elvégezted a regisztrációt:"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -139,15 +139,15 @@
|
||||
},
|
||||
"appearance": {
|
||||
"themeMode": {
|
||||
"label": "Theme Mode",
|
||||
"label": "Téma",
|
||||
"light": "Világos mód",
|
||||
"dark": "Éjjeli mód",
|
||||
"system": "Adapt to System"
|
||||
"system": "Rendszerbeállítás követése"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sideBar": {
|
||||
"openSidebar": "Open sidebar",
|
||||
"closeSidebar": "Close sidebar"
|
||||
"openSidebar": "Oldalsáv megnyitása",
|
||||
"closeSidebar": "Oldalsáv bezárása"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
"subscribeNewsletterText": "消息订阅",
|
||||
"letsGoButtonText": "开始",
|
||||
"title": "标题",
|
||||
"youCanAlso": "您还可以",
|
||||
"and": "以及",
|
||||
"signUp": {
|
||||
"buttonText": "注册",
|
||||
"title": "注册 @:appName 账户",
|
||||
@@ -21,6 +23,7 @@
|
||||
"signIn": {
|
||||
"loginTitle": "登录 @:appName",
|
||||
"loginButtonText": "登录",
|
||||
"loginAsGuestButtonText": "开始使用",
|
||||
"buttonText": "登录",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"emailHint": "邮箱",
|
||||
@@ -40,6 +43,21 @@
|
||||
"markdown": "Markdown",
|
||||
"copyLink": "复制链接"
|
||||
},
|
||||
"moreAction": {
|
||||
"small": "小",
|
||||
"medium": "中",
|
||||
"large": "大",
|
||||
"fontSize": "字号",
|
||||
"import": "导入",
|
||||
"moreOptions": "更多选项"
|
||||
},
|
||||
"importPanel": {
|
||||
"textAndMarkdown": "文本 & Markdown",
|
||||
"documentFromV010": "来自 v0.1.0 的文档",
|
||||
"databaseFromV010": "来自 v0.1.0 的数据库",
|
||||
"csv": "CSV",
|
||||
"database": "数据库"
|
||||
},
|
||||
"disclosureAction": {
|
||||
"rename": "重命名",
|
||||
"delete": "删除",
|
||||
@@ -54,7 +72,7 @@
|
||||
"pageHeader": {
|
||||
"fileName": "文件名",
|
||||
"lastModified": "最近修改",
|
||||
"created": "创建"
|
||||
"created": "创建时间"
|
||||
}
|
||||
},
|
||||
"deletePagePrompt": {
|
||||
@@ -64,8 +82,10 @@
|
||||
},
|
||||
"dialogCreatePageNameHint": "页面名称",
|
||||
"questionBubble": {
|
||||
"shortcuts": "快捷键",
|
||||
"whatsNew": "新功能?",
|
||||
"help": "帮助 & 支持",
|
||||
"markdown": "Markdown",
|
||||
"debug": {
|
||||
"name": "调试信息",
|
||||
"success": "将调试信息复制到剪贴板!",
|
||||
@@ -90,14 +110,18 @@
|
||||
"inlineCode": "内联代码",
|
||||
"quote": "块引用",
|
||||
"header": "标题",
|
||||
"highlight": "高亮"
|
||||
"highlight": "高亮",
|
||||
"color": "颜色"
|
||||
},
|
||||
"tooltip": {
|
||||
"lightMode": "切换到亮色模式",
|
||||
"darkMode": "切换到暗色模式",
|
||||
"openAsPage": "作为页面打开",
|
||||
"addNewRow": "增加一行",
|
||||
"openMenu": "点击打开菜单"
|
||||
"addNewRow": "添加一行",
|
||||
"openMenu": "点击打开菜单",
|
||||
"dragRow": "拖动来调整行",
|
||||
"viewDataBase": "查看数据库",
|
||||
"referencePage": "这个 {name} 正在被引用"
|
||||
},
|
||||
"sideBar": {
|
||||
"openSidebar": "打开侧边栏",
|
||||
@@ -117,11 +141,19 @@
|
||||
},
|
||||
"button": {
|
||||
"OK": "确认",
|
||||
"Done": "完成",
|
||||
"Cancel": "取消",
|
||||
"signIn": "登录",
|
||||
"signOut": "登出",
|
||||
"complete": "完成",
|
||||
"save": "保存"
|
||||
"save": "保存",
|
||||
"generate": "生成",
|
||||
"esc": "ESC",
|
||||
"keep": "保留",
|
||||
"tryAgain": "重试",
|
||||
"discard": "放弃",
|
||||
"replace": "替换",
|
||||
"insertBelow": "下方插入"
|
||||
},
|
||||
"label": {
|
||||
"welcome": "欢迎!",
|
||||
@@ -149,37 +181,133 @@
|
||||
"appearance": "外观",
|
||||
"language": "语言",
|
||||
"user": "用户",
|
||||
"files": "文件",
|
||||
"open": "打开设置"
|
||||
},
|
||||
"appearance": {
|
||||
"themeMode": {
|
||||
"label": "Theme Mode",
|
||||
"label": "主题模式",
|
||||
"light": "日间模式",
|
||||
"dark": "夜间模式",
|
||||
"system": "Adapt to System"
|
||||
}
|
||||
"system": "系统自适应"
|
||||
},
|
||||
"theme": "主题"
|
||||
},
|
||||
"files": {
|
||||
"copy": "复制",
|
||||
"defaultLocation": "读取文件和数据存储位置",
|
||||
"exportData": "导出您的数据",
|
||||
"doubleTapToCopy": "双击复制路径",
|
||||
"restoreLocation": "恢复为 AppFlowy 默认路径",
|
||||
"customizeLocation": "打开另一个文件夹",
|
||||
"restartApp": "请重启 app 使设置生效",
|
||||
"exportDatabase": "导出数据库",
|
||||
"selectFiles": "选择需要导出的文件",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选",
|
||||
"createNewFolder": "新建文件夹",
|
||||
"createNewFolderDesc": "告诉我们您要将数据存储在何处",
|
||||
"defineWhereYourDataIsStored": "定义数据存储位置",
|
||||
"open": "打开",
|
||||
"openFolder": "打开现有文件夹",
|
||||
"openFolderDesc": "读取并将其写入您现有的 AppFlowy 文件夹",
|
||||
"folderHintText": "文件夹名",
|
||||
"location": "正在新建文件夹",
|
||||
"locationDesc": "为您的 AppFlowy 数据文件夹选择一个名称",
|
||||
"browser": "浏览",
|
||||
"create": "新建",
|
||||
"set": "设置",
|
||||
"folderPath": "保存文件夹的路径",
|
||||
"locationCannotBeEmpty": "路径不能为空",
|
||||
"pathCopiedSnackbar": "文件存储路径已被复制到剪贴板!",
|
||||
"changeLocationTooltips": "更改数据目录",
|
||||
"change": "更改",
|
||||
"openLocationTooltips": "打开另一个数据目录",
|
||||
"openCurrentDataFolder": "打开当前数据目录",
|
||||
"recoverLocationTooltips": "恢复为 AppFlowy 默认数据目录",
|
||||
"exportFileSuccess": "导出文件成功!",
|
||||
"exportFileFail": "导出文件失败!",
|
||||
"export": "导出"
|
||||
},
|
||||
"user": {
|
||||
"name": "名字",
|
||||
"icon": "图标",
|
||||
"selectAnIcon": "选择一个图标",
|
||||
"pleaseInputYourOpenAIKey": "请输入您的 OpenAI 密钥"
|
||||
}
|
||||
},
|
||||
"grid": {
|
||||
"deleteView": "您确定要删除这个视窗吗?",
|
||||
"createView": "新建",
|
||||
"settings": {
|
||||
"filter": "过滤器",
|
||||
"filter": "筛选",
|
||||
"sort": "以……排序",
|
||||
"sortBy": "排序",
|
||||
"Properties": "属性",
|
||||
"group": "组"
|
||||
"group": "组",
|
||||
"addFilter": "添加筛选",
|
||||
"deleteFilter": "删除筛选",
|
||||
"filterBy": "以……筛选",
|
||||
"typeAValue": "请输入一个值",
|
||||
"layout": "布局",
|
||||
"databaseLayout": "布局"
|
||||
},
|
||||
"textFilter": {
|
||||
"contains": "包含",
|
||||
"doesNotContain": "不包含",
|
||||
"endsWith": "以……结束",
|
||||
"startWith": "以……开始",
|
||||
"is": "等于",
|
||||
"isNot": "不等于",
|
||||
"isEmpty": "为空",
|
||||
"isNotEmpty": "不为空",
|
||||
"choicechipPrefix": {
|
||||
"isNot": "不等于",
|
||||
"startWith": "以……开始",
|
||||
"endWith": "以……结束",
|
||||
"isEmpty": "为空",
|
||||
"isNotEmpty": "不为空"
|
||||
}
|
||||
},
|
||||
"checkboxFilter": {
|
||||
"isChecked": "已勾选",
|
||||
"isUnchecked": "未勾选",
|
||||
"choicechipPrefix": {
|
||||
"is": "is"
|
||||
}
|
||||
},
|
||||
"checklistFilter": {
|
||||
"isComplete": "已完成",
|
||||
"isIncomplted": "未完成"
|
||||
},
|
||||
"singleSelectOptionFilter": {
|
||||
"is": "等于",
|
||||
"isNot": "不等于",
|
||||
"isEmpty": "为空",
|
||||
"isNotEmpty": "不为空"
|
||||
},
|
||||
"multiSelectOptionFilter": {
|
||||
"contains": "包含",
|
||||
"doesNotContain": "不包含",
|
||||
"isEmpty": "为空",
|
||||
"isNotEmpty": "不为空"
|
||||
},
|
||||
"field": {
|
||||
"hide": "隐藏",
|
||||
"insertLeft": "左侧插入",
|
||||
"insertRight": "右侧插入",
|
||||
"duplicate": "拷贝",
|
||||
"duplicate": "复制",
|
||||
"delete": "删除",
|
||||
"textFieldName": "文本",
|
||||
"checkboxFieldName": "勾选框",
|
||||
"dateFieldName": "日期",
|
||||
"updatedAtFieldName": "修改时间",
|
||||
"createdAtFieldName": "创建时间",
|
||||
"numberFieldName": "数字",
|
||||
"singleSelectFieldName": "单项选择器",
|
||||
"multiSelectFieldName": "多项选择器",
|
||||
"urlFieldName": "链接",
|
||||
"checklistFieldName": "清单",
|
||||
"numberFormat": "数字格式",
|
||||
"dateFormat": "日期格式",
|
||||
"includeTime": "包含时间",
|
||||
@@ -187,6 +315,7 @@
|
||||
"dateFormatISO": "年-月-日",
|
||||
"dateFormatLocal": "月/日/年",
|
||||
"dateFormatUS": "年/月/日",
|
||||
"dateFormatDayMonthYear": "日/月/年",
|
||||
"timeFormat": "时间格式",
|
||||
"invalidTimeFormat": "时间格式错误",
|
||||
"timeFormatTwelveHour": "12小时制",
|
||||
@@ -195,16 +324,23 @@
|
||||
"optionTitle": "标签",
|
||||
"addOption": "添加标签",
|
||||
"editProperty": "编辑列属性",
|
||||
"newProperty": "增加一列",
|
||||
"newProperty": "添加一列",
|
||||
"deleteFieldPromptMessage": "确定要删除这个属性吗? "
|
||||
},
|
||||
"sort": {
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"deleteSort": "删除排序",
|
||||
"addSort": "添加排序"
|
||||
},
|
||||
"row": {
|
||||
"duplicate": "复制",
|
||||
"delete": "删除",
|
||||
"textPlaceholder": "空",
|
||||
"copyProperty": "复制列",
|
||||
"count": "数量",
|
||||
"newRow": "添加一行"
|
||||
"newRow": "添加一行",
|
||||
"action": "执行"
|
||||
},
|
||||
"selectOption": {
|
||||
"create": "新建",
|
||||
@@ -222,19 +358,130 @@
|
||||
"panelTitle": "选择或新建一个标签",
|
||||
"searchOption": "搜索标签"
|
||||
},
|
||||
"menuName": "网格"
|
||||
"checklist": {
|
||||
"panelTitle": "添加项"
|
||||
},
|
||||
"menuName": "网格",
|
||||
"referencedGridPrefix": "视图"
|
||||
},
|
||||
"document": {
|
||||
"menuName": "文档",
|
||||
"date": {
|
||||
"timeHintTextInTwelveHour": "01:00 PM",
|
||||
"timeHintTextInTwentyFourHour": "13:00"
|
||||
},
|
||||
"slashMenu": {
|
||||
"board": {
|
||||
"selectABoardToLinkTo": "选择要链接到的看板",
|
||||
"createANewBoard": "新建看板"
|
||||
},
|
||||
"grid": {
|
||||
"selectAGridToLinkTo": "选择要链接到的网格",
|
||||
"createANewGrid": "新建网格"
|
||||
},
|
||||
"calendar": {
|
||||
"selectACalendarToLinkTo": "选择要链接到的日历",
|
||||
"createANewCalendar": "新建日历"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"referencedBoard": "引用的看板",
|
||||
"referencedGrid": "引用的网格",
|
||||
"referencedCalendar": "引用的日历",
|
||||
"autoGeneratorMenuItemName": "OpenAI 创作",
|
||||
"autoGeneratorTitleName": "OpenAI: 让 AI 写些什么...",
|
||||
"autoGeneratorLearnMore": "学习更多",
|
||||
"autoGeneratorGenerate": "生成",
|
||||
"autoGeneratorHintText": "让 OpenAI ...",
|
||||
"autoGeneratorCantGetOpenAIKey": "无法获得 OpenAI 密钥",
|
||||
"autoGeneratorRewrite": "重写",
|
||||
"smartEdit": "AI 助手",
|
||||
"openAI": "OpenAI",
|
||||
"smartEditFixSpelling": "修正拼写",
|
||||
"warning": "⚠️ AI 可能不准确或具有误导性.",
|
||||
"smartEditSummarize": "总结",
|
||||
"smartEditImproveWriting": "提高写作水平",
|
||||
"smartEditMakeLonger": "丰富内容",
|
||||
"smartEditCouldNotFetchResult": "无法从 OpenAI 获取到结果",
|
||||
"smartEditCouldNotFetchKey": "无法获取到 OpenAI 密钥",
|
||||
"smartEditDisabled": "在设置中连接 OpenAI",
|
||||
"discardResponse": "您是否要放弃 AI 继续写作?",
|
||||
"cover": {
|
||||
"changeCover": "修改封面",
|
||||
"colors": "颜色",
|
||||
"images": "图像",
|
||||
"clearAll": "清除所有",
|
||||
"abstract": "摘要",
|
||||
"addCover": "添加封面",
|
||||
"addLocalImage": "添加本地图像",
|
||||
"invalidImageUrl": "无效的图像网址",
|
||||
"failedToAddImageToGallery": "无法将图片添加到库",
|
||||
"enterImageUrl": "输入图像网址",
|
||||
"add": "添加",
|
||||
"back": "返回",
|
||||
"saveToGallery": "保存至库",
|
||||
"removeIcon": "移除图标",
|
||||
"pasteImageUrl": "粘贴图片网址",
|
||||
"or": "或",
|
||||
"pickFromFiles": "从文件中选取",
|
||||
"couldNotFetchImage": "无法获取到图像",
|
||||
"imageSavingFailed": "图像保存失败",
|
||||
"addIcon": "添加图标",
|
||||
"coverRemoveAlert": "删除后将从封面中移除。",
|
||||
"alertDialogConfirmation": "您确定您要继续吗?"
|
||||
},
|
||||
"mathEquation": {
|
||||
"addMathEquation": "添加数学公式",
|
||||
"editMathEquation": "编辑数学公式"
|
||||
},
|
||||
"optionAction": {
|
||||
"click": "点击",
|
||||
"toOpenMenu": " 来打开菜单",
|
||||
"delete": "删除",
|
||||
"duplicate": "复制",
|
||||
"turnInto": "变成",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"color": "颜色",
|
||||
"align": "对齐",
|
||||
"left": "左",
|
||||
"center": "中心",
|
||||
"right": "又",
|
||||
"defaultColor": "默认"
|
||||
}
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"column": {
|
||||
"create_new_card": "新建"
|
||||
},
|
||||
"menuName": "看板"
|
||||
"menuName": "看板",
|
||||
"referencedBoardPrefix": "视图"
|
||||
},
|
||||
"calendar": {
|
||||
"menuName": "日历",
|
||||
"defaultNewCalendarTitle": "未命名",
|
||||
"navigation": {
|
||||
"today": "今天",
|
||||
"jumpToday": "跳转到今天",
|
||||
"previousMonth": "上一月",
|
||||
"nextMonth": "下一月"
|
||||
},
|
||||
"settings": {
|
||||
"showWeekNumbers": "显示周数",
|
||||
"showWeekends": "显示周末",
|
||||
"firstDayOfWeek": "一周开始于",
|
||||
"layoutDateField": "以……为日历布局",
|
||||
"noDateTitle": "没有日期",
|
||||
"noDateHint": "计划外事件将显示在此处",
|
||||
"clickToAdd": "单击以添加到日历",
|
||||
"name": "日历布局"
|
||||
},
|
||||
"referencedCalendarPrefix": "视图"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "AppFlowy 错误",
|
||||
"howToFixFallback": "对于给您带来的不便, 我们深表歉意! 请在我们的 GitHub 页面上提交 issue 并描述您遇到的错误。",
|
||||
"github": "在 GitHub 查看"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,12 +206,13 @@ void main() {
|
||||
await tester.tapCreateCalendarButton();
|
||||
|
||||
// Create a new event in today's calendar cell
|
||||
final today = DateTime.now();
|
||||
await tester.scrollToToday();
|
||||
await tester.doubleClickCalendarCell(DateTime.now());
|
||||
await tester.doubleClickCalendarCell(today);
|
||||
await tester.dismissRowDetailPage();
|
||||
|
||||
// Make sure that the event is today
|
||||
tester.assertNumberofEventsOnSpecificDay(1, DateTime.now());
|
||||
tester.assertNumberofEventsOnSpecificDay(1, today);
|
||||
|
||||
// Click on the event
|
||||
await tester.openCalendarEvent(index: 0);
|
||||
@@ -220,9 +221,11 @@ void main() {
|
||||
await tester.tapDateCellInRowDetailPage();
|
||||
await tester.findDateEditor(findsOneWidget);
|
||||
|
||||
// Edit the event's date
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
await tester.selectDay(content: tomorrow.day);
|
||||
// Edit the event's date. To avoid selecting a day outside of the current month, the new date will be one day closer to the middle of the month.
|
||||
final newDate = today.day < 15
|
||||
? today.add(const Duration(days: 1))
|
||||
: today.subtract(const Duration(days: 1));
|
||||
await tester.selectDay(content: newDate.day);
|
||||
await tester.dismissCellEditor();
|
||||
|
||||
// Dismiss the row details page
|
||||
@@ -230,7 +233,7 @@ void main() {
|
||||
|
||||
// Make sure that the event is edited
|
||||
tester.assertNumberOfEventsInCalendar(1);
|
||||
tester.assertNumberofEventsOnSpecificDay(1, tomorrow);
|
||||
tester.assertNumberofEventsOnSpecificDay(1, newDate);
|
||||
});
|
||||
|
||||
testWidgets('reschedule an event by drag-and-drop', (tester) async {
|
||||
|
||||
@@ -237,7 +237,7 @@ void main() {
|
||||
await tester.assertDateCellInGrid(
|
||||
rowIndex: 0,
|
||||
fieldType: fieldType,
|
||||
content: DateFormat('MMM d, y').format(today),
|
||||
content: DateFormat('MMM dd, y').format(today),
|
||||
);
|
||||
|
||||
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
|
||||
@@ -252,7 +252,7 @@ void main() {
|
||||
await tester.assertDateCellInGrid(
|
||||
rowIndex: 0,
|
||||
fieldType: fieldType,
|
||||
content: DateFormat('MMM d, y HH:mm').format(now),
|
||||
content: DateFormat('MMM dd, y HH:mm').format(now),
|
||||
);
|
||||
|
||||
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'util/database_test_op.dart';
|
||||
import 'util/emoji.dart';
|
||||
import 'util/ime.dart';
|
||||
import 'util/util.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../util/emoji.dart';
|
||||
import '../util/util.dart';
|
||||
|
||||
void main() {
|
||||
@@ -22,15 +24,112 @@ void main() {
|
||||
await TestFolder.cleanTestLocation(null);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'hovering on cover image will display change and delete cover image buttons',
|
||||
(tester) async {
|
||||
testWidgets('document cover tests', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
|
||||
await tester.tapGoButton();
|
||||
await tester.editor.hoverOnCoverPluginAddButton();
|
||||
|
||||
tester.expectToSeeNoDocumentCover();
|
||||
|
||||
// Hover over cover toolbar to show 'Add Cover' and 'Add Icon' buttons
|
||||
await tester.editor.hoverOnCoverToolbar();
|
||||
tester.expectToSeePluginAddCoverAndIconButton();
|
||||
|
||||
// Insert a document cover
|
||||
await tester.editor.tapOnAddCover();
|
||||
tester.expectToSeeDocumentCover(CoverType.asset);
|
||||
|
||||
// Hover over the cover to show the 'Change Cover' and delete buttons
|
||||
await tester.editor.hoverOnCover();
|
||||
tester.expectChangeCoverAndDeleteButton();
|
||||
|
||||
// Change cover to a solid color background
|
||||
await tester.editor.tapOnChangeCover();
|
||||
await tester.editor.switchSolidColorBackground();
|
||||
await tester.editor.dismissCoverPicker();
|
||||
tester.expectToSeeDocumentCover(CoverType.color);
|
||||
|
||||
// Change cover to a network image
|
||||
const imageUrl =
|
||||
"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy/main/frontend/appflowy_flutter/assets/images/appflowy_launch_splash.jpg";
|
||||
await tester.editor.hoverOnCover();
|
||||
await tester.editor.tapOnChangeCover();
|
||||
await tester.editor.addNetworkImageCover(imageUrl);
|
||||
await tester.editor.switchNetworkImageCover(imageUrl);
|
||||
await tester.editor.dismissCoverPicker();
|
||||
tester.expectToSeeDocumentCover(CoverType.file);
|
||||
|
||||
// Remove the cover
|
||||
await tester.editor.hoverOnCover();
|
||||
await tester.editor.tapOnRemoveCover();
|
||||
tester.expectToSeeNoDocumentCover();
|
||||
});
|
||||
|
||||
testWidgets('document icon tests', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
tester.expectToSeeDocumentIcon(null);
|
||||
|
||||
// Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons
|
||||
await tester.editor.hoverOnCoverToolbar();
|
||||
tester.expectToSeePluginAddCoverAndIconButton();
|
||||
|
||||
// Insert a document icon
|
||||
await tester.editor.tapAddIconButton();
|
||||
await tester.switchToEmojiList();
|
||||
await tester.tapEmoji('😀');
|
||||
tester.expectToSeeDocumentIcon('😀');
|
||||
|
||||
// Remove the document icon from the cover toolbar
|
||||
await tester.editor.hoverOnCoverToolbar();
|
||||
await tester.editor.tapRemoveIconButton();
|
||||
tester.expectToSeeDocumentIcon(null);
|
||||
|
||||
// Add the icon back for further testing
|
||||
await tester.editor.hoverOnCoverToolbar();
|
||||
await tester.editor.tapAddIconButton();
|
||||
await tester.switchToEmojiList();
|
||||
await tester.tapEmoji('😀');
|
||||
tester.expectToSeeDocumentIcon('😀');
|
||||
|
||||
// Change the document icon
|
||||
await tester.editor.tapOnIconWidget();
|
||||
await tester.switchToEmojiList();
|
||||
await tester.tapEmoji('😅');
|
||||
tester.expectToSeeDocumentIcon('😅');
|
||||
|
||||
// Remove the document icon from the icon picker
|
||||
await tester.editor.tapOnIconWidget();
|
||||
await tester.editor.tapRemoveIconButton(isInPicker: true);
|
||||
tester.expectToSeeDocumentIcon(null);
|
||||
});
|
||||
|
||||
testWidgets('icon and cover at the same time', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
tester.expectToSeeDocumentIcon(null);
|
||||
tester.expectToSeeNoDocumentCover();
|
||||
|
||||
// Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons
|
||||
await tester.editor.hoverOnCoverToolbar();
|
||||
tester.expectToSeePluginAddCoverAndIconButton();
|
||||
|
||||
// Insert a document icon
|
||||
await tester.editor.tapAddIconButton();
|
||||
await tester.switchToEmojiList();
|
||||
await tester.tapEmoji('😀');
|
||||
|
||||
// Insert a document cover
|
||||
await tester.editor.tapOnAddCover();
|
||||
|
||||
// Expect to see the icon and cover at the same time
|
||||
tester.expectToSeeDocumentIcon('😀');
|
||||
tester.expectToSeeDocumentCover(CoverType.asset);
|
||||
|
||||
// Hover over the cover toolbar and see that neither icons are shown
|
||||
await tester.editor.hoverOnCoverToolbar();
|
||||
tester.expectToSeeEmptyDocumentHeaderToolbar();
|
||||
});
|
||||
});
|
||||
}
|
||||
+15
-14
@@ -1,4 +1,5 @@
|
||||
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
|
||||
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
|
||||
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
|
||||
@@ -56,21 +57,21 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
// testWidgets('insert a referenced calendar', (tester) async {
|
||||
// await tester.initializeAppFlowy();
|
||||
// await tester.tapGoButton();
|
||||
testWidgets('insert a referenced calendar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
// await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
|
||||
await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
|
||||
|
||||
// // validate the referenced grid is inserted
|
||||
// expect(
|
||||
// find.descendant(
|
||||
// of: find.byType(AppFlowyEditor),
|
||||
// matching: find.byType(CalendarPage),
|
||||
// ),
|
||||
// findsOneWidget,
|
||||
// );
|
||||
// });
|
||||
// validate the referenced grid is inserted
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppFlowyEditor),
|
||||
matching: find.byType(CalendarPage),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ Future<void> insertReferenceDatabase(
|
||||
);
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
// insert a referenced grid
|
||||
// insert a referenced view
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
layout.referencedMenuName,
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flowy_infra_ui/widget/error_page.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../util/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('inline page view in document', () {
|
||||
const location = 'inline_page';
|
||||
|
||||
setUp(() async {
|
||||
await TestFolder.cleanTestLocation(location);
|
||||
await TestFolder.setTestLocation(location);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await TestFolder.cleanTestLocation(null);
|
||||
});
|
||||
|
||||
testWidgets('insert a inline page - grid', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await insertingInlinePage(tester, ViewLayoutPB.Grid);
|
||||
|
||||
final mentionBlock = find.byType(MentionPageBlock);
|
||||
expect(mentionBlock, findsOneWidget);
|
||||
await tester.tapButton(mentionBlock);
|
||||
});
|
||||
|
||||
testWidgets('insert a inline page - board', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await insertingInlinePage(tester, ViewLayoutPB.Board);
|
||||
|
||||
final mentionBlock = find.byType(MentionPageBlock);
|
||||
expect(mentionBlock, findsOneWidget);
|
||||
await tester.tapButton(mentionBlock);
|
||||
});
|
||||
|
||||
testWidgets('insert a inline page - calendar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await insertingInlinePage(tester, ViewLayoutPB.Calendar);
|
||||
|
||||
final mentionBlock = find.byType(MentionPageBlock);
|
||||
expect(mentionBlock, findsOneWidget);
|
||||
await tester.tapButton(mentionBlock);
|
||||
});
|
||||
|
||||
testWidgets('insert a inline page - document', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await insertingInlinePage(tester, ViewLayoutPB.Document);
|
||||
|
||||
final mentionBlock = find.byType(MentionPageBlock);
|
||||
expect(mentionBlock, findsOneWidget);
|
||||
await tester.tapButton(mentionBlock);
|
||||
});
|
||||
|
||||
testWidgets('insert a inline page and rename it', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
final pageName = await insertingInlinePage(tester, ViewLayoutPB.Document);
|
||||
|
||||
// rename
|
||||
await tester.hoverOnPageName(pageName);
|
||||
const newName = 'RenameToNewPageName';
|
||||
await tester.renamePage(newName);
|
||||
final finder = find.descendant(
|
||||
of: find.byType(MentionPageBlock),
|
||||
matching: find.findTextInFlowyText(newName),
|
||||
);
|
||||
expect(finder, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('insert a inline page and delete it', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
final pageName = await insertingInlinePage(tester, ViewLayoutPB.Grid);
|
||||
|
||||
// rename
|
||||
await tester.hoverOnPageName(pageName);
|
||||
await tester.tapDeletePageButton();
|
||||
final finder = find.descendant(
|
||||
of: find.byType(MentionPageBlock),
|
||||
matching: find.findTextInFlowyText(pageName),
|
||||
);
|
||||
expect(finder, findsOneWidget);
|
||||
await tester.tapButton(finder);
|
||||
expect(find.byType(FlowyErrorPage), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert a referenced database of [layout] into the document
|
||||
Future<String> insertingInlinePage(
|
||||
WidgetTester tester,
|
||||
ViewLayoutPB layout,
|
||||
) async {
|
||||
// create a new grid
|
||||
final id = uuid();
|
||||
final name = '${layout.name}_$id';
|
||||
await tester.createNewPageWithName(
|
||||
layout,
|
||||
name,
|
||||
);
|
||||
// create a new document
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
'insert_a_inline_page_${layout.name}',
|
||||
);
|
||||
// tap the first line of the document
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
// insert a inline page
|
||||
await tester.editor.showAtMenu();
|
||||
await tester.editor.tapAtMenuItemWithName(name);
|
||||
return name;
|
||||
}
|
||||
@@ -118,7 +118,7 @@ const _sample = r'''
|
||||
---
|
||||
[] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~
|
||||
|
||||
[] Type / followed by /bullet or /num to create a list.
|
||||
[] Type followed by bullet or num to create a list.
|
||||
|
||||
[x] Click `+ New Page` button at the bottom of your sidebar to add a new page.
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../util/ime.dart';
|
||||
import '../util/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('outline block test', () {
|
||||
const location = 'outline_test';
|
||||
|
||||
setUp(() async {
|
||||
await TestFolder.cleanTestLocation(location);
|
||||
await TestFolder.setTestLocation(location);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await TestFolder.cleanTestLocation(null);
|
||||
});
|
||||
|
||||
testWidgets('insert an outline block', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
'outline_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await insertOutlineInDocument(tester);
|
||||
|
||||
// validate the outline is inserted
|
||||
expect(find.byType(OutlineBlockWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('insert an outline block and check if headings are visible',
|
||||
(tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.createNewPageWithName(
|
||||
ViewLayoutPB.Document,
|
||||
'outline_test',
|
||||
);
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
|
||||
await tester.ime.insertText('# Heading 1\n');
|
||||
await tester.ime.insertText('## Heading 2\n');
|
||||
await tester.ime.insertText('### Heading 3\n');
|
||||
|
||||
/* Results in:
|
||||
* # Heading 1
|
||||
* ## Heading 2
|
||||
* ### Heading 3
|
||||
*/
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(3);
|
||||
await insertOutlineInDocument(tester);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text('Heading 1'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Heading 2 is prefixed with a bullet
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text('Heading 2'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Heading 3 is prefixed with a dash
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text('Heading 3'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// update the Heading 1 to Heading 1Hello world
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText('Hello world');
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(OutlineBlockWidget),
|
||||
matching: find.text('Heading 1Hello world'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Inserts an outline block in the document
|
||||
Future<void> insertOutlineInDocument(WidgetTester tester) async {
|
||||
// open the actions menu and insert the outline block
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_selectionMenu_outline.tr(),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
|
||||
import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart';
|
||||
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_day.dart';
|
||||
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
|
||||
import 'package:appflowy/plugins/database_view/calendar/presentation/toolbar/calendar_layout_setting.dart';
|
||||
@@ -40,6 +41,7 @@ import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:calendar_view/calendar_view.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text_input.dart';
|
||||
@@ -294,7 +296,17 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
matching: findDay,
|
||||
);
|
||||
|
||||
await tapButton(finder);
|
||||
// if the day is very near the beginning or the end of the month,
|
||||
// it may overlap with the same day in the next or previous month,
|
||||
// respectively because it was spilling over. This will lead to 2
|
||||
// widgets being found and thus cannot be tapped correctly.
|
||||
if (content < 15) {
|
||||
// e.g., Jan 2 instead of Feb 2
|
||||
await tapButton(finder.first);
|
||||
} else {
|
||||
// e.g. Jun 28 instead of May 28
|
||||
await tapButton(finder.last);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleIncludeTime() async {
|
||||
@@ -461,17 +473,6 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
await tapButton(find.byType(EmojiSelectionMenu));
|
||||
}
|
||||
|
||||
/// Must call [openEmojiPicker] first
|
||||
Future<void> switchToEmojiList() async {
|
||||
final icon = find.byIcon(Icons.tag_faces);
|
||||
await tapButton(icon);
|
||||
}
|
||||
|
||||
Future<void> tapEmoji(String emoji) async {
|
||||
final emojiWidget = find.text(emoji);
|
||||
await tapButton(emojiWidget);
|
||||
}
|
||||
|
||||
Future<void> tapDateCellInRowDetailPage() async {
|
||||
final findDateCell = find.byType(GridDateCell);
|
||||
await tapButton(findDateCell);
|
||||
@@ -978,9 +979,20 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
Future<void> scrollToToday() async {
|
||||
final todayCell = find.byWidgetPredicate(
|
||||
(widget) => widget is CalendarDayCard && widget.isToday,
|
||||
skipOffstage: false,
|
||||
);
|
||||
await ensureVisible(todayCell);
|
||||
final scrollable = find
|
||||
.descendant(
|
||||
of: find.byType(MonthView<CalendarDayEvent>),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is Scrollable && widget.axis == Axis.vertical,
|
||||
),
|
||||
)
|
||||
.first;
|
||||
await scrollUntilVisible(
|
||||
todayCell,
|
||||
300,
|
||||
scrollable: scrollable,
|
||||
);
|
||||
await pumpAndSettle(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'ime.dart';
|
||||
@@ -21,19 +32,109 @@ class EditorOperations {
|
||||
|
||||
/// Tap the line of editor at [index]
|
||||
Future<void> tapLineOfEditorAt(int index) async {
|
||||
final textBlocks = find.byType(TextBlockComponentWidget);
|
||||
final textBlocks = find.byType(FlowyRichText);
|
||||
index = index.clamp(0, textBlocks.evaluate().length - 1);
|
||||
await tester.tapAt(tester.getTopRight(textBlocks.at(index)));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Hover on cover plugin button above the document
|
||||
Future<void> hoverOnCoverPluginAddButton() async {
|
||||
final editor = find.byWidgetPredicate(
|
||||
(widget) => widget is AppFlowyEditor,
|
||||
Future<void> hoverOnCoverToolbar() async {
|
||||
final coverToolbar = find.byType(DocumentHeaderToolbar);
|
||||
await tester.startGesture(
|
||||
tester.getBottomLeft(coverToolbar).translate(5, -5),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.hoverOnWidget(
|
||||
editor,
|
||||
offset: tester.getTopLeft(editor).translate(20, 20),
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Taps on the 'Add Icon' button in the cover toolbar
|
||||
Future<void> tapAddIconButton() async {
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_plugins_cover_addIcon.tr(),
|
||||
);
|
||||
expect(find.byType(EmojiPopover), findsOneWidget);
|
||||
}
|
||||
|
||||
/// Taps the 'Remove Icon' button in the cover toolbar and the icon popover
|
||||
Future<void> tapRemoveIconButton({bool isInPicker = false}) async {
|
||||
Finder button =
|
||||
find.text(LocaleKeys.document_plugins_cover_removeIcon.tr());
|
||||
if (isInPicker) {
|
||||
button = find.descendant(of: find.byType(EmojiPopover), matching: button);
|
||||
}
|
||||
|
||||
await tester.tapButton(button);
|
||||
}
|
||||
|
||||
/// Requires that the document must already have an icon. This opens the icon
|
||||
/// picker
|
||||
Future<void> tapOnIconWidget() async {
|
||||
final iconWidget = find.byType(EmojiIconWidget);
|
||||
await tester.tapButton(iconWidget);
|
||||
}
|
||||
|
||||
Future<void> tapOnAddCover() async {
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_plugins_cover_addCover.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> tapOnChangeCover() async {
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_plugins_cover_changeCover.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> switchSolidColorBackground() async {
|
||||
final findPurpleButton = find.byWidgetPredicate(
|
||||
(widget) => widget is ColorItem && widget.option.colorHex == "ffe8e0ff",
|
||||
);
|
||||
await tester.tapButton(findPurpleButton);
|
||||
}
|
||||
|
||||
Future<void> addNetworkImageCover(String imageUrl) async {
|
||||
final findNewImageButton = find.byType(NewCustomCoverButton);
|
||||
await tester.tapButton(findNewImageButton);
|
||||
|
||||
final imageUrlTextField = find.descendant(
|
||||
of: find.byType(NetworkImageUrlInput),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
await tester.enterText(imageUrlTextField, imageUrl);
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_plugins_cover_add.tr(),
|
||||
);
|
||||
await tester.tapButtonWithName(
|
||||
LocaleKeys.document_plugins_cover_saveToGallery.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> switchNetworkImageCover(String imageUrl) async {
|
||||
final image = find.byWidgetPredicate(
|
||||
(widget) => widget is ImageGridItem,
|
||||
);
|
||||
await tester.tapButton(image);
|
||||
}
|
||||
|
||||
Future<void> tapOnRemoveCover() async {
|
||||
await tester.tapButton(find.byType(DeleteCoverButton));
|
||||
}
|
||||
|
||||
/// A cover must be present in the document to function properly since this
|
||||
/// catches all cover types collectively
|
||||
Future<void> hoverOnCover() async {
|
||||
final cover = find.byType(DocumentCover);
|
||||
await tester.startGesture(
|
||||
tester.getCenter(cover),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> dismissCoverPicker() async {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// trigger the slash command (selection menu)
|
||||
@@ -41,6 +142,11 @@ class EditorOperations {
|
||||
await tester.ime.insertCharacter('/');
|
||||
}
|
||||
|
||||
/// trigger the slash command (selection menu)
|
||||
Future<void> showAtMenu() async {
|
||||
await tester.ime.insertCharacter('@');
|
||||
}
|
||||
|
||||
/// Tap the slash menu item with [name]
|
||||
///
|
||||
/// Must call [showSlashMenu] first.
|
||||
@@ -48,4 +154,15 @@ class EditorOperations {
|
||||
final slashMenuItem = find.text(name, findRichText: true);
|
||||
await tester.tapButton(slashMenuItem);
|
||||
}
|
||||
|
||||
/// Tap the at menu item with [name]
|
||||
///
|
||||
/// Must call [showAtMenu] first.
|
||||
Future<void> tapAtMenuItemWithName(String name) async {
|
||||
final atMenuItem = find.descendant(
|
||||
of: find.byType(SelectionMenuWidget),
|
||||
matching: find.text(name, findRichText: true),
|
||||
);
|
||||
await tester.tapButton(atMenuItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'base.dart';
|
||||
|
||||
extension EmojiTestExtension on WidgetTester {
|
||||
/// Must call [openEmojiPicker] first
|
||||
Future<void> switchToEmojiList() async {
|
||||
final icon = find.byIcon(Icons.tag_faces);
|
||||
await tapButton(icon);
|
||||
}
|
||||
|
||||
Future<void> tapEmoji(String emoji) async {
|
||||
final emojiWidget = find.text(emoji);
|
||||
await tapButton(emojiWidget);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -47,7 +49,7 @@ extension Expectation on WidgetTester {
|
||||
expect(exportSuccess, findsOneWidget);
|
||||
}
|
||||
|
||||
/// Expect to see the add button and icon button inside the document.
|
||||
/// Expect to see the add button and icon button in the cover toolbar
|
||||
void expectToSeePluginAddCoverAndIconButton() {
|
||||
final addCover = find.textContaining(
|
||||
LocaleKeys.document_plugins_cover_addCover.tr(),
|
||||
@@ -59,6 +61,51 @@ extension Expectation on WidgetTester {
|
||||
expect(addIcon, findsOneWidget);
|
||||
}
|
||||
|
||||
/// Expect to see the document header toolbar empty
|
||||
void expectToSeeEmptyDocumentHeaderToolbar() {
|
||||
final addCover = find.textContaining(
|
||||
LocaleKeys.document_plugins_cover_addCover.tr(),
|
||||
);
|
||||
final addIcon = find.textContaining(
|
||||
LocaleKeys.document_plugins_cover_addIcon.tr(),
|
||||
);
|
||||
expect(addCover, findsNothing);
|
||||
expect(addIcon, findsNothing);
|
||||
}
|
||||
|
||||
void expectToSeeDocumentIcon(String? emoji) {
|
||||
if (emoji == null) {
|
||||
final iconWidget = find.byType(EmojiIconWidget);
|
||||
expect(iconWidget, findsNothing);
|
||||
return;
|
||||
}
|
||||
final iconWidget = find.byWidgetPredicate(
|
||||
(widget) => widget is EmojiIconWidget && widget.emoji == emoji,
|
||||
);
|
||||
expect(iconWidget, findsOneWidget);
|
||||
}
|
||||
|
||||
void expectToSeeDocumentCover(CoverType type) {
|
||||
final findCover = find.byWidgetPredicate(
|
||||
(widget) => widget is DocumentCover && widget.coverType == type,
|
||||
);
|
||||
expect(findCover, findsOneWidget);
|
||||
}
|
||||
|
||||
void expectToSeeNoDocumentCover() {
|
||||
final findCover = find.byType(DocumentCover);
|
||||
expect(findCover, findsNothing);
|
||||
}
|
||||
|
||||
void expectChangeCoverAndDeleteButton() {
|
||||
final findChangeCover = find.text(
|
||||
LocaleKeys.document_plugins_cover_changeCover.tr(),
|
||||
);
|
||||
final findRemoveIcon = find.byType(DeleteCoverButton);
|
||||
expect(findChangeCover, findsOneWidget);
|
||||
expect(findRemoveIcon, findsOneWidget);
|
||||
}
|
||||
|
||||
/// Expect to see the user name on the home page
|
||||
void expectToSeeUserName(String name) {
|
||||
final userName = find.byWidgetPredicate(
|
||||
|
||||
@@ -9,11 +9,11 @@ extension IME on WidgetTester {
|
||||
|
||||
class IMESimulator {
|
||||
IMESimulator(this.tester) {
|
||||
client = findDeltaTextInputClient();
|
||||
client = findTextInputClient();
|
||||
}
|
||||
|
||||
final WidgetTester tester;
|
||||
late final DeltaTextInputClient client;
|
||||
late final TextInputClient client;
|
||||
|
||||
Future<void> insertText(String text) async {
|
||||
for (final c in text.characters) {
|
||||
@@ -27,28 +27,22 @@ class IMESimulator {
|
||||
assert(false);
|
||||
return;
|
||||
}
|
||||
final deltas = [
|
||||
TextEditingDeltaInsertion(
|
||||
textInserted: character,
|
||||
oldText: value.text.replaceRange(
|
||||
value.selection.start,
|
||||
value.selection.end,
|
||||
'',
|
||||
),
|
||||
insertionOffset: value.selection.baseOffset,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.selection.baseOffset + 1,
|
||||
),
|
||||
composing: TextRange.empty,
|
||||
final text = value.text
|
||||
.replaceRange(value.selection.start, value.selection.end, character);
|
||||
final textEditingValue = TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: value.selection.baseOffset + 1,
|
||||
),
|
||||
];
|
||||
client.updateEditingValueWithDeltas(deltas);
|
||||
composing: TextRange.empty,
|
||||
);
|
||||
client.updateEditingValue(textEditingValue);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
DeltaTextInputClient findDeltaTextInputClient() {
|
||||
TextInputClient findTextInputClient() {
|
||||
final finder = find.byType(KeyboardServiceWidget);
|
||||
final KeyboardServiceWidgetState state = tester.state(finder);
|
||||
return state.textInputService as DeltaTextInputClient;
|
||||
return state.textInputService as TextInputClient;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import 'package:appflowy/plugins/document/application/doc_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
show EditorState, LogLevel, TransactionTime;
|
||||
show EditorState, LogLevel, TransactionTime, Selection, paragraphNode;
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -155,6 +155,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
return;
|
||||
}
|
||||
await _transactionAdapter.apply(event.$2, editorState);
|
||||
|
||||
// check if the document is empty.
|
||||
applyRules();
|
||||
});
|
||||
|
||||
// output the log from the editor when debug mode
|
||||
@@ -166,6 +169,39 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> applyRules() async {
|
||||
ensureAtLeastOneParagraphExists();
|
||||
ensureLastNodeIsEditable();
|
||||
}
|
||||
|
||||
Future<void> ensureLastNodeIsEditable() async {
|
||||
final editorState = this.editorState;
|
||||
if (editorState == null) {
|
||||
return;
|
||||
}
|
||||
final document = editorState.document;
|
||||
final lastNode = document.root.children.lastOrNull;
|
||||
if (lastNode == null || lastNode.delta == null) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNode([document.root.children.length], paragraphNode());
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureAtLeastOneParagraphExists() async {
|
||||
final editorState = this.editorState;
|
||||
if (editorState == null) {
|
||||
return;
|
||||
}
|
||||
final document = editorState.document;
|
||||
if (document.root.children.isEmpty) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNode([0], paragraphNode());
|
||||
transaction.afterSelection = Selection.collapse([0], 0);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -123,7 +123,7 @@ class _DocumentPageState extends State<DocumentPage> {
|
||||
return const Placeholder();
|
||||
}
|
||||
final page = editorState!.document.root;
|
||||
return CoverImageNodeWidget(
|
||||
return DocumentHeaderNodeWidget(
|
||||
node: page,
|
||||
editorState: editorState!,
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_list.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -35,6 +34,8 @@ class AppFlowyEditorPage extends StatefulWidget {
|
||||
class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
late final ScrollController effectiveScrollController;
|
||||
|
||||
final inlinePageReferenceService = InlinePageReferenceService();
|
||||
|
||||
final List<CommandShortcutEvent> commandShortcutEvents = [
|
||||
...codeBlockCommands,
|
||||
...standardCommandShortcutEvents,
|
||||
@@ -53,23 +54,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
highlightColorItem,
|
||||
];
|
||||
|
||||
late final slashMenuItems = [
|
||||
inlineGridMenuItem(documentBloc),
|
||||
referencedGridMenuItem,
|
||||
inlineBoardMenuItem(documentBloc),
|
||||
referencedBoardMenuItem,
|
||||
inlineCalendarMenuItem(documentBloc),
|
||||
referencedCalendarMenuItem,
|
||||
calloutItem,
|
||||
mathEquationItem,
|
||||
codeBlockItem,
|
||||
emojiMenuItem,
|
||||
autoGeneratorMenuItem,
|
||||
];
|
||||
late final List<SelectionMenuItem> slashMenuItems;
|
||||
|
||||
late final Map<String, BlockComponentBuilder> blockComponentBuilders =
|
||||
_customAppFlowyBlockComponentBuilders();
|
||||
|
||||
List<CharacterShortcutEvent> get characterShortcutEvents => [
|
||||
// inline page reference list
|
||||
...inlinePageReferenceShortcuts,
|
||||
|
||||
// code block
|
||||
...codeBlockCharacterEvents,
|
||||
|
||||
@@ -88,6 +81,18 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
), // remove the default slash command.
|
||||
];
|
||||
|
||||
late final inlinePageReferenceShortcuts = [
|
||||
inlinePageReferenceService.customPageLinkMenu(
|
||||
character: '@',
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
),
|
||||
// uncomment this to enable the inline page reference list
|
||||
// inlinePageReferenceService.customPageLinkMenu(
|
||||
// character: '+',
|
||||
// style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
// ),
|
||||
];
|
||||
|
||||
late final showSlashMenu = customSlashCommand(
|
||||
slashMenuItems,
|
||||
shouldInsertSlash: false,
|
||||
@@ -100,6 +105,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
slashMenuItems = _customSlashMenuItems();
|
||||
|
||||
effectiveScrollController = widget.scrollController ?? ScrollController();
|
||||
}
|
||||
|
||||
@@ -200,6 +208,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
),
|
||||
ImageBlockKeys.type: ImageBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
showMenu: true,
|
||||
menuBuilder: (node, state) => Positioned(
|
||||
top: 0,
|
||||
right: 10,
|
||||
child: ImageMenu(
|
||||
node: node,
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
),
|
||||
DatabaseBlockKeys.gridType: DatabaseViewBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
@@ -235,7 +252,15 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
),
|
||||
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
|
||||
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
|
||||
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(),
|
||||
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
),
|
||||
OutlineBlockKeys.type: OutlineBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderTextStyle: (_) =>
|
||||
styleCustomizer.outlineBlockPlaceholderStyleBuilder(),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
final builders = {
|
||||
@@ -258,7 +283,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
NumberedListBlockKeys.type,
|
||||
QuoteBlockKeys.type,
|
||||
TodoListBlockKeys.type,
|
||||
CalloutBlockKeys.type
|
||||
CalloutBlockKeys.type,
|
||||
OutlineBlockKeys.type,
|
||||
];
|
||||
|
||||
final supportAlignBuilderType = [
|
||||
@@ -304,6 +330,34 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
||||
return builders;
|
||||
}
|
||||
|
||||
List<SelectionMenuItem> _customSlashMenuItems() {
|
||||
final items = [...standardSelectionMenuItems];
|
||||
final imageItem = items.firstWhereOrNull(
|
||||
(element) => element.name == AppFlowyEditorLocalizations.current.image,
|
||||
);
|
||||
if (imageItem != null) {
|
||||
final imageItemIndex = items.indexOf(imageItem);
|
||||
if (imageItemIndex != -1) {
|
||||
items[imageItemIndex] = customImageMenuItem;
|
||||
}
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
inlineGridMenuItem(documentBloc),
|
||||
referencedGridMenuItem,
|
||||
inlineBoardMenuItem(documentBloc),
|
||||
referencedBoardMenuItem,
|
||||
inlineCalendarMenuItem(documentBloc),
|
||||
referencedCalendarMenuItem,
|
||||
calloutItem,
|
||||
outlineItem,
|
||||
mathEquationItem,
|
||||
codeBlockItem,
|
||||
emojiMenuItem,
|
||||
autoGeneratorMenuItem,
|
||||
];
|
||||
}
|
||||
|
||||
(bool, Selection?) _computeAutoFocusParameters() {
|
||||
if (widget.editorState.document.isEmpty) {
|
||||
return (true, Selection.collapse([0], 0));
|
||||
|
||||
+2
-1
@@ -87,7 +87,8 @@ class _LinkToPageMenuState extends State<LinkToPageMenu> {
|
||||
final Map<int, (ViewPB, ViewPB)> _items = {};
|
||||
|
||||
Future<List<(ViewPB, List<ViewPB>)>> fetchItems() async {
|
||||
final items = await ViewBackendService().fetchViews(widget.layoutType);
|
||||
final items =
|
||||
await ViewBackendService().fetchViewsWithLayoutType(widget.layoutType);
|
||||
|
||||
int index = 0;
|
||||
for (final (app, children) in items) {
|
||||
|
||||
-555
@@ -1,555 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_popover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CoverBlockKeys {
|
||||
const CoverBlockKeys._();
|
||||
|
||||
static const String selectionType = 'cover_selection_type';
|
||||
static const String selection = 'cover_selection';
|
||||
static const String iconSelection = 'selected_icon';
|
||||
}
|
||||
|
||||
enum CoverSelectionType {
|
||||
initial,
|
||||
color,
|
||||
file,
|
||||
asset;
|
||||
|
||||
static CoverSelectionType fromString(String? value) {
|
||||
if (value == null) {
|
||||
return CoverSelectionType.initial;
|
||||
}
|
||||
return CoverSelectionType.values.firstWhere(
|
||||
(e) => e.toString() == value,
|
||||
orElse: () => CoverSelectionType.initial,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CoverNodeWidgetBuilder implements NodeWidgetBuilder {
|
||||
@override
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return CoverImageNodeWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => (node) {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
class CoverImageNodeWidget extends StatefulWidget {
|
||||
const CoverImageNodeWidget({
|
||||
Key? key,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
}) : super(key: key);
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<CoverImageNodeWidget> createState() => _CoverImageNodeWidgetState();
|
||||
}
|
||||
|
||||
class _CoverImageNodeWidgetState extends State<CoverImageNodeWidget> {
|
||||
CoverSelectionType get selectionType => CoverSelectionType.fromString(
|
||||
widget.node.attributes[CoverBlockKeys.selectionType],
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.node.addListener(_reload);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.node.removeListener(_reload);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
PopoverController iconPopoverController = PopoverController();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CoverImage(
|
||||
editorState: widget.editorState,
|
||||
node: widget.node,
|
||||
onCoverChanged: (type, value) {
|
||||
_insertCover(type, value);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _insertCover(CoverSelectionType type, dynamic cover) async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
CoverBlockKeys.selectionType: type.toString(),
|
||||
CoverBlockKeys.selection: cover,
|
||||
CoverBlockKeys.iconSelection:
|
||||
widget.node.attributes[CoverBlockKeys.iconSelection]
|
||||
});
|
||||
return widget.editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddCoverButton extends StatefulWidget {
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
final bool hasIcon;
|
||||
final CoverSelectionType selectionType;
|
||||
|
||||
final PopoverController iconPopoverController;
|
||||
const _AddCoverButton({
|
||||
required this.onTap,
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
required this.hasIcon,
|
||||
required this.selectionType,
|
||||
required this.iconPopoverController,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_AddCoverButton> createState() => _AddCoverButtonState();
|
||||
}
|
||||
|
||||
bool isPopoverOpen = false;
|
||||
|
||||
class _AddCoverButtonState extends State<_AddCoverButton> {
|
||||
bool isHidden = true;
|
||||
PopoverMutex mutex = PopoverMutex();
|
||||
bool isPopoverOpen = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (event) {
|
||||
setHidden(false);
|
||||
},
|
||||
onExit: (event) {
|
||||
setHidden(isPopoverOpen ? false : true);
|
||||
},
|
||||
opaque: false,
|
||||
child: Container(
|
||||
height: widget.hasIcon ? 180 : 50.0,
|
||||
alignment: Alignment.bottomLeft,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 80,
|
||||
top: 20,
|
||||
bottom: 5,
|
||||
),
|
||||
child: isHidden
|
||||
? Container()
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Add Cover Button.
|
||||
widget.selectionType != CoverSelectionType.initial
|
||||
? Container()
|
||||
: FlowyButton(
|
||||
key: UniqueKey(),
|
||||
leftIconSize: const Size.square(18),
|
||||
onTap: widget.onTap,
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: const FlowySvg(name: 'editor/image'),
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_cover_addCover.tr(),
|
||||
),
|
||||
),
|
||||
// Add Icon Button.
|
||||
widget.hasIcon
|
||||
? FlowyButton(
|
||||
leftIconSize: const Size.square(18),
|
||||
onTap: () {
|
||||
_removeIcon();
|
||||
},
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: const Icon(
|
||||
Icons.emoji_emotions_outlined,
|
||||
size: 18,
|
||||
),
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_cover_removeIcon.tr(),
|
||||
),
|
||||
)
|
||||
: AppFlowyPopover(
|
||||
mutex: mutex,
|
||||
asBarrier: true,
|
||||
onClose: () {
|
||||
isPopoverOpen = false;
|
||||
setHidden(true);
|
||||
},
|
||||
offset: const Offset(120, 10),
|
||||
controller: widget.iconPopoverController,
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints:
|
||||
BoxConstraints.loose(const Size(320, 380)),
|
||||
margin: EdgeInsets.zero,
|
||||
child: FlowyButton(
|
||||
leftIconSize: const Size.square(18),
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: const Icon(
|
||||
Icons.emoji_emotions_outlined,
|
||||
size: 18,
|
||||
),
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_cover_addIcon.tr(),
|
||||
),
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
isPopoverOpen = true;
|
||||
return EmojiPopover(
|
||||
showRemoveButton: widget.hasIcon,
|
||||
removeIcon: _removeIcon,
|
||||
node: widget.node,
|
||||
editorState: widget.editorState,
|
||||
onEmojiChanged: (Emoji emoji) {
|
||||
_insertIcon(emoji);
|
||||
widget.iconPopoverController.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _insertIcon(Emoji emoji) async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
CoverBlockKeys.selectionType:
|
||||
widget.node.attributes[CoverBlockKeys.selectionType],
|
||||
CoverBlockKeys.selection:
|
||||
widget.node.attributes[CoverBlockKeys.selection],
|
||||
CoverBlockKeys.iconSelection: emoji.emoji,
|
||||
});
|
||||
return widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _removeIcon() async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
CoverBlockKeys.iconSelection: "",
|
||||
CoverBlockKeys.selectionType:
|
||||
widget.node.attributes[CoverBlockKeys.selectionType],
|
||||
CoverBlockKeys.selection:
|
||||
widget.node.attributes[CoverBlockKeys.selection],
|
||||
});
|
||||
return widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
void setHidden(bool value) {
|
||||
if (isHidden == value) return;
|
||||
setState(() {
|
||||
isHidden = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _CoverImage extends StatefulWidget {
|
||||
const _CoverImage({
|
||||
required this.editorState,
|
||||
required this.node,
|
||||
required this.onCoverChanged,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
final Function(
|
||||
CoverSelectionType selectionType,
|
||||
dynamic selection,
|
||||
) onCoverChanged;
|
||||
@override
|
||||
State<_CoverImage> createState() => _CoverImageState();
|
||||
}
|
||||
|
||||
class _CoverImageState extends State<_CoverImage> {
|
||||
final popoverController = PopoverController();
|
||||
|
||||
CoverSelectionType get selectionType => CoverSelectionType.fromString(
|
||||
widget.node.attributes[CoverBlockKeys.selectionType],
|
||||
);
|
||||
Color get color {
|
||||
final hex = widget.node.attributes[CoverBlockKeys.selection] as String?;
|
||||
return hex?.toColor() ?? Colors.white;
|
||||
}
|
||||
|
||||
bool get hasIcon =>
|
||||
widget.node.attributes[CoverBlockKeys.iconSelection] == null
|
||||
? false
|
||||
: widget.node.attributes[CoverBlockKeys.iconSelection].isNotEmpty;
|
||||
bool isOverlayButtonsHidden = true;
|
||||
PopoverController iconPopoverController = PopoverController();
|
||||
bool get hasCover =>
|
||||
selectionType == CoverSelectionType.initial ? false : true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.bottomLeft,
|
||||
children: [
|
||||
Container(
|
||||
alignment: Alignment.topCenter,
|
||||
height: !hasCover
|
||||
? 0
|
||||
: hasIcon
|
||||
? 320
|
||||
: 280,
|
||||
child: _buildCoverImage(context, widget.editorState),
|
||||
),
|
||||
hasIcon
|
||||
? Positioned(
|
||||
left: 80,
|
||||
bottom: !hasCover ? 30 : 40,
|
||||
child: AppFlowyPopover(
|
||||
offset: const Offset(100, 0),
|
||||
controller: iconPopoverController,
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: BoxConstraints.loose(const Size(320, 380)),
|
||||
margin: EdgeInsets.zero,
|
||||
child: EmojiIconWidget(
|
||||
emoji: widget.node.attributes[CoverBlockKeys.iconSelection],
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
return EmojiPopover(
|
||||
node: widget.node,
|
||||
showRemoveButton: hasIcon,
|
||||
removeIcon: _removeIcon,
|
||||
editorState: widget.editorState,
|
||||
onEmojiChanged: (Emoji emoji) {
|
||||
_insertIcon(emoji);
|
||||
iconPopoverController.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
hasIcon && selectionType != CoverSelectionType.initial
|
||||
? Container()
|
||||
: _AddCoverButton(
|
||||
onTap: () {
|
||||
_insertCover(
|
||||
CoverSelectionType.asset,
|
||||
builtInAssetImages.first,
|
||||
);
|
||||
},
|
||||
node: widget.node,
|
||||
editorState: widget.editorState,
|
||||
hasIcon: hasIcon,
|
||||
selectionType: selectionType,
|
||||
iconPopoverController: iconPopoverController,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _insertCover(CoverSelectionType type, dynamic cover) async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
CoverBlockKeys.selectionType: type.toString(),
|
||||
CoverBlockKeys.selection: cover,
|
||||
CoverBlockKeys.iconSelection:
|
||||
widget.node.attributes[CoverBlockKeys.iconSelection]
|
||||
});
|
||||
return widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _insertIcon(Emoji emoji) async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
CoverBlockKeys.selectionType:
|
||||
widget.node.attributes[CoverBlockKeys.selectionType],
|
||||
CoverBlockKeys.selection:
|
||||
widget.node.attributes[CoverBlockKeys.selection],
|
||||
CoverBlockKeys.iconSelection: emoji.emoji,
|
||||
});
|
||||
return widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> _removeIcon() async {
|
||||
final transaction = widget.editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
CoverBlockKeys.iconSelection: "",
|
||||
CoverBlockKeys.selectionType:
|
||||
widget.node.attributes[CoverBlockKeys.selectionType],
|
||||
CoverBlockKeys.selection:
|
||||
widget.node.attributes[CoverBlockKeys.selection],
|
||||
});
|
||||
return widget.editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Widget _buildCoverOverlayButtons(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: 20,
|
||||
right: 50,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppFlowyPopover(
|
||||
onClose: () {
|
||||
setOverlayButtonsHidden(true);
|
||||
},
|
||||
offset: const Offset(-125, 10),
|
||||
controller: popoverController,
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: BoxConstraints.loose(const Size(380, 450)),
|
||||
margin: EdgeInsets.zero,
|
||||
child: Visibility(
|
||||
maintainState: true,
|
||||
maintainAnimation: true,
|
||||
maintainSize: true,
|
||||
visible: !isOverlayButtonsHidden,
|
||||
child: RoundedTextButton(
|
||||
onPressed: () {
|
||||
popoverController.show();
|
||||
setOverlayButtonsHidden(true);
|
||||
},
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
textColor: Theme.of(context).colorScheme.tertiary,
|
||||
fillColor:
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
width: 120,
|
||||
height: 28,
|
||||
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
|
||||
),
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
return ChangeCoverPopover(
|
||||
node: widget.node,
|
||||
editorState: widget.editorState,
|
||||
onCoverChanged: widget.onCoverChanged,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Visibility(
|
||||
maintainAnimation: true,
|
||||
maintainSize: true,
|
||||
maintainState: true,
|
||||
visible: !isOverlayButtonsHidden,
|
||||
child: FlowyIconButton(
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
iconPadding: const EdgeInsets.all(5),
|
||||
width: 28,
|
||||
icon: svgWidget(
|
||||
'editor/delete',
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
onPressed: () {
|
||||
widget.onCoverChanged(CoverSelectionType.initial, null);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverImage(BuildContext context, EditorState editorState) {
|
||||
const height = 250.0;
|
||||
final Widget coverImage;
|
||||
switch (selectionType) {
|
||||
case CoverSelectionType.file:
|
||||
final imageFile =
|
||||
File(widget.node.attributes[CoverBlockKeys.selection]);
|
||||
if (!imageFile.existsSync()) {
|
||||
// reset cover state
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onCoverChanged(CoverSelectionType.initial, null);
|
||||
});
|
||||
coverImage = const SizedBox();
|
||||
break;
|
||||
}
|
||||
coverImage = Image.file(
|
||||
imageFile,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
break;
|
||||
case CoverSelectionType.asset:
|
||||
coverImage = Image.asset(
|
||||
widget.node.attributes[CoverBlockKeys.selection],
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
break;
|
||||
case CoverSelectionType.color:
|
||||
coverImage = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
);
|
||||
break;
|
||||
case CoverSelectionType.initial:
|
||||
coverImage = const SizedBox();
|
||||
break;
|
||||
}
|
||||
// OverflowBox needs to be wraped by a widget with constraints(or from its parent) first,otherwise it will occur an error
|
||||
return MouseRegion(
|
||||
onEnter: (event) {
|
||||
setOverlayButtonsHidden(false);
|
||||
},
|
||||
onExit: (event) {
|
||||
setOverlayButtonsHidden(true);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
height: double.infinity,
|
||||
width: double.infinity,
|
||||
child: coverImage,
|
||||
),
|
||||
hasCover
|
||||
? _buildCoverOverlayButtons(context)
|
||||
: const SizedBox.shrink()
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setOverlayButtonsHidden(bool value) {
|
||||
if (isOverlayButtonsHidden == value) return;
|
||||
setState(() {
|
||||
isOverlayButtonsHidden = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+90
-64
@@ -11,6 +11,7 @@ import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
@@ -25,7 +26,7 @@ class ChangeCoverPopover extends StatefulWidget {
|
||||
final EditorState editorState;
|
||||
final Node node;
|
||||
final Function(
|
||||
CoverSelectionType selectionType,
|
||||
CoverType selectionType,
|
||||
String selection,
|
||||
) onCoverChanged;
|
||||
|
||||
@@ -83,7 +84,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
child: BlocBuilder<ChangeCoverPopoverBloc, ChangeCoverPopoverState>(
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(15),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SingleChildScrollView(
|
||||
child: isAddingImage
|
||||
? CoverImagePicker(
|
||||
@@ -116,18 +117,18 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
LocaleKeys.document_plugins_cover_colors.tr(),
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const VSpace(10),
|
||||
_buildColorPickerList(),
|
||||
const SizedBox(height: 10),
|
||||
const VSpace(10),
|
||||
_buildImageHeader(),
|
||||
const SizedBox(height: 10),
|
||||
const VSpace(10),
|
||||
_buildFileImagePicker(),
|
||||
const SizedBox(height: 10),
|
||||
const VSpace(10),
|
||||
FlowyText.semibold(
|
||||
LocaleKeys.document_plugins_cover_abstract.tr(),
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const VSpace(10),
|
||||
_buildAbstractImagePicker(),
|
||||
],
|
||||
);
|
||||
@@ -149,10 +150,10 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
LocaleKeys.document_plugins_cover_clearAll.tr(),
|
||||
fontColor: Theme.of(context).colorScheme.tertiary,
|
||||
onPressed: () async {
|
||||
final hasFileImageCover = CoverSelectionType.fromString(
|
||||
widget.node.attributes[CoverBlockKeys.selectionType],
|
||||
final hasFileImageCover = CoverType.fromString(
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
|
||||
) ==
|
||||
CoverSelectionType.file;
|
||||
CoverType.file;
|
||||
final changeCoverBloc = context.read<ChangeCoverPopoverBloc>();
|
||||
if (hasFileImageCover) {
|
||||
await showDialog(
|
||||
@@ -196,7 +197,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
widget.onCoverChanged(
|
||||
CoverSelectionType.asset,
|
||||
CoverType.asset,
|
||||
builtInAssetImages[index],
|
||||
);
|
||||
},
|
||||
@@ -220,14 +221,14 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
pickerBackgroundColor: theme.cardColor,
|
||||
pickerItemHoverColor: theme.hoverColor,
|
||||
selectedBackgroundColorHex:
|
||||
widget.node.attributes[CoverBlockKeys.selectionType] ==
|
||||
CoverSelectionType.color.toString()
|
||||
? widget.node.attributes[CoverBlockKeys.selection]
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverType] ==
|
||||
CoverType.color.toString()
|
||||
? widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]
|
||||
: 'ffffff',
|
||||
backgroundColorOptions:
|
||||
_generateBackgroundColorOptions(widget.editorState),
|
||||
onSubmittedBackgroundColorHex: (color) {
|
||||
widget.onCoverChanged(CoverSelectionType.color, color);
|
||||
widget.onCoverChanged(CoverType.color, color);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
@@ -249,43 +250,25 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
itemCount: images.length + 1,
|
||||
itemBuilder: (BuildContext ctx, index) {
|
||||
if (index == 0) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
borderRadius: Corners.s8Border,
|
||||
),
|
||||
child: FlowyIconButton(
|
||||
iconPadding: EdgeInsets.zero,
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
hoverColor:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.15),
|
||||
width: 20,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
isAddingImage = true;
|
||||
});
|
||||
},
|
||||
return NewCustomCoverButton(
|
||||
onPressed: () => setState(
|
||||
() => isAddingImage = true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ImageGridItem(
|
||||
onImageSelect: () {
|
||||
widget.onCoverChanged(
|
||||
CoverSelectionType.file,
|
||||
CoverType.file,
|
||||
images[index - 1],
|
||||
);
|
||||
},
|
||||
onImageDelete: () async {
|
||||
final changeCoverBloc =
|
||||
context.read<ChangeCoverPopoverBloc>();
|
||||
final deletingCurrentCover =
|
||||
widget.node.attributes[CoverBlockKeys.selection] ==
|
||||
images[index - 1];
|
||||
final deletingCurrentCover = widget.node
|
||||
.attributes[DocumentHeaderBlockKeys.coverDetails] ==
|
||||
images[index - 1];
|
||||
if (deletingCurrentCover) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -328,6 +311,32 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class NewCustomCoverButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
const NewCustomCoverButton({super.key, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
borderRadius: Corners.s8Border,
|
||||
),
|
||||
child: FlowyIconButton(
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.15),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteImageAlertDialog extends StatelessWidget {
|
||||
const DeleteImageAlertDialog({
|
||||
Key? key,
|
||||
@@ -481,36 +490,63 @@ class _CoverColorPickerState extends State<CoverColorPicker> {
|
||||
scrollController.dispose();
|
||||
}
|
||||
|
||||
Widget _buildColorItem(ColorOption option, bool isChecked) {
|
||||
Widget _buildColorItems(List<ColorOption> options, String? selectedColor) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: options
|
||||
.map(
|
||||
(e) => ColorItem(
|
||||
option: e,
|
||||
isChecked: e.colorHex == selectedColor,
|
||||
hoverColor: widget.pickerItemHoverColor,
|
||||
onTap: widget.onSubmittedBackgroundColorHex,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class ColorItem extends StatelessWidget {
|
||||
final ColorOption option;
|
||||
final bool isChecked;
|
||||
final Color hoverColor;
|
||||
final void Function(String) onTap;
|
||||
const ColorItem({
|
||||
required this.option,
|
||||
required this.isChecked,
|
||||
required this.hoverColor,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
customBorder: const RoundedRectangleBorder(
|
||||
borderRadius: Corners.s6Border,
|
||||
),
|
||||
hoverColor: widget.pickerItemHoverColor,
|
||||
onTap: () {
|
||||
widget.onSubmittedBackgroundColorHex(option.colorHex);
|
||||
},
|
||||
hoverColor: hoverColor,
|
||||
onTap: () => onTap(option.colorHex),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: SizedBox.square(
|
||||
dimension: isChecked ? 24 : 25,
|
||||
dimension: 25,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: option.colorHex.toColor(),
|
||||
border: isChecked
|
||||
? Border.all(
|
||||
color: const Color(0xFFFFFFFF),
|
||||
width: 2.0,
|
||||
)
|
||||
: null,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: isChecked
|
||||
? SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
margin: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).cardColor,
|
||||
width: 3.0,
|
||||
),
|
||||
color: option.colorHex.toColor(),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
@@ -522,14 +558,4 @@ class _CoverColorPickerState extends State<CoverColorPicker> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorItems(List<ColorOption> options, String? selectedColor) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: options
|
||||
.map((e) => _buildColorItem(e, e.colorHex == selectedColor))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -1,14 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/change_cover_popover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/cover_node_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
part 'change_cover_popover_bloc.freezed.dart';
|
||||
part 'cover_editor_bloc.freezed.dart';
|
||||
|
||||
class ChangeCoverPopoverBloc
|
||||
extends Bloc<ChangeCoverPopoverEvent, ChangeCoverPopoverState> {
|
||||
@@ -32,7 +32,7 @@ class ChangeCoverPopoverBloc
|
||||
deleteImage: (DeleteImage deleteImage) async {
|
||||
final currentState = state;
|
||||
final currentlySelectedImage =
|
||||
node.attributes[CoverBlockKeys.selection];
|
||||
node.attributes[DocumentHeaderBlockKeys.coverDetails];
|
||||
if (currentState is Loaded) {
|
||||
await _deleteImageInStorage(deleteImage.path);
|
||||
if (currentlySelectedImage == deleteImage.path) {
|
||||
@@ -48,7 +48,7 @@ class ChangeCoverPopoverBloc
|
||||
clearAllImages: (ClearAllImages clearAllImages) async {
|
||||
final currentState = state;
|
||||
final currentlySelectedImage =
|
||||
node.attributes[CoverBlockKeys.selection];
|
||||
node.attributes[DocumentHeaderBlockKeys.coverDetails];
|
||||
|
||||
if (currentState is Loaded) {
|
||||
for (final image in currentState.imageNames) {
|
||||
@@ -90,9 +90,9 @@ class ChangeCoverPopoverBloc
|
||||
Future<void> _removeCoverImageFromNode() async {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(node, {
|
||||
CoverBlockKeys.selectionType: CoverSelectionType.initial.toString(),
|
||||
CoverBlockKeys.iconSelection:
|
||||
node.attributes[CoverBlockKeys.iconSelection]
|
||||
DocumentHeaderBlockKeys.coverType: CoverType.none.toString(),
|
||||
DocumentHeaderBlockKeys.icon:
|
||||
node.attributes[DocumentHeaderBlockKeys.icon]
|
||||
});
|
||||
return editorState.apply(transaction);
|
||||
}
|
||||
+6
-13
@@ -1,8 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/cover_image_picker_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:flowy_infra/image.dart';
|
||||
@@ -67,17 +68,13 @@ class _CoverImagePickerState extends State<CoverImagePicker> {
|
||||
),
|
||||
)
|
||||
: CoverImagePreviewWidget(state: state),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
const VSpace(10),
|
||||
NetworkImageUrlInput(
|
||||
onAdd: (url) {
|
||||
context.read<CoverImagePickerBloc>().add(UrlSubmit(url));
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
const VSpace(10),
|
||||
ImagePickerActionButtons(
|
||||
onBackPressed: () {
|
||||
widget.onBackPressed();
|
||||
@@ -233,16 +230,12 @@ class _CoverImagePreviewWidgetState extends State<CoverImagePreviewWidget> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
const VSpace(10),
|
||||
FlowyText(
|
||||
LocaleKeys.document_plugins_cover_or.tr(),
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
const VSpace(10),
|
||||
FlowyButton(
|
||||
hoverColor: Theme.of(context).hoverColor,
|
||||
onTap: () {
|
||||
+3
-2
@@ -13,9 +13,10 @@ import 'package:dartz/dartz.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'change_cover_popover.dart';
|
||||
|
||||
part 'cover_image_picker_bloc.freezed.dart';
|
||||
import 'cover_editor.dart';
|
||||
|
||||
part 'custom_cover_picker_bloc.freezed.dart';
|
||||
|
||||
class CoverImagePickerBloc
|
||||
extends Bloc<CoverImagePickerEvent, CoverImagePickerState> {
|
||||
+507
@@ -0,0 +1,507 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'cover_editor.dart';
|
||||
import 'emoji_icon_widget.dart';
|
||||
import 'emoji_popover.dart';
|
||||
|
||||
const double kCoverHeight = 250.0;
|
||||
const double kIconHeight = 60.0;
|
||||
const double kToolbarHeight = 40.0; // with padding to the top
|
||||
|
||||
class DocumentHeaderBlockKeys {
|
||||
const DocumentHeaderBlockKeys._();
|
||||
|
||||
static const String coverType = 'cover_selection_type';
|
||||
static const String coverDetails = 'cover_selection';
|
||||
static const String icon = 'selected_icon';
|
||||
}
|
||||
|
||||
enum CoverType {
|
||||
none,
|
||||
color,
|
||||
file,
|
||||
asset;
|
||||
|
||||
static CoverType fromString(String? value) {
|
||||
if (value == null) {
|
||||
return CoverType.none;
|
||||
}
|
||||
return CoverType.values.firstWhere(
|
||||
(e) => e.toString() == value,
|
||||
orElse: () => CoverType.none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentHeaderNodeWidgetBuilder implements NodeWidgetBuilder {
|
||||
@override
|
||||
Widget build(NodeWidgetContext<Node> context) {
|
||||
return DocumentHeaderNodeWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
editorState: context.editorState,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
NodeValidator<Node> get nodeValidator => (_) => true;
|
||||
}
|
||||
|
||||
class DocumentHeaderNodeWidget extends StatefulWidget {
|
||||
const DocumentHeaderNodeWidget({
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<DocumentHeaderNodeWidget> createState() =>
|
||||
_DocumentHeaderNodeWidgetState();
|
||||
}
|
||||
|
||||
class _DocumentHeaderNodeWidgetState extends State<DocumentHeaderNodeWidget> {
|
||||
CoverType get coverType => CoverType.fromString(
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
|
||||
);
|
||||
String? get coverDetails =>
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails];
|
||||
String get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon];
|
||||
bool get hasIcon =>
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.icon]?.isNotEmpty ?? false;
|
||||
bool get hasCover => coverType != CoverType.none;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.node.addListener(_reload);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.node.removeListener(_reload);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reload() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _calculateOverallHeight(),
|
||||
child: DocumentHeaderToolbar(
|
||||
onCoverChanged: _saveCover,
|
||||
node: widget.node,
|
||||
editorState: widget.editorState,
|
||||
hasCover: hasCover,
|
||||
hasIcon: hasIcon,
|
||||
),
|
||||
),
|
||||
if (hasCover)
|
||||
DocumentCover(
|
||||
editorState: widget.editorState,
|
||||
node: widget.node,
|
||||
coverType: coverType,
|
||||
coverDetails: coverDetails,
|
||||
onCoverChanged: (type, details) =>
|
||||
_saveCover(cover: (type, details)),
|
||||
),
|
||||
if (hasIcon)
|
||||
Positioned(
|
||||
left: 80,
|
||||
// if hasCover, there shouldn't be icons present so the icon can
|
||||
// be closer to the bottom.
|
||||
bottom:
|
||||
hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight,
|
||||
child: DocumentIcon(
|
||||
editorState: widget.editorState,
|
||||
node: widget.node,
|
||||
icon: icon,
|
||||
onIconChanged: (icon) => _saveCover(icon: icon),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
double _calculateOverallHeight() {
|
||||
switch ((hasIcon, hasCover)) {
|
||||
case (true, true):
|
||||
return kCoverHeight + kToolbarHeight;
|
||||
case (true, false):
|
||||
return 50 + kIconHeight + kToolbarHeight;
|
||||
case (false, true):
|
||||
return kCoverHeight + kToolbarHeight;
|
||||
case (false, false):
|
||||
return kToolbarHeight;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveCover({(CoverType, String?)? cover, String? icon}) {
|
||||
final transaction = widget.editorState.transaction;
|
||||
final Map<String, dynamic> attributes = {
|
||||
DocumentHeaderBlockKeys.coverType:
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
|
||||
DocumentHeaderBlockKeys.coverDetails:
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails],
|
||||
DocumentHeaderBlockKeys.icon:
|
||||
widget.node.attributes[DocumentHeaderBlockKeys.icon]
|
||||
};
|
||||
if (cover != null) {
|
||||
attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString();
|
||||
attributes[DocumentHeaderBlockKeys.coverDetails] = cover.$2;
|
||||
}
|
||||
if (icon != null) {
|
||||
attributes[DocumentHeaderBlockKeys.icon] = icon;
|
||||
}
|
||||
|
||||
transaction.updateNode(widget.node, attributes);
|
||||
return widget.editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class DocumentHeaderToolbar extends StatefulWidget {
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
final bool hasCover;
|
||||
final bool hasIcon;
|
||||
final Future<void> Function({(CoverType, String?)? cover, String? icon})
|
||||
onCoverChanged;
|
||||
|
||||
const DocumentHeaderToolbar({
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
required this.hasCover,
|
||||
required this.hasIcon,
|
||||
required this.onCoverChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DocumentHeaderToolbar> createState() => _DocumentHeaderToolbarState();
|
||||
}
|
||||
|
||||
class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
|
||||
bool isHidden = true;
|
||||
bool isPopoverOpen = false;
|
||||
|
||||
final PopoverController _popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (event) => setHidden(false),
|
||||
onExit: (event) {
|
||||
if (!isPopoverOpen) {
|
||||
setHidden(true);
|
||||
}
|
||||
},
|
||||
opaque: false,
|
||||
child: Container(
|
||||
alignment: Alignment.bottomLeft,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 80),
|
||||
child: SizedBox(
|
||||
height: 28,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: buildRowChildren(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> buildRowChildren() {
|
||||
if (isHidden || widget.hasCover && widget.hasIcon) {
|
||||
return [];
|
||||
}
|
||||
final List<Widget> children = [];
|
||||
|
||||
if (!widget.hasCover) {
|
||||
children.add(
|
||||
FlowyButton(
|
||||
leftIconSize: const Size.square(18),
|
||||
onTap: () => widget.onCoverChanged(
|
||||
cover: (CoverType.asset, builtInAssetImages.first),
|
||||
),
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: const FlowySvg(name: 'editor/image'),
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_cover_addCover.tr(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.hasIcon) {
|
||||
children.add(
|
||||
FlowyButton(
|
||||
leftIconSize: const Size.square(18),
|
||||
onTap: () => widget.onCoverChanged(icon: ""),
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: const Icon(
|
||||
Icons.emoji_emotions_outlined,
|
||||
size: 18,
|
||||
),
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_cover_removeIcon.tr(),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
children.add(
|
||||
AppFlowyPopover(
|
||||
onClose: () => isPopoverOpen = false,
|
||||
controller: _popoverController,
|
||||
offset: const Offset(0, 8),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: BoxConstraints.loose(const Size(320, 380)),
|
||||
child: FlowyButton(
|
||||
leftIconSize: const Size.square(18),
|
||||
useIntrinsicWidth: true,
|
||||
leftIcon: const Icon(
|
||||
Icons.emoji_emotions_outlined,
|
||||
size: 18,
|
||||
),
|
||||
text: FlowyText.regular(
|
||||
LocaleKeys.document_plugins_cover_addIcon.tr(),
|
||||
),
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
isPopoverOpen = true;
|
||||
return EmojiPopover(
|
||||
showRemoveButton: widget.hasIcon,
|
||||
removeIcon: () {
|
||||
widget.onCoverChanged(icon: "");
|
||||
_popoverController.close();
|
||||
},
|
||||
node: widget.node,
|
||||
editorState: widget.editorState,
|
||||
onEmojiChanged: (Emoji emoji) {
|
||||
widget.onCoverChanged(icon: emoji.emoji);
|
||||
_popoverController.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
void setHidden(bool value) {
|
||||
if (isHidden == value) return;
|
||||
setState(() {
|
||||
isHidden = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class DocumentCover extends StatefulWidget {
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
final CoverType coverType;
|
||||
final String? coverDetails;
|
||||
final Future<void> Function(CoverType type, String? details) onCoverChanged;
|
||||
|
||||
const DocumentCover({
|
||||
required this.editorState,
|
||||
required this.node,
|
||||
required this.coverType,
|
||||
required this.onCoverChanged,
|
||||
this.coverDetails,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DocumentCover> createState() => DocumentCoverState();
|
||||
}
|
||||
|
||||
class DocumentCoverState extends State<DocumentCover> {
|
||||
bool isOverlayButtonsHidden = true;
|
||||
bool isPopoverOpen = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: kCoverHeight,
|
||||
child: MouseRegion(
|
||||
onEnter: (event) => setOverlayButtonsHidden(false),
|
||||
onExit: (event) =>
|
||||
setOverlayButtonsHidden(isPopoverOpen ? false : true),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: double.infinity,
|
||||
width: double.infinity,
|
||||
child: _buildCoverImage(),
|
||||
),
|
||||
if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverImage() {
|
||||
switch (widget.coverType) {
|
||||
case CoverType.file:
|
||||
final imageFile = File(widget.coverDetails ?? "");
|
||||
if (!imageFile.existsSync()) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onCoverChanged(CoverType.none, null);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Image.file(
|
||||
imageFile,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
case CoverType.asset:
|
||||
return Image.asset(
|
||||
widget.coverDetails!,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
case CoverType.color:
|
||||
final color = widget.coverDetails?.toColor() ?? Colors.white;
|
||||
return Container(color: color);
|
||||
case CoverType.none:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCoverOverlayButtons(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: 20,
|
||||
right: 50,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppFlowyPopover(
|
||||
offset: const Offset(0, 8),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
constraints: BoxConstraints.loose(const Size(380, 450)),
|
||||
margin: EdgeInsets.zero,
|
||||
onClose: () => isPopoverOpen = false,
|
||||
child: RoundedTextButton(
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
textColor: Theme.of(context).colorScheme.tertiary,
|
||||
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
width: 120,
|
||||
height: 28,
|
||||
title: LocaleKeys.document_plugins_cover_changeCover.tr(),
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
isPopoverOpen = true;
|
||||
return ChangeCoverPopover(
|
||||
node: widget.node,
|
||||
editorState: widget.editorState,
|
||||
onCoverChanged: (cover, selection) =>
|
||||
widget.onCoverChanged(cover, selection),
|
||||
);
|
||||
},
|
||||
),
|
||||
const HSpace(10),
|
||||
DeleteCoverButton(
|
||||
onTap: () => widget.onCoverChanged(CoverType.none, null),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setOverlayButtonsHidden(bool value) {
|
||||
if (isOverlayButtonsHidden == value) return;
|
||||
setState(() {
|
||||
isOverlayButtonsHidden = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class DeleteCoverButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const DeleteCoverButton({required this.onTap, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyIconButton(
|
||||
hoverColor: Theme.of(context).colorScheme.surface,
|
||||
fillColor: Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
iconPadding: const EdgeInsets.all(5),
|
||||
width: 28,
|
||||
icon: svgWidget(
|
||||
'editor/delete',
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
onPressed: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class DocumentIcon extends StatefulWidget {
|
||||
final Node node;
|
||||
final EditorState editorState;
|
||||
final String icon;
|
||||
final Future<void> Function(String icon) onIconChanged;
|
||||
|
||||
const DocumentIcon({
|
||||
required this.node,
|
||||
required this.editorState,
|
||||
required this.icon,
|
||||
required this.onIconChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DocumentIcon> createState() => _DocumentIconState();
|
||||
}
|
||||
|
||||
class _DocumentIconState extends State<DocumentIcon> {
|
||||
final PopoverController _popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppFlowyPopover(
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
controller: _popoverController,
|
||||
offset: const Offset(0, 8),
|
||||
constraints: BoxConstraints.loose(const Size(320, 380)),
|
||||
child: EmojiIconWidget(emoji: widget.icon),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
return EmojiPopover(
|
||||
node: widget.node,
|
||||
showRemoveButton: true,
|
||||
removeIcon: () {
|
||||
widget.onIconChanged("");
|
||||
_popoverController.close();
|
||||
},
|
||||
editorState: widget.editorState,
|
||||
onEmojiChanged: (Emoji emoji) {
|
||||
widget.onIconChanged(emoji.emoji);
|
||||
_popoverController.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -5,12 +5,10 @@ class EmojiIconWidget extends StatefulWidget {
|
||||
const EmojiIconWidget({
|
||||
super.key,
|
||||
required this.emoji,
|
||||
this.size = 80,
|
||||
this.emojiSize = 60,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final double size;
|
||||
final double emojiSize;
|
||||
|
||||
@override
|
||||
@@ -25,12 +23,11 @@ class _EmojiIconWidgetState extends State<EmojiIconWidget> {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setHidden(false),
|
||||
onExit: (_) => setHidden(true),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
height: widget.size,
|
||||
width: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: !hover
|
||||
? Theme.of(context).colorScheme.inverseSurface
|
||||
? Theme.of(context).colorScheme.inverseSurface.withOpacity(0.5)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
+36
-36
@@ -30,38 +30,35 @@ class EmojiPopover extends StatefulWidget {
|
||||
class _EmojiPopoverState extends State<EmojiPopover> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.showRemoveButton)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: DeleteButton(onTap: widget.removeIcon),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (category, emoji) {
|
||||
widget.onEmojiChanged(emoji);
|
||||
},
|
||||
config: Config(
|
||||
columns: 8,
|
||||
emojiSizeMax: 28,
|
||||
bgColor: Colors.transparent,
|
||||
iconColor: Theme.of(context).iconTheme.color!,
|
||||
iconColorSelected: Theme.of(context).colorScheme.onSurface,
|
||||
selectedHoverColor: Theme.of(context).colorScheme.secondary,
|
||||
progressIndicatorColor: Theme.of(context).iconTheme.color!,
|
||||
buttonMode: ButtonMode.CUPERTINO,
|
||||
initCategory: Category.RECENT,
|
||||
),
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showRemoveButton)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: DeleteButton(onTap: widget.removeIcon),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (category, emoji) {
|
||||
widget.onEmojiChanged(emoji);
|
||||
},
|
||||
config: Config(
|
||||
columns: 8,
|
||||
emojiSizeMax: 28,
|
||||
bgColor: Colors.transparent,
|
||||
iconColor: Theme.of(context).iconTheme.color!,
|
||||
iconColorSelected: Theme.of(context).colorScheme.onSurface,
|
||||
selectedHoverColor: Theme.of(context).colorScheme.secondary,
|
||||
progressIndicatorColor: Theme.of(context).iconTheme.color!,
|
||||
buttonMode: ButtonMode.CUPERTINO,
|
||||
initCategory: Category.RECENT,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,13 +69,16 @@ class DeleteButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyButton(
|
||||
onTap: onTap,
|
||||
useIntrinsicWidth: true,
|
||||
text: FlowyText(
|
||||
LocaleKeys.document_plugins_cover_removeIcon.tr(),
|
||||
return SizedBox(
|
||||
height: 28,
|
||||
child: FlowyButton(
|
||||
onTap: onTap,
|
||||
useIntrinsicWidth: true,
|
||||
text: FlowyText(
|
||||
LocaleKeys.document_plugins_cover_removeIcon.tr(),
|
||||
),
|
||||
leftIcon: const FlowySvg(name: 'editor/delete'),
|
||||
),
|
||||
leftIcon: const FlowySvg(name: 'editor/delete'),
|
||||
);
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ImageMenu extends StatefulWidget {
|
||||
const ImageMenu({
|
||||
super.key,
|
||||
required this.node,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final ImageBlockComponentWidgetState state;
|
||||
|
||||
@override
|
||||
State<ImageMenu> createState() => _ImageMenuState();
|
||||
}
|
||||
|
||||
class _ImageMenuState extends State<ImageMenu> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(4),
|
||||
_ImageCopyLinkButton(
|
||||
onTap: copyImageLink,
|
||||
),
|
||||
const HSpace(4),
|
||||
_ImageAlignButton(
|
||||
node: widget.node,
|
||||
state: widget.state,
|
||||
),
|
||||
const _Divider(),
|
||||
_ImageDeleteButton(
|
||||
onTap: () => deleteImage(),
|
||||
),
|
||||
const HSpace(4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void copyImageLink() {
|
||||
final url = widget.node.attributes[ImageBlockKeys.url];
|
||||
if (url != null) {
|
||||
Clipboard.setData(ClipboardData(text: url));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: FlowyText(
|
||||
LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteImage() async {
|
||||
final node = widget.node;
|
||||
final editorState = context.read<EditorState>();
|
||||
final transaction = editorState.transaction;
|
||||
transaction.deleteNode(node);
|
||||
transaction.afterSelection = null;
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageCopyLinkButton extends StatelessWidget {
|
||||
const _ImageCopyLinkButton({
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: const FlowySvg(
|
||||
name: 'editor/copy',
|
||||
size: Size.square(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageAlignButton extends StatefulWidget {
|
||||
const _ImageAlignButton({
|
||||
required this.node,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
final ImageBlockComponentWidgetState state;
|
||||
|
||||
@override
|
||||
State<_ImageAlignButton> createState() => _ImageAlignButtonState();
|
||||
}
|
||||
|
||||
const interceptorKey = 'image-align';
|
||||
|
||||
class _ImageAlignButtonState extends State<_ImageAlignButton> {
|
||||
final gestureInterceptor = SelectionGestureInterceptor(
|
||||
key: interceptorKey,
|
||||
canTap: (details) => false,
|
||||
);
|
||||
|
||||
String get align => widget.node.attributes['align'] ?? 'center';
|
||||
final popoverController = PopoverController();
|
||||
late final EditorState editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
editorState = context.read<EditorState>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
allowMenuClose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnoreParentGestureWidget(
|
||||
child: AppFlowyPopover(
|
||||
onClose: allowMenuClose,
|
||||
controller: popoverController,
|
||||
windowPadding: const EdgeInsets.all(0),
|
||||
margin: const EdgeInsets.all(0),
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
offset: const Offset(0, 10),
|
||||
child: buildAlignIcon(),
|
||||
popupBuilder: (_) {
|
||||
preventMenuClose();
|
||||
return _AlignButtons(
|
||||
onAlignChanged: onAlignChanged,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onAlignChanged(String align) {
|
||||
popoverController.close();
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(widget.node, {
|
||||
ImageBlockKeys.align: align,
|
||||
});
|
||||
editorState.apply(transaction);
|
||||
|
||||
allowMenuClose();
|
||||
}
|
||||
|
||||
void preventMenuClose() {
|
||||
widget.state.alwaysShowMenu = true;
|
||||
editorState.service.selectionService.registerGestureInterceptor(
|
||||
gestureInterceptor,
|
||||
);
|
||||
}
|
||||
|
||||
void allowMenuClose() {
|
||||
widget.state.alwaysShowMenu = false;
|
||||
editorState.service.selectionService.unregisterGestureInterceptor(
|
||||
interceptorKey,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAlignIcon() {
|
||||
return FlowySvg(
|
||||
name: 'editor/align/$align',
|
||||
size: const Size.square(16),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlignButtons extends StatelessWidget {
|
||||
const _AlignButtons({
|
||||
required this.onAlignChanged,
|
||||
});
|
||||
|
||||
final Function(String align) onAlignChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const HSpace(4),
|
||||
_AlignButton(
|
||||
align: 'left',
|
||||
onTap: () => onAlignChanged('left'),
|
||||
),
|
||||
const _Divider(),
|
||||
_AlignButton(
|
||||
align: 'left',
|
||||
onTap: () => onAlignChanged('center'),
|
||||
),
|
||||
const _Divider(),
|
||||
_AlignButton(
|
||||
align: 'left',
|
||||
onTap: () => onAlignChanged('right'),
|
||||
),
|
||||
const HSpace(4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlignButton extends StatelessWidget {
|
||||
const _AlignButton({
|
||||
required this.align,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String align;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: FlowySvg(
|
||||
name: 'editor/align/$align',
|
||||
size: const Size.square(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageDeleteButton extends StatelessWidget {
|
||||
const _ImageDeleteButton({
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: const FlowySvg(
|
||||
name: 'editor/delete',
|
||||
size: Size.square(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Divider extends StatelessWidget {
|
||||
const _Divider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Container(
|
||||
width: 1,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:flowy_infra/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final customImageMenuItem = SelectionMenuItem(
|
||||
name: AppFlowyEditorLocalizations.current.image,
|
||||
icon: (editorState, isSelected, style) => SelectionMenuIconWidget(
|
||||
name: 'image',
|
||||
isSelected: isSelected,
|
||||
style: style,
|
||||
),
|
||||
keywords: ['image', 'picture', 'img', 'photo'],
|
||||
handler: (editorState, menuService, context) {
|
||||
final container = Overlay.of(context);
|
||||
showImageMenu(
|
||||
container,
|
||||
editorState,
|
||||
menuService,
|
||||
onInsertImage: (url) async {
|
||||
// if the url is http, we can insert it directly
|
||||
// otherwise, if it's a file url, we need to copy the file to the app's document directory
|
||||
|
||||
final regex = RegExp('^(http|https)://');
|
||||
if (regex.hasMatch(url)) {
|
||||
await editorState.insertImageNode(url);
|
||||
} else {
|
||||
final path = await getIt<ApplicationDataStorage>().getPath();
|
||||
final imagePath = p.join(
|
||||
path,
|
||||
'images',
|
||||
);
|
||||
try {
|
||||
// create the directory if not exists
|
||||
final directory = Directory(imagePath);
|
||||
if (!directory.existsSync()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
final copyToPath = p.join(
|
||||
imagePath,
|
||||
'${uuid()}${p.extension(url)}',
|
||||
);
|
||||
await File(url).copy(
|
||||
copyToPath,
|
||||
);
|
||||
await editorState.insertImageNode(copyToPath);
|
||||
} catch (e) {
|
||||
Log.error('cannot copy image file', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
enum MentionType {
|
||||
page;
|
||||
|
||||
static MentionType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'page':
|
||||
return page;
|
||||
default:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MentionBlockKeys {
|
||||
const MentionBlockKeys._();
|
||||
|
||||
static const mention = 'mention';
|
||||
static const type = 'type'; // MentionType, String
|
||||
static const pageId = 'page_id';
|
||||
static const pageType = 'page_type';
|
||||
static const pageName = 'page_name';
|
||||
}
|
||||
|
||||
class InlinePageReferenceService {
|
||||
customPageLinkMenu({
|
||||
bool shouldInsertKeyword = false,
|
||||
SelectionMenuStyle style = SelectionMenuStyle.light,
|
||||
String character = '@',
|
||||
}) {
|
||||
return CharacterShortcutEvent(
|
||||
key: 'show page link menu',
|
||||
character: character,
|
||||
handler: (editorState) async {
|
||||
final items = await generatePageItems(character);
|
||||
return _showPageSelectionMenu(
|
||||
editorState,
|
||||
items,
|
||||
shouldInsertKeyword: shouldInsertKeyword,
|
||||
style: style,
|
||||
character: character,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SelectionMenuService? _selectionMenuService;
|
||||
Future<bool> _showPageSelectionMenu(
|
||||
EditorState editorState,
|
||||
List<SelectionMenuItem> items, {
|
||||
bool shouldInsertKeyword = true,
|
||||
SelectionMenuStyle style = SelectionMenuStyle.light,
|
||||
String character = '@',
|
||||
}) async {
|
||||
if (PlatformExtension.isMobile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// delete the selection
|
||||
await editorState.deleteSelection(selection);
|
||||
|
||||
final afterSelection = editorState.selection;
|
||||
if (afterSelection == null || !afterSelection.isCollapsed) {
|
||||
assert(false, 'the selection should be collapsed');
|
||||
return true;
|
||||
}
|
||||
await editorState.insertTextAtPosition(
|
||||
character,
|
||||
position: selection.start,
|
||||
);
|
||||
|
||||
() {
|
||||
final context = editorState.getNodeAtPath(selection.start.path)?.context;
|
||||
if (context != null) {
|
||||
_selectionMenuService = SelectionMenu(
|
||||
context: context,
|
||||
editorState: editorState,
|
||||
selectionMenuItems: items,
|
||||
deleteSlashByDefault: false,
|
||||
style: style,
|
||||
itemCountFilter: 5,
|
||||
);
|
||||
_selectionMenuService?.show();
|
||||
}
|
||||
}();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<List<SelectionMenuItem>> generatePageItems(String character) async {
|
||||
final service = ViewBackendService();
|
||||
final List<(ViewPB, List<ViewPB>)> pbViews = await service.fetchViews(
|
||||
(_, __) => true,
|
||||
);
|
||||
if (pbViews.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
final List<SelectionMenuItem> pages = [];
|
||||
final List<ViewPB> views = [];
|
||||
for (final element in pbViews) {
|
||||
views.addAll(element.$2);
|
||||
}
|
||||
views.sort(((a, b) => b.createTime.compareTo(a.createTime)));
|
||||
|
||||
for (final view in views) {
|
||||
final SelectionMenuItem pageSelectionMenuItem = SelectionMenuItem(
|
||||
icon: (editorState, isSelected, style) => SelectableSvgWidget(
|
||||
name: view.iconName,
|
||||
isSelected: isSelected,
|
||||
style: style,
|
||||
),
|
||||
keywords: [
|
||||
view.name.toLowerCase(),
|
||||
],
|
||||
name: view.name,
|
||||
handler: (editorState, menuService, context) async {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null || !selection.isCollapsed) {
|
||||
return;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.end.path);
|
||||
final delta = node?.delta;
|
||||
if (node == null || delta == null) {
|
||||
return;
|
||||
}
|
||||
final index = selection.endIndex;
|
||||
final lastKeywordIndex =
|
||||
delta.toPlainText().substring(0, index).lastIndexOf(character);
|
||||
// @page name -> $
|
||||
// preload the page infos
|
||||
pageMemorizer[view.id] = view;
|
||||
final transaction = editorState.transaction
|
||||
..replaceText(
|
||||
node,
|
||||
lastKeywordIndex,
|
||||
index - lastKeywordIndex,
|
||||
'\$',
|
||||
attributes: {
|
||||
MentionBlockKeys.mention: {
|
||||
MentionBlockKeys.type: MentionType.page.name,
|
||||
MentionBlockKeys.pageId: view.id,
|
||||
}
|
||||
},
|
||||
);
|
||||
await editorState.apply(transaction);
|
||||
},
|
||||
);
|
||||
pages.add(pageSelectionMenuItem);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MentionBlock extends StatelessWidget {
|
||||
const MentionBlock({
|
||||
super.key,
|
||||
required this.mention,
|
||||
});
|
||||
|
||||
final Map mention;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = MentionType.fromString(mention[MentionBlockKeys.type]);
|
||||
if (type == MentionType.page) {
|
||||
final pageId = mention[MentionBlockKeys.pageId];
|
||||
return MentionPageBlock(key: ValueKey(pageId), pageId: pageId);
|
||||
}
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy/plugins/trash/application/trash_service.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
show EditorState, SelectionUpdateReason;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
final pageMemorizer = <String, ViewPB?>{};
|
||||
|
||||
class MentionPageBlock extends StatefulWidget {
|
||||
const MentionPageBlock({
|
||||
super.key,
|
||||
required this.pageId,
|
||||
});
|
||||
|
||||
final String pageId;
|
||||
|
||||
@override
|
||||
State<MentionPageBlock> createState() => _MentionPageBlockState();
|
||||
}
|
||||
|
||||
class _MentionPageBlockState extends State<MentionPageBlock> {
|
||||
late final EditorState editorState;
|
||||
late final Future<ViewPB?> viewPBFuture;
|
||||
ViewListener? viewListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
editorState = context.read<EditorState>();
|
||||
viewPBFuture = fetchView(widget.pageId);
|
||||
viewListener = ViewListener(viewId: widget.pageId)
|
||||
..start(
|
||||
onViewUpdated: (p0) {
|
||||
pageMemorizer[p0.id] = p0;
|
||||
editorState.reload();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
viewListener?.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
||||
return FutureBuilder<ViewPB?>(
|
||||
initialData: pageMemorizer[widget.pageId],
|
||||
future: viewPBFuture,
|
||||
builder: (context, state) {
|
||||
final view = state.data;
|
||||
// memorize the result
|
||||
pageMemorizer[widget.pageId] = view;
|
||||
if (view == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
updateSelection();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: FlowyHover(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => openPage(widget.pageId),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const HSpace(4),
|
||||
FlowySvg(
|
||||
name: view.layout.iconName,
|
||||
size: const Size.square(18.0),
|
||||
),
|
||||
const HSpace(2),
|
||||
FlowyText(
|
||||
view.name,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
const HSpace(2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void openPage(String pageId) async {
|
||||
final view = await fetchView(pageId);
|
||||
if (view == null) {
|
||||
Log.error('Page($pageId) not found');
|
||||
return;
|
||||
}
|
||||
getIt<MenuSharedState>().latestOpenView = view;
|
||||
}
|
||||
|
||||
Future<ViewPB?> fetchView(String pageId) async {
|
||||
final views = await ViewBackendService().fetchViews((_, __) => true);
|
||||
final flattenViews = views.expand((e) => [e.$1, ...e.$2]).toList();
|
||||
final view = flattenViews.firstWhereOrNull(
|
||||
(element) => element.id == pageId,
|
||||
);
|
||||
if (view == null) {
|
||||
// try to fetch from trash
|
||||
final trashViews = await TrashService().readTrash();
|
||||
final trash = trashViews.fold(
|
||||
(l) => l.items.firstWhereOrNull((element) => element.id == pageId),
|
||||
(r) => null,
|
||||
);
|
||||
if (trash != null) {
|
||||
return ViewPB()
|
||||
..id = trash.id
|
||||
..name = trash.name;
|
||||
}
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
void updateSelection() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
editorState.updateSelectionWithReason(
|
||||
editorState.selection,
|
||||
reason: SelectionUpdateReason.transaction,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class OutlineBlockKeys {
|
||||
const OutlineBlockKeys._();
|
||||
|
||||
static const String type = 'outline';
|
||||
static const String backgroundColor = blockComponentBackgroundColor;
|
||||
}
|
||||
|
||||
// defining the callout block menu item for selection
|
||||
SelectionMenuItem outlineItem = SelectionMenuItem.node(
|
||||
name: LocaleKeys.document_selectionMenu_outline.tr(),
|
||||
iconData: Icons.list_alt,
|
||||
keywords: ['outline', 'table of contents'],
|
||||
nodeBuilder: (editorState) => outlineBlockNode(),
|
||||
replace: (_, node) => node.delta?.isEmpty ?? false,
|
||||
);
|
||||
|
||||
Node outlineBlockNode() {
|
||||
return Node(
|
||||
type: OutlineBlockKeys.type,
|
||||
);
|
||||
}
|
||||
|
||||
class OutlineBlockComponentBuilder extends BlockComponentBuilder {
|
||||
OutlineBlockComponentBuilder({
|
||||
this.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@override
|
||||
final BlockComponentConfiguration configuration;
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return OutlineBlockWidget(
|
||||
key: node.key,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
showActions: showActions(node),
|
||||
actionBuilder: (context, state) => actionBuilder(
|
||||
blockComponentContext,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool validate(Node node) => node.children.isEmpty;
|
||||
}
|
||||
|
||||
class OutlineBlockWidget extends BlockComponentStatefulWidget {
|
||||
const OutlineBlockWidget({
|
||||
super.key,
|
||||
required super.node,
|
||||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
});
|
||||
|
||||
@override
|
||||
State<OutlineBlockWidget> createState() => _OutlineBlockWidgetState();
|
||||
}
|
||||
|
||||
class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
|
||||
with BlockComponentConfigurable {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
// get the background color of the note block from the node's attributes
|
||||
Color get backgroundColor {
|
||||
final colorString =
|
||||
node.attributes[OutlineBlockKeys.backgroundColor] as String?;
|
||||
if (colorString == null) {
|
||||
return Colors.transparent;
|
||||
}
|
||||
return colorString.toColor();
|
||||
}
|
||||
|
||||
late EditorState editorState = context.read<EditorState>();
|
||||
late Stream<(TransactionTime, Transaction)> stream =
|
||||
editorState.transactionStream;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder(
|
||||
stream: stream,
|
||||
builder: (context, snapshot) {
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
return BlockComponentActionWrapper(
|
||||
node: widget.node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
child: _buildOutlineBlock(),
|
||||
);
|
||||
}
|
||||
return _buildOutlineBlock();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOutlineBlock() {
|
||||
final children = getHeadingNodes()
|
||||
.map(
|
||||
(e) => Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 4.0,
|
||||
),
|
||||
width: double.infinity,
|
||||
child: OutlineItemWidget(node: e),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
if (children.isEmpty) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
LocaleKeys.document_plugins_outline_addHeadingToCreateOutline.tr(),
|
||||
style: configuration.placeholderTextStyle(node),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||
color: backgroundColor,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Node> getHeadingNodes() {
|
||||
final children = editorState.document.root.children;
|
||||
return children.where((element) => element.type == HeadingBlockKeys.type);
|
||||
}
|
||||
}
|
||||
|
||||
class OutlineItemWidget extends StatelessWidget {
|
||||
OutlineItemWidget({
|
||||
super.key,
|
||||
required this.node,
|
||||
}) {
|
||||
assert(node.type == HeadingBlockKeys.type);
|
||||
}
|
||||
|
||||
final Node node;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final editorState = context.read<EditorState>();
|
||||
final textStyle = editorState.editorStyle.textStyleConfiguration;
|
||||
final style = textStyle.href.combine(textStyle.text);
|
||||
return FlowyHover(
|
||||
style: HoverStyle(
|
||||
hoverColor: Colors.grey.withOpacity(0.2), // TODO: use theme color.
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => updateBlockSelection(context),
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(left: node.leftIndent),
|
||||
child: Text(
|
||||
node.outlineItemText,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateBlockSelection(BuildContext context) {
|
||||
final editorState = context.read<EditorState>();
|
||||
editorState.selectionType = SelectionType.block;
|
||||
editorState.selection = Selection.collapse(
|
||||
node.path,
|
||||
node.delta?.length ?? 0,
|
||||
);
|
||||
editorState.selectionType = null;
|
||||
}
|
||||
}
|
||||
|
||||
extension on Node {
|
||||
double get leftIndent {
|
||||
assert(type == HeadingBlockKeys.type);
|
||||
if (type != HeadingBlockKeys.type) {
|
||||
return 0.0;
|
||||
}
|
||||
final level = attributes[HeadingBlockKeys.level];
|
||||
if (level == 2) {
|
||||
return 20;
|
||||
} else if (level == 3) {
|
||||
return 40;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
String get outlineItemText {
|
||||
return delta?.toPlainText() ?? '';
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -1,12 +1,13 @@
|
||||
export 'callout/callout_block_component.dart';
|
||||
export 'code_block/code_block_component.dart';
|
||||
export 'code_block/code_block_shortcut_event.dart';
|
||||
export 'cover/change_cover_popover_bloc.dart';
|
||||
export 'cover/cover_node_widget.dart';
|
||||
export 'cover/cover_image_picker.dart';
|
||||
export 'header/cover_editor_bloc.dart';
|
||||
export 'header/document_header_node_widget.dart';
|
||||
export 'header/custom_cover_picker.dart';
|
||||
export 'emoji_picker/emoji_menu_item.dart';
|
||||
export 'extensions/flowy_tint_extension.dart';
|
||||
export 'database/inline_database_menu_item.dart';
|
||||
export 'database/referenced_database_menu_item.dart';
|
||||
export 'database/database_view_block_component.dart';
|
||||
export 'math_equation/math_equation_block_component.dart';
|
||||
export 'openai/widgets/auto_completion_node_widget.dart';
|
||||
@@ -14,3 +15,8 @@ export 'openai/widgets/smart_edit_node_widget.dart';
|
||||
export 'openai/widgets/smart_edit_toolbar_item.dart';
|
||||
export 'toggle/toggle_block_component.dart';
|
||||
export 'toggle/toggle_block_shortcut_event.dart';
|
||||
export 'outline/outline_block_component.dart';
|
||||
export 'image/image_menu.dart';
|
||||
export 'image/image_selection_menu.dart';
|
||||
export 'actions/option_action.dart';
|
||||
export 'actions/block_action_list.dart';
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg, Log;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -57,6 +59,7 @@ class EditorStyleCustomizer {
|
||||
),
|
||||
),
|
||||
),
|
||||
textSpanDecorator: customizeAttributeDecorator,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +127,17 @@ class EditorStyleCustomizer {
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle outlineBlockPlaceholderStyleBuilder() {
|
||||
final theme = Theme.of(context);
|
||||
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
|
||||
return TextStyle(
|
||||
fontFamily: 'poppins',
|
||||
fontSize: fontSize,
|
||||
height: 1.5,
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.6),
|
||||
);
|
||||
}
|
||||
|
||||
SelectionMenuStyle selectionMenuStyleBuilder() {
|
||||
final theme = Theme.of(context);
|
||||
return SelectionMenuStyle(
|
||||
@@ -142,4 +156,28 @@ class EditorStyleCustomizer {
|
||||
backgroundColor: theme.colorScheme.onTertiary,
|
||||
);
|
||||
}
|
||||
|
||||
InlineSpan customizeAttributeDecorator(
|
||||
TextInsert textInsert,
|
||||
TextSpan textSpan,
|
||||
) {
|
||||
final attributes = textInsert.attributes;
|
||||
if (attributes == null) {
|
||||
return textSpan;
|
||||
}
|
||||
final mention = attributes[MentionBlockKeys.mention] as Map?;
|
||||
if (mention != null) {
|
||||
final type = mention[MentionBlockKeys.type];
|
||||
if (type == MentionType.page.name) {
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: MentionBlock(
|
||||
key: ValueKey(mention[MentionBlockKeys.pageId]),
|
||||
mention: mention,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return textSpan;
|
||||
}
|
||||
}
|
||||
+10
@@ -25,6 +25,11 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
|
||||
void fetch() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final fontSize = prefs.getDouble(_kDocumentAppearanceFontSize) ?? 16.0;
|
||||
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
fontSize: fontSize,
|
||||
@@ -35,6 +40,11 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
|
||||
void syncFontSize(double fontSize) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setDouble(_kDocumentAppearanceFontSize, fontSize);
|
||||
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
fontSize: fontSize,
|
||||
|
||||
@@ -94,13 +94,21 @@ extension ViewExtension on ViewPB {
|
||||
}
|
||||
|
||||
String get iconName {
|
||||
switch (layout) {
|
||||
return layout.iconName;
|
||||
}
|
||||
}
|
||||
|
||||
extension ViewLayoutExtension on ViewLayoutPB {
|
||||
String get iconName {
|
||||
switch (this) {
|
||||
case ViewLayoutPB.Grid:
|
||||
return 'editor/grid';
|
||||
case ViewLayoutPB.Board:
|
||||
return 'editor/board';
|
||||
case ViewLayoutPB.Calendar:
|
||||
return 'editor/calendar';
|
||||
case ViewLayoutPB.Document:
|
||||
return 'editor/documents';
|
||||
default:
|
||||
throw Exception('Unknown layout type');
|
||||
}
|
||||
|
||||
@@ -154,8 +154,19 @@ class ViewBackendService {
|
||||
return FolderEventMoveView(payload).send();
|
||||
}
|
||||
|
||||
Future<List<(ViewPB, List<ViewPB>)>> fetchViewsWithLayoutType(
|
||||
ViewLayoutPB? layoutType,
|
||||
) async {
|
||||
return fetchViews((workspace, view) {
|
||||
if (layoutType != null) {
|
||||
return view.layout == layoutType;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<(ViewPB, List<ViewPB>)>> fetchViews(
|
||||
ViewLayoutPB layoutType,
|
||||
bool Function(WorkspaceSettingPB workspace, ViewPB view) filter,
|
||||
) async {
|
||||
final result = <(ViewPB, List<ViewPB>)>[];
|
||||
return FolderEventGetCurrentWorkspace().send().then((value) async {
|
||||
@@ -166,7 +177,7 @@ class ViewBackendService {
|
||||
final childViews = await getChildViews(viewId: view.id).then(
|
||||
(value) => value
|
||||
.getLeftOrNull<List<ViewPB>>()
|
||||
?.where((e) => e.layout == layoutType)
|
||||
?.where((e) => filter(workspaces, e))
|
||||
.toList(),
|
||||
);
|
||||
if (childViews != null && childViews.isNotEmpty) {
|
||||
|
||||
+24
-8
@@ -3,6 +3,7 @@ import 'package:appflowy/workspace/presentation/home/toast.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
@@ -78,6 +79,11 @@ class BubbleActionList extends StatelessWidget {
|
||||
"https://appflowy.gitbook.io/docs/essential-documentation/markdown",
|
||||
);
|
||||
break;
|
||||
case BubbleAction.github:
|
||||
_launchURL(
|
||||
'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,14 +175,14 @@ class FlowyVersionDescription extends CustomActionCell {
|
||||
}
|
||||
}
|
||||
|
||||
enum BubbleAction { whatsNews, help, debug, shortcuts, markdown }
|
||||
enum BubbleAction { whatsNews, help, debug, shortcuts, markdown, github }
|
||||
|
||||
class BubbleActionWrapper extends ActionCell {
|
||||
final BubbleAction inner;
|
||||
|
||||
BubbleActionWrapper(this.inner);
|
||||
@override
|
||||
Widget? leftIcon(Color iconColor) => FlowyText.regular(inner.emoji);
|
||||
Widget? leftIcon(Color iconColor) => inner.emoji;
|
||||
|
||||
@override
|
||||
String get name => inner.name;
|
||||
@@ -195,21 +201,31 @@ extension QuestionBubbleExtension on BubbleAction {
|
||||
return LocaleKeys.questionBubble_shortcuts.tr();
|
||||
case BubbleAction.markdown:
|
||||
return LocaleKeys.questionBubble_markdown.tr();
|
||||
case BubbleAction.github:
|
||||
return LocaleKeys.questionBubble_feedback.tr();
|
||||
}
|
||||
}
|
||||
|
||||
String get emoji {
|
||||
Widget get emoji {
|
||||
switch (this) {
|
||||
case BubbleAction.whatsNews:
|
||||
return '🆕';
|
||||
return const FlowyText.regular('🆕');
|
||||
case BubbleAction.help:
|
||||
return '👥';
|
||||
return const FlowyText.regular('👥');
|
||||
case BubbleAction.debug:
|
||||
return '🐛';
|
||||
return const FlowyText.regular('🐛');
|
||||
case BubbleAction.shortcuts:
|
||||
return '📋';
|
||||
return const FlowyText.regular('📋');
|
||||
case BubbleAction.markdown:
|
||||
return '✨';
|
||||
return const FlowyText.regular('✨');
|
||||
case BubbleAction.github:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: svgWidget(
|
||||
'login/github-mark',
|
||||
size: const Size.square(12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,10 +78,10 @@ class FlowyIconButton extends StatelessWidget {
|
||||
onPressed: onPressed,
|
||||
child: FlowyHover(
|
||||
style: HoverStyle(
|
||||
hoverColor: hoverColor,
|
||||
hoverColor: Colors.transparent,
|
||||
foregroundColorOnHover:
|
||||
iconColorOnHover ?? Theme.of(context).iconTheme.color,
|
||||
backgroundColor: fillColor ?? Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
child: Padding(
|
||||
padding: iconPadding,
|
||||
|
||||
@@ -9,12 +9,14 @@ class FlowyErrorPage extends StatelessWidget {
|
||||
Error e, {
|
||||
required String howToFix,
|
||||
Key? key,
|
||||
List<Widget>? actions,
|
||||
}) =>
|
||||
FlowyErrorPage._(
|
||||
e.toString(),
|
||||
stackTrace: e.stackTrace?.toString(),
|
||||
howToFix: howToFix,
|
||||
key: key,
|
||||
actions: actions,
|
||||
);
|
||||
|
||||
factory FlowyErrorPage.message(
|
||||
@@ -22,12 +24,14 @@ class FlowyErrorPage extends StatelessWidget {
|
||||
required String howToFix,
|
||||
String? stackTrace,
|
||||
Key? key,
|
||||
List<Widget>? actions,
|
||||
}) =>
|
||||
FlowyErrorPage._(
|
||||
message,
|
||||
key: key,
|
||||
stackTrace: stackTrace,
|
||||
howToFix: howToFix,
|
||||
actions: actions,
|
||||
);
|
||||
|
||||
factory FlowyErrorPage.exception(
|
||||
@@ -35,12 +39,14 @@ class FlowyErrorPage extends StatelessWidget {
|
||||
required String howToFix,
|
||||
String? stackTrace,
|
||||
Key? key,
|
||||
List<Widget>? actions,
|
||||
}) =>
|
||||
FlowyErrorPage._(
|
||||
e.toString(),
|
||||
stackTrace: stackTrace,
|
||||
key: key,
|
||||
howToFix: howToFix,
|
||||
actions: actions,
|
||||
);
|
||||
|
||||
const FlowyErrorPage._(
|
||||
@@ -48,14 +54,16 @@ class FlowyErrorPage extends StatelessWidget {
|
||||
required this.howToFix,
|
||||
this.stackTrace,
|
||||
super.key,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
static const _titleFontSize = 24.0;
|
||||
static const _titleToMessagePadding = 8.0;
|
||||
|
||||
final List<Widget>? actions;
|
||||
final String howToFix;
|
||||
final String message;
|
||||
final String? stackTrace;
|
||||
final String howToFix;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -87,6 +95,11 @@ class FlowyErrorPage extends StatelessWidget {
|
||||
height: _titleToMessagePadding,
|
||||
),
|
||||
if (stackTrace != null) StackTracePreview(stackTrace!),
|
||||
if (actions != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions!,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -53,8 +53,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: cd0f67a
|
||||
resolved-ref: cd0f67a48e40188114800fae9a0f59cafe15b0f2
|
||||
ref: "572a174"
|
||||
resolved-ref: "572a174892267e2f78f9c3d7f1fe4ca71c9be0db"
|
||||
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
|
||||
source: git
|
||||
version: "1.0.4"
|
||||
|
||||
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 0.2.4
|
||||
version: 0.2.5
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
@@ -46,7 +46,7 @@ dependencies:
|
||||
appflowy_editor:
|
||||
git:
|
||||
url: https://github.com/AppFlowy-IO/appflowy-editor.git
|
||||
ref: cd0f67a
|
||||
ref: 572a174
|
||||
appflowy_popover:
|
||||
path: packages/appflowy_popover
|
||||
|
||||
|
||||
@@ -27,12 +27,14 @@
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-regex": "^10.2.1",
|
||||
"events": "^3.3.0",
|
||||
"google-protobuf": "^3.21.2",
|
||||
"i18next": "^22.4.10",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"is-hotkey": "^0.2.0",
|
||||
"jest": "^29.5.0",
|
||||
"katex": "^0.16.7",
|
||||
"nanoid": "^4.0.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"protoc-gen-ts": "^0.8.5",
|
||||
@@ -44,6 +46,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-i18next": "^12.2.0",
|
||||
"react-katex": "^3.0.1",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react18-input-otp": "^1.1.2",
|
||||
@@ -60,12 +63,14 @@
|
||||
"@tauri-apps/cli": "^1.2.2",
|
||||
"@types/google-protobuf": "^3.15.6",
|
||||
"@types/is-hotkey": "^0.1.7",
|
||||
"@types/katex": "^0.16.0",
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/quill": "^2.0.10",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-beautiful-dnd": "^13.1.3",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-katex": "^3.0.0",
|
||||
"@types/utf8": "^3.0.1",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
|
||||
Generated
+52
@@ -37,6 +37,9 @@ dependencies:
|
||||
emoji-mart:
|
||||
specifier: ^5.5.2
|
||||
version: 5.5.2
|
||||
emoji-regex:
|
||||
specifier: ^10.2.1
|
||||
version: 10.2.1
|
||||
events:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
@@ -55,6 +58,9 @@ dependencies:
|
||||
jest:
|
||||
specifier: ^29.5.0
|
||||
version: 29.5.0(@types/node@18.16.9)
|
||||
katex:
|
||||
specifier: ^0.16.7
|
||||
version: 0.16.7
|
||||
nanoid:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.2
|
||||
@@ -88,6 +94,9 @@ dependencies:
|
||||
react-i18next:
|
||||
specifier: ^12.2.0
|
||||
version: 12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react-katex:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(prop-types@15.8.1)(react@18.2.0)
|
||||
react-redux:
|
||||
specifier: ^8.0.5
|
||||
version: 8.0.5(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
|
||||
@@ -132,6 +141,9 @@ devDependencies:
|
||||
'@types/is-hotkey':
|
||||
specifier: ^0.1.7
|
||||
version: 0.1.7
|
||||
'@types/katex':
|
||||
specifier: ^0.16.0
|
||||
version: 0.16.0
|
||||
'@types/node':
|
||||
specifier: ^18.7.10
|
||||
version: 18.16.9
|
||||
@@ -150,6 +162,9 @@ devDependencies:
|
||||
'@types/react-dom':
|
||||
specifier: ^18.0.6
|
||||
version: 18.2.4
|
||||
'@types/react-katex':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
'@types/utf8':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
@@ -1632,6 +1647,10 @@ packages:
|
||||
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
|
||||
dev: true
|
||||
|
||||
/@types/katex@0.16.0:
|
||||
resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==}
|
||||
dev: true
|
||||
|
||||
/@types/lodash.memoize@4.1.7:
|
||||
resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==}
|
||||
dependencies:
|
||||
@@ -1684,6 +1703,12 @@ packages:
|
||||
'@types/react': 17.0.59
|
||||
dev: false
|
||||
|
||||
/@types/react-katex@3.0.0:
|
||||
resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.6
|
||||
dev: true
|
||||
|
||||
/@types/react-redux@7.1.25:
|
||||
resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==}
|
||||
dependencies:
|
||||
@@ -2280,6 +2305,11 @@ packages:
|
||||
engines: {node: '>= 6'}
|
||||
dev: true
|
||||
|
||||
/commander@8.3.0:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
dev: false
|
||||
|
||||
/compute-scroll-into-view@1.0.20:
|
||||
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
||||
dev: false
|
||||
@@ -2437,6 +2467,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
|
||||
dev: false
|
||||
|
||||
/emoji-regex@10.2.1:
|
||||
resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==}
|
||||
dev: false
|
||||
|
||||
/emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
dev: false
|
||||
@@ -3799,6 +3833,13 @@ packages:
|
||||
object.assign: 4.1.4
|
||||
dev: true
|
||||
|
||||
/katex@0.16.7:
|
||||
resolution: {integrity: sha512-Xk9C6oGKRwJTfqfIbtr0Kes9OSv6IFsuhFGc7tW4urlpMJtuh+7YhzU6YEG9n8gmWKcMAFzkp7nr+r69kV0zrA==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
dev: false
|
||||
|
||||
/kleur@3.0.3:
|
||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4483,6 +4524,17 @@ packages:
|
||||
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
||||
dev: false
|
||||
|
||||
/react-katex@3.0.1(prop-types@15.8.1)(react@18.2.0):
|
||||
resolution: {integrity: sha512-wIUW1fU5dHlkKvq4POfDkHruQsYp3fM8xNb/jnc8dnQ+nNCnaj0sx5pw7E6UyuEdLRyFKK0HZjmXBo+AtXXy0A==}
|
||||
peerDependencies:
|
||||
prop-types: ^15.8.1
|
||||
react: '>=15.3.2 <=18'
|
||||
dependencies:
|
||||
katex: 0.16.7
|
||||
prop-types: 15.8.1
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
|
||||
peerDependencies:
|
||||
|
||||
+160
-150
@@ -105,7 +105,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
|
||||
[[package]]
|
||||
name = "appflowy-integrate"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@@ -145,9 +145,9 @@ checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.3"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8868f09ff8cea88b079da74ae569d9b8c62a23c68c746240b704ee6f7525c89c"
|
||||
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
@@ -168,7 +168,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -179,7 +179,7 @@ checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -203,7 +203,7 @@ dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -600,7 +600,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -827,7 +827,7 @@ checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -837,7 +837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"toml 0.7.4",
|
||||
"toml 0.7.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -886,9 +886,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.2"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e70d3ad08698a0568b0562f22710fe6bfc1f4a61a367c77d0398c562eadd453a"
|
||||
checksum = "215c0072ecc28f92eeb0eea38ba63ddfcb65c2828c46311d646f1a3ff5f9841c"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
@@ -935,7 +935,7 @@ checksum = "cf9cc2b23599e6d7479755f3594285efb3f74a1bdca7a7374948bc831e23a552"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build 0.1.0",
|
||||
"phf 0.11.1",
|
||||
"phf 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -956,8 +956,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9998fb9f7e9b2111641485bf8beb32f92945f97f92a3d061f744cfef335f751"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf 0.11.1",
|
||||
"phf_codegen 0.11.1",
|
||||
"phf 0.11.2",
|
||||
"phf_codegen 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1030,7 +1030,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -1048,7 +1048,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-client-ws"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"collab-sync",
|
||||
@@ -1066,7 +1066,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-database"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1104,7 +1104,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-document"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"collab",
|
||||
@@ -1122,7 +1122,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-folder"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1142,7 +1142,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-persistence"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"chrono",
|
||||
@@ -1162,7 +1162,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-plugins"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1193,7 +1193,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collab-sync"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=06e942#06e942cb6433c94b5ecfe1d431b64bba625fc09c"
|
||||
source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"collab",
|
||||
@@ -1292,21 +1292,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics-types"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a68b68b3446082644c91ac778bf50cd4104bfb002b5a6a7c44cca5a2c70788b"
|
||||
checksum = "2bb142d41022986c1d8ff29103a1411c8a3dfad3552f87a4f8dc50d61d4f4e33"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.7"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58"
|
||||
checksum = "03e69e28e9f7f77debdedbaafa2866e1de9ba56df55a8bd7cfc724c25a09987c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -1397,7 +1396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1452,7 +1451,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1463,7 +1462,7 @@ checksum = "29a358ff9f12ec09c3e61fef9b5a9902623a695a46a917b07f269bff1445611a"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1629,7 +1628,7 @@ checksum = "80663502655af01a2902dff3f06869330782267924bf1788410b74edcd93770a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"rustc_version",
|
||||
"toml 0.7.4",
|
||||
"toml 0.7.5",
|
||||
"vswhom",
|
||||
"winreg 0.11.0",
|
||||
]
|
||||
@@ -1655,6 +1654,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.1"
|
||||
@@ -1868,7 +1873,7 @@ dependencies = [
|
||||
"flowy-notification",
|
||||
"flowy-task",
|
||||
"futures",
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"lazy_static",
|
||||
"lib-dispatch",
|
||||
"lib-infra",
|
||||
@@ -1916,7 +1921,7 @@ dependencies = [
|
||||
"flowy-derive",
|
||||
"flowy-error",
|
||||
"flowy-notification",
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"lib-dispatch",
|
||||
"nanoid",
|
||||
"parking_lot 0.12.1",
|
||||
@@ -2213,7 +2218,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2294,7 +2299,7 @@ dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2311,7 +2316,7 @@ dependencies = [
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"pkg-config",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2325,7 +2330,7 @@ dependencies = [
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2337,15 +2342,15 @@ dependencies = [
|
||||
"gdk-sys",
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.7.4"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3e123d9ae7c02966b4d892e550bdc32164f05853cd40ab570650ad600596a8a"
|
||||
checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -2400,9 +2405,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.27.2"
|
||||
version = "0.27.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad0a93d233ebf96623465aad4046a8d3aa4da22d4f4beba5388838c8a434bbb4"
|
||||
checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e"
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
@@ -2430,7 +2435,7 @@ dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -2476,7 +2481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2517,7 +2522,7 @@ checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2558,7 +2563,7 @@ dependencies = [
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2577,9 +2582,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.19"
|
||||
version = "0.3.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d357c7ae988e7d2182f7d7871d0b963962420b0678b0997ce7de72001aeab782"
|
||||
checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
@@ -2587,7 +2592,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http",
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -2612,6 +2617,12 @@ dependencies = [
|
||||
"ahash 0.8.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.3.3"
|
||||
@@ -2732,9 +2743,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.26"
|
||||
version = "0.14.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab302d72a6f11a3b910431ff93aae7e773078c769f0a3ef15fb9ec692ed147d4"
|
||||
checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
@@ -2834,17 +2845,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
|
||||
dependencies = [
|
||||
"matches",
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.4.0"
|
||||
@@ -2896,6 +2896,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.12.0"
|
||||
@@ -2927,9 +2937,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.7.2"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f"
|
||||
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
@@ -3145,9 +3155,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lib0"
|
||||
version = "0.16.5"
|
||||
version = "0.16.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf23122cb1c970b77ea6030eac5e328669415b65d2ab245c99bfb110f9d62dc"
|
||||
checksum = "49d27ae71668a38ad135d463703ce0c5d9cf5a29f9a02add7a0dac6ebb523196"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3156,9 +3166,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.146"
|
||||
version = "0.2.147"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b"
|
||||
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -3631,9 +3641,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.54"
|
||||
version = "0.10.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b3f656a17a6cbc115b5c7a40c616947d213ba182135b014d6051b73ab6f019"
|
||||
checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
@@ -3652,7 +3662,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3663,9 +3673,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.88"
|
||||
version = "0.9.90"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2ce0f250f34a308dcfdbb351f511359857d4ed2134ba715a4eadd46e1ffd617"
|
||||
checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3717,7 +3727,7 @@ dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3797,9 +3807,9 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e68e84bfb01f0507134eac1e9b410a12ba379d064eab48c50ba4ce329a527b70"
|
||||
checksum = "f73935e4d55e2abf7f130186537b19e7a4abc886a0252380b59248af473a3fc9"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
"ucd-trie",
|
||||
@@ -3807,9 +3817,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b79d4c71c865a25a4322296122e3924d30bc8ee0834c8bfc8b95f7f054afbfb"
|
||||
checksum = "aef623c9bbfa0eedf5a0efba11a5ee83209c326653ca31ff019bec3a95bfff2b"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -3817,22 +3827,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c435bf1076437b851ebc8edc3a18442796b30f1728ffea6262d59bbe28b077e"
|
||||
checksum = "b3e8cba4ec22bada7fc55ffe51e2deb6a0e0db2d0b7ab0b103acc80d2510c190"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "745a452f8eb71e39ffd8ee32b3c5f51d03845f99786fa9b68db6ff509c505411"
|
||||
checksum = "a01f71cb40bd8bb94232df14b946909e14660e33fc05db3e50ae2a82d7ea0ca0"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"pest",
|
||||
@@ -3863,11 +3873,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928c6535de93548188ef63bb7c4036bd415cd8f36ad25af44b9789b2ee72a48c"
|
||||
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.1",
|
||||
"phf_shared 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3892,12 +3902,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a56ac890c5e3ca598bbdeaa99964edb5b0258a583a9eb6ef4e89fc85d9224770"
|
||||
checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.1",
|
||||
"phf_shared 0.11.1",
|
||||
"phf_generator 0.11.2",
|
||||
"phf_shared 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3922,11 +3932,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1181c94580fa345f50f19d738aaa39c0ed30a600d95cb2d3e23f94266f14fbf"
|
||||
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.1",
|
||||
"phf_shared 0.11.2",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
@@ -3979,9 +3989,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1fb5f6f826b772a8d4c0394209441e7d37cbbb967ae9c7e0e8134365c9ee676"
|
||||
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -4003,7 +4013,7 @@ checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4031,7 +4041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
|
||||
dependencies = [
|
||||
"base64 0.21.2",
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"line-wrap",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@@ -4074,12 +4084,12 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.6"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b69d39aab54d069e7f2fe8cb970493e7834601ca2d8c65fd7bbd183578080d1"
|
||||
checksum = "9825a04601d60621feed79c4e6b56d65db77cdca55cef43b46b0de1096d1c282"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4133,9 +4143,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.60"
|
||||
version = "1.0.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406"
|
||||
checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -4582,11 +4592,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.29.1"
|
||||
version = "1.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26bd36b60561ee1fb5ec2817f198b6fd09fa571c897a5e86d1487cfc2b096dfc"
|
||||
checksum = "d0446843641c69436765a35a5a77088e28c2e6a12da93e84aa3ab1cd4aa5a042"
|
||||
dependencies = [
|
||||
"arrayvec 0.7.3",
|
||||
"arrayvec 0.7.4",
|
||||
"borsh",
|
||||
"bytecheck",
|
||||
"byteorder",
|
||||
@@ -4600,9 +4610,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal_macros"
|
||||
version = "1.29.1"
|
||||
version = "1.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e773fd3da1ed42472fdf3cfdb4972948a555bc3d73f5e0bdb99d17e7b54c687"
|
||||
checksum = "7ca5c398d85f83b9a44de754a2048625a8c5eafcf070da7b8f116b685e2f6608"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"rust_decimal",
|
||||
@@ -4861,14 +4871,14 @@ checksum = "d9735b638ccc51c28bf6914d90a2e9725b377144fc612c49a611fddd1b631d68"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.96"
|
||||
version = "1.0.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1"
|
||||
checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3"
|
||||
dependencies = [
|
||||
"itoa 1.0.6",
|
||||
"ryu",
|
||||
@@ -4883,14 +4893,14 @@ checksum = "bcec881020c684085e55a25f7fd888954d56609ef363479dc5a1305eb0d40cab"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93107647184f6027e3b7dcb2e11034cf95ffa1e3a682c67951963ac69c1c007d"
|
||||
checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -4916,7 +4926,7 @@ dependencies = [
|
||||
"base64 0.21.2",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap",
|
||||
"indexmap 1.9.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
@@ -4932,7 +4942,7 @@ dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4999,9 +5009,9 @@ checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.6"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0"
|
||||
checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
@@ -5245,9 +5255,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.18"
|
||||
version = "2.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
|
||||
checksum = "2efbeae7acf4eabd6bcdcbd11c92f45231ddda7539edc7806bd1a04a03b24616"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5269,14 +5279,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.1.0"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5fa6fb9ee296c0dc2df41a656ca7948546d061958115ddb0bcaae43ad0d17d2"
|
||||
checksum = "30c2de8a4d8f4b823d634affc9cd2a74ec98c53a756f317e529a48046cbf71f3"
|
||||
dependencies = [
|
||||
"cfg-expr 0.15.2",
|
||||
"cfg-expr 0.15.3",
|
||||
"heck 0.4.1",
|
||||
"pkg-config",
|
||||
"toml 0.7.4",
|
||||
"toml 0.7.5",
|
||||
"version-compare 0.1.1",
|
||||
]
|
||||
|
||||
@@ -5357,15 +5367,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.7"
|
||||
version = "0.12.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd1ba337640d60c3e96bc6f0638a939b9c9a7f2c316a1598c279828b3d1dc8c5"
|
||||
checksum = "1b1c7f239eb94671427157bd93b3694320f3668d4e1eff08c7285366fd777fac"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc35893c7e08d9564a9206bd52182dce031b0d5132dc946b3e166e00d03f8cfe"
|
||||
checksum = "7fbe522898e35407a8e60dc3870f7579fea2fc262a6a6072eccdd37ae1e1d91e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cocoa",
|
||||
@@ -5542,7 +5552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb"
|
||||
dependencies = [
|
||||
"embed-resource",
|
||||
"toml 0.7.4",
|
||||
"toml 0.7.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5626,7 +5636,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5729,7 +5739,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5836,9 +5846,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.7.4"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6135d499e69981f9ff0ef2167955a5333c35e36f6937d382974566b3d5b94ec"
|
||||
checksum = "1ebafdf5ad1220cb59e7d17cf4d2c72015297b75b19a10472f99b89225089240"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
@@ -5848,20 +5858,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a76a9312f5ba4c2dec6b9161fdf25d87ad8a09256ccea5a556fef03c706a10f"
|
||||
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.10"
|
||||
version = "0.19.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2380d56e8670370eee6566b0bfd4265f65b3f432e8c6d85623f728d4fa31f739"
|
||||
checksum = "266f016b7f039eec8a1a80dfe6156b633d208b9fccca5e4db1d6775b0c4e34a7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.0.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
@@ -5922,13 +5932,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.24"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f57e3ca2a01450b1a921183a9c9cbfda207fd822cef4ccb00a65402cbba7a74"
|
||||
checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6189,7 +6199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna 0.4.0",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
@@ -6218,11 +6228,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "validator"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32ad5bf234c7d3ad1042e5252b7eddb2c4669ee23f32c7dd0e9b7705f07ef591"
|
||||
checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd"
|
||||
dependencies = [
|
||||
"idna 0.2.3",
|
||||
"idna",
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -6345,7 +6355,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -6379,7 +6389,7 @@ checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.18",
|
||||
"syn 2.0.22",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -6444,7 +6454,7 @@ dependencies = [
|
||||
"pango-sys",
|
||||
"pkg-config",
|
||||
"soup2-sys",
|
||||
"system-deps 6.1.0",
|
||||
"system-deps 6.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6887,9 +6897,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yrs"
|
||||
version = "0.16.5"
|
||||
version = "0.16.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c2aef2bf89b4f7c003f9c73f1c8097427ca32e1d006443f3f607f11e79a797b"
|
||||
checksum = "04e5192da97bd1621497ddf66b42475fb0cc44b6ebcf64510f0d3827c0b15cad"
|
||||
dependencies = [
|
||||
"atomic_refcell",
|
||||
"lib0",
|
||||
|
||||
@@ -34,12 +34,12 @@ default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[patch.crates-io]
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
|
||||
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
|
||||
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "06e942" }
|
||||
collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
|
||||
collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
|
||||
collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
|
||||
collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
|
||||
collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
|
||||
appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "d1882d" }
|
||||
|
||||
#collab = { path = "../../AppFlowy-Collab/collab" }
|
||||
#collab-folder = { path = "../../AppFlowy-Collab/collab-folder" }
|
||||
|
||||
+55
-22
@@ -11,8 +11,13 @@ import {
|
||||
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||
import { isApple } from '$app/utils/env';
|
||||
|
||||
const onFrameTime = 1000 / 60;
|
||||
|
||||
export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||
const timeStampRef = useRef(0);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onKeyDown = useRangeKeyDown();
|
||||
const { docId } = useSubscribeDocument();
|
||||
@@ -36,10 +41,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||
useEffect(() => {
|
||||
if (!range) return;
|
||||
const { anchor, focus } = range;
|
||||
|
||||
if (!anchor || !focus) {
|
||||
container.classList.remove('caret-transparent');
|
||||
return;
|
||||
}
|
||||
|
||||
// if the focus block is different from the anchor block, we need to set the caret transparent
|
||||
if (focus.id !== anchor.id) {
|
||||
container.classList.add('caret-transparent');
|
||||
@@ -50,18 +57,21 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||
|
||||
useEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
|
||||
if (!anchor || !focus) return;
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) return;
|
||||
// update focus point
|
||||
dispatch(
|
||||
rangeActions.setFocusPoint({
|
||||
...focus,
|
||||
focusPoint: focus,
|
||||
docId,
|
||||
})
|
||||
);
|
||||
|
||||
const focused = isFocused(focus.id);
|
||||
|
||||
// if the focus block is not focused, we need to set the cursor position
|
||||
if (!focused) {
|
||||
// if the focus block is the same as the anchor block, we just update the anchor's range
|
||||
@@ -70,14 +80,17 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||
anchor.point.x - container.scrollLeft,
|
||||
anchor.point.y - container.scrollTop
|
||||
);
|
||||
|
||||
if (!range) return;
|
||||
const selection = window.getSelection();
|
||||
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
return;
|
||||
}
|
||||
|
||||
const node = getNodeTextBoxByBlockId(focus.id);
|
||||
|
||||
if (!node) return;
|
||||
// if the selection is forward, we set the cursor position to the start of the focus block
|
||||
if (isForward) {
|
||||
@@ -89,15 +102,33 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||
}
|
||||
}, [container, dispatch, docId, focus, isForward]);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
const handleDragEnd = useCallback(() => {
|
||||
timeStampRef.current = Date.now();
|
||||
if (!isDragging) return;
|
||||
setFocus(null);
|
||||
anchorRef.current = null;
|
||||
dispatch(
|
||||
rangeActions.setDragging({
|
||||
isDragging: false,
|
||||
docId,
|
||||
})
|
||||
);
|
||||
}, [docId, dispatch, isDragging]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
setForward(true);
|
||||
const isTapToClick = isApple() && timeStampRef.current > 0 && Date.now() - timeStampRef.current < onFrameTime;
|
||||
|
||||
// skip if the target is not a block
|
||||
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
||||
|
||||
if (!blockId) {
|
||||
dispatch(rangeActions.initialState(docId));
|
||||
return;
|
||||
}
|
||||
|
||||
setForward(true);
|
||||
|
||||
dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
|
||||
const startX = e.clientX + container.scrollLeft;
|
||||
const startY = e.clientY + container.scrollTop;
|
||||
@@ -113,18 +144,25 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||
anchorRef.current = {
|
||||
...anchor,
|
||||
};
|
||||
|
||||
// set the anchor point and focus point
|
||||
dispatch(rangeActions.setAnchorPoint({ ...anchor, docId }));
|
||||
dispatch(rangeActions.setFocusPoint({ ...anchor, docId }));
|
||||
dispatch(rangeActions.setAnchorPoint({ docId, anchorPoint: anchor }));
|
||||
dispatch(rangeActions.setFocusPoint({ docId, focusPoint: anchor }));
|
||||
|
||||
// This is a workaround for a bug in Safari where the mouseup event is not fired
|
||||
if (isTapToClick) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
rangeActions.setDragging({
|
||||
isDragging: true,
|
||||
docId,
|
||||
})
|
||||
);
|
||||
return;
|
||||
},
|
||||
[container.scrollLeft, container.scrollTop, dispatch, docId]
|
||||
[container.scrollLeft, container.scrollTop, dispatch, docId, handleDragEnd]
|
||||
);
|
||||
|
||||
const handleDraging = useCallback(
|
||||
@@ -133,12 +171,14 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||
|
||||
// skip if the target is not a block
|
||||
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
||||
|
||||
if (!blockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endX = e.clientX + container.scrollLeft;
|
||||
const endY = e.clientY + container.scrollTop;
|
||||
|
||||
// set the focus point
|
||||
setFocus({
|
||||
id: blockId,
|
||||
@@ -149,42 +189,35 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||
});
|
||||
// set forward
|
||||
const anchorId = anchorRef.current.id;
|
||||
|
||||
if (anchorId === blockId) {
|
||||
const startX = anchorRef.current.point.x;
|
||||
|
||||
setForward(startX < endX);
|
||||
return;
|
||||
}
|
||||
|
||||
const startY = anchorRef.current.point.y;
|
||||
|
||||
setForward(startY < endY);
|
||||
},
|
||||
[container.scrollLeft, container.scrollTop, isDragging]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (!isDragging) return;
|
||||
setFocus(null);
|
||||
anchorRef.current = null;
|
||||
dispatch(
|
||||
rangeActions.setDragging({
|
||||
isDragging: false,
|
||||
docId,
|
||||
})
|
||||
);
|
||||
}, [docId, dispatch, isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleDragStart);
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('mousemove', handleDraging);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
|
||||
container.addEventListener('keydown', onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleDragStart);
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('mousemove', handleDraging);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
|
||||
container.removeEventListener('keydown', onKeyDown, true);
|
||||
};
|
||||
}, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
|
||||
}, [handleMouseDown, handleDragEnd, handleDraging, container, onKeyDown]);
|
||||
|
||||
return null;
|
||||
}
|
||||
+34
-21
@@ -6,6 +6,8 @@ import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMe
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
import { selectOptionByUpDown } from '$app/utils/document/menu';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { BlockType } from '$app/interfaces/document';
|
||||
|
||||
enum BlockMenuOption {
|
||||
Duplicate = 'Duplicate',
|
||||
@@ -22,6 +24,7 @@ interface Option {
|
||||
|
||||
function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const { handleDelete, handleDuplicate } = useBlockMenu(id);
|
||||
const { node } = useSubscribeNode(id);
|
||||
const [subMenuOpened, setSubMenuOpened] = useState(false);
|
||||
const [hovered, setHovered] = useState<BlockMenuOption | null>(null);
|
||||
|
||||
@@ -39,29 +42,36 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const excludeTurnIntoBlock = useMemo(() => {
|
||||
return [BlockType.DividerBlock].includes(node.type);
|
||||
}, [node.type]);
|
||||
|
||||
const options: Option[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
operate: () => {
|
||||
return handleClick({ operate: handleDelete });
|
||||
() =>
|
||||
[
|
||||
{
|
||||
operate: () => {
|
||||
return handleClick({ operate: handleDelete });
|
||||
},
|
||||
title: 'Delete',
|
||||
icon: <Delete />,
|
||||
key: BlockMenuOption.Delete,
|
||||
},
|
||||
title: 'Delete',
|
||||
icon: <Delete />,
|
||||
key: BlockMenuOption.Delete,
|
||||
},
|
||||
{
|
||||
operate: () => {
|
||||
return handleClick({ operate: handleDuplicate });
|
||||
{
|
||||
operate: () => {
|
||||
return handleClick({ operate: handleDuplicate });
|
||||
},
|
||||
title: 'Duplicate',
|
||||
icon: <ContentCopy />,
|
||||
key: BlockMenuOption.Duplicate,
|
||||
},
|
||||
title: 'Duplicate',
|
||||
icon: <ContentCopy />,
|
||||
key: BlockMenuOption.Duplicate,
|
||||
},
|
||||
{
|
||||
key: BlockMenuOption.TurnInto,
|
||||
},
|
||||
],
|
||||
[handleClick, handleDelete, handleDuplicate]
|
||||
excludeTurnIntoBlock
|
||||
? null
|
||||
: {
|
||||
key: BlockMenuOption.TurnInto,
|
||||
},
|
||||
].filter((item) => item !== null) as Option[],
|
||||
[excludeTurnIntoBlock, handleClick, handleDelete, handleDuplicate]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
@@ -131,7 +141,10 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
}}
|
||||
menuOpened={subMenuOpened}
|
||||
isHovered={hovered === BlockMenuOption.TurnInto}
|
||||
onClose={() => setSubMenuOpened(false)}
|
||||
onClose={() => {
|
||||
setSubMenuOpened(false);
|
||||
onClose();
|
||||
}}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
|
||||
+18
-7
@@ -1,4 +1,4 @@
|
||||
import React, { MouseEvent, useRef } from 'react';
|
||||
import React, { MouseEvent, useEffect, useRef } from 'react';
|
||||
import { ArrowRight, Transform } from '@mui/icons-material';
|
||||
import MenuItem from '$app/components/document/_shared/MenuItem';
|
||||
import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
|
||||
@@ -17,8 +17,22 @@ function BlockMenuTurnInto({
|
||||
menuOpened: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const open = isHovered && menuOpened && Boolean(ref.current);
|
||||
const [anchorPosition, setAnchorPosition] = React.useState<{ top: number; left: number }>();
|
||||
const open = Boolean(anchorPosition);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovered && menuOpened) {
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
|
||||
if (!rect) return;
|
||||
setAnchorPosition({
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.left + rect.width,
|
||||
});
|
||||
} else {
|
||||
setAnchorPosition(undefined);
|
||||
}
|
||||
}, [isHovered, menuOpened]);
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
@@ -45,11 +59,8 @@ function BlockMenuTurnInto({
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
anchorEl={ref.current}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
anchorReference={'anchorPosition'}
|
||||
anchorPosition={anchorPosition}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'left',
|
||||
|
||||
+30
-18
@@ -40,6 +40,10 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
|
||||
top = headingBlockTopOffset[nodeData.level];
|
||||
}
|
||||
|
||||
if (node.type === BlockType.DividerBlock) {
|
||||
top = -3;
|
||||
}
|
||||
|
||||
setStyle({
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
@@ -99,39 +103,47 @@ function getNodeIdByPoint(x: number, y: number) {
|
||||
: null;
|
||||
}
|
||||
|
||||
const origin: {
|
||||
anchorOrigin: PopoverOrigin;
|
||||
transformOrigin: PopoverOrigin;
|
||||
} = {
|
||||
anchorOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
},
|
||||
transformOrigin: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
},
|
||||
const transformOrigin: PopoverOrigin = {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
};
|
||||
|
||||
export function usePopover() {
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
||||
const [anchorPosition, setAnchorPosition] = React.useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
}>();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setAnchorPosition(undefined);
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setAnchorEl(e.currentTarget);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
setAnchorPosition({
|
||||
top: rect.top + rect.height,
|
||||
left: rect.left + rect.width,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const open = Boolean(anchorPosition);
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
anchorEl,
|
||||
anchorPosition,
|
||||
onClose,
|
||||
open,
|
||||
handleOpen,
|
||||
...origin,
|
||||
anchorReference: 'anchorPosition' as const,
|
||||
transformOrigin,
|
||||
onMouseDown,
|
||||
disableRestoreFocus: true,
|
||||
disableAutoFocus: true,
|
||||
disableEnforceFocus: true,
|
||||
};
|
||||
}
|
||||
+9
-11
@@ -10,6 +10,8 @@ import ToolbarButton from './ToolbarButton';
|
||||
import { rectSelectionActions } from '$app_reducers/document/slice';
|
||||
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
|
||||
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
||||
|
||||
export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -17,13 +19,10 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
|
||||
|
||||
const { nodeId, style, ref } = useBlockSideToolbar({ container });
|
||||
const isDragging = useAppSelector(
|
||||
(state) => state.documentRange[docId]?.isDragging || state.documentRectSelection[docId]?.isDragging
|
||||
(state) => state[RANGE_NAME][docId]?.isDragging || state[RECT_RANGE_NAME][docId]?.isDragging
|
||||
);
|
||||
const { handleOpen, ...popoverProps } = usePopover();
|
||||
|
||||
// prevent popover from showing when anchorEl is not in DOM
|
||||
const showPopover = popoverProps.anchorEl ? document.contains(popoverProps.anchorEl) : true;
|
||||
|
||||
if (!nodeId || isDragging) return null;
|
||||
|
||||
return (
|
||||
@@ -64,11 +63,12 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!nodeId) return;
|
||||
dispatch(
|
||||
rectSelectionActions.setSelectionById({
|
||||
setRectSelectionThunk({
|
||||
docId,
|
||||
blockId: nodeId,
|
||||
selection: [nodeId],
|
||||
})
|
||||
);
|
||||
|
||||
handleOpen(e);
|
||||
}}
|
||||
>
|
||||
@@ -77,11 +77,9 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
|
||||
</div>
|
||||
</Portal>
|
||||
|
||||
{showPopover && (
|
||||
<Popover {...popoverProps}>
|
||||
<BlockMenu id={nodeId} onClose={popoverProps.onClose} />
|
||||
</Popover>
|
||||
)}
|
||||
<Popover {...popoverProps}>
|
||||
<BlockMenu id={nodeId} onClose={popoverProps.onClose} />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+14
-5
@@ -11,7 +11,10 @@ export function useBlockSlash() {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const { blockId, visible, slashText, hoverOption } = useSubscribeSlash();
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
|
||||
const [anchorPosition, setAnchorPosition] = React.useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (blockId && visible) {
|
||||
@@ -19,11 +22,17 @@ export function useBlockSlash() {
|
||||
const el = blockEl.querySelector(`[role="textbox"]`) as HTMLElement;
|
||||
|
||||
if (el) {
|
||||
setAnchorEl(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
setAnchorPosition({
|
||||
top: rect.top + rect.height,
|
||||
left: rect.left,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setAnchorEl(null);
|
||||
|
||||
setAnchorPosition(undefined);
|
||||
}, [blockId, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,11 +52,11 @@ export function useBlockSlash() {
|
||||
dispatch(slashCommandActions.closeSlashCommand(docId));
|
||||
}, [dispatch, docId]);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const open = Boolean(anchorPosition);
|
||||
|
||||
return {
|
||||
open,
|
||||
anchorEl,
|
||||
anchorPosition,
|
||||
onClose,
|
||||
blockId,
|
||||
searchText,
|
||||
|
||||
@@ -5,18 +5,15 @@ import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks';
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
|
||||
function BlockSlash({ container }: { container: HTMLDivElement }) {
|
||||
const { blockId, open, onClose, anchorEl, searchText, hoverOption } = useBlockSlash();
|
||||
const { blockId, open, onClose, anchorPosition, searchText, hoverOption } = useBlockSlash();
|
||||
|
||||
if (!blockId) return null;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
anchorReference={'anchorPosition'}
|
||||
anchorPosition={anchorPosition}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import KatexMath from '$app/components/document/_shared/KatexMath';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
|
||||
import { useEquationBlock } from '$app/components/document/EquationBlock/useEquationBlock';
|
||||
import { Functions } from '@mui/icons-material';
|
||||
|
||||
function EquationBlock({ node }: { node: NestedBlock<BlockType.EquationBlock> }) {
|
||||
const { ref, value, onChange, onOpenPopover, open, anchorPosition, onConfirm, onClosePopover } =
|
||||
useEquationBlock(node);
|
||||
|
||||
const formula = open ? value : node.data.formula;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={onOpenPopover}
|
||||
className={'flex min-h-[59px] cursor-pointer items-center justify-center overflow-hidden hover:bg-main-selector'}
|
||||
>
|
||||
{formula ? (
|
||||
<KatexMath latex={formula} />
|
||||
) : (
|
||||
<span className={'flex text-shade-2'}>
|
||||
<Functions />
|
||||
<span>Add a TeX equation</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Popover
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClose={onClosePopover}
|
||||
open={open}
|
||||
anchorReference={'anchorPosition'}
|
||||
anchorPosition={anchorPosition}
|
||||
>
|
||||
<EquationEditContent
|
||||
placeholder={'c = \\pm\\sqrt{a^2 + b^2\\text{ if }a\\neq 0\\text{ or }b\\neq 0}'}
|
||||
multiline={true}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EquationBlock;
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||
import { rectSelectionActions } from '$app_reducers/document/slice';
|
||||
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
||||
|
||||
export function useEquationBlock(node: NestedBlock<BlockType.EquationBlock>) {
|
||||
const { controller, docId } = useSubscribeDocument();
|
||||
const id = node.id;
|
||||
const dispatch = useAppDispatch();
|
||||
const formula = node.data.formula;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [value, setValue] = useState(formula);
|
||||
|
||||
const [anchorPosition, setAnchorPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
}>();
|
||||
const open = Boolean(anchorPosition);
|
||||
|
||||
const onChange = useCallback((newVal: string) => {
|
||||
setValue(newVal);
|
||||
}, []);
|
||||
|
||||
const onOpenPopover = useCallback(() => {
|
||||
setValue(formula);
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
|
||||
if (!rect) return;
|
||||
setAnchorPosition({
|
||||
top: rect.top + rect.height,
|
||||
left: rect.left + rect.width / 2,
|
||||
});
|
||||
}, [formula]);
|
||||
|
||||
const onClosePopover = useCallback(() => {
|
||||
setAnchorPosition(undefined);
|
||||
dispatch(
|
||||
setRectSelectionThunk({
|
||||
docId,
|
||||
selection: [id],
|
||||
})
|
||||
);
|
||||
}, [dispatch, id, docId]);
|
||||
|
||||
const onConfirm = useCallback(async () => {
|
||||
await dispatch(
|
||||
updateNodeDataThunk({
|
||||
id,
|
||||
data: {
|
||||
formula: value,
|
||||
},
|
||||
controller,
|
||||
})
|
||||
);
|
||||
onClosePopover();
|
||||
}, [dispatch, id, value, controller, onClosePopover]);
|
||||
|
||||
return {
|
||||
open,
|
||||
ref,
|
||||
value,
|
||||
onChange,
|
||||
onOpenPopover,
|
||||
onClosePopover,
|
||||
onConfirm,
|
||||
anchorPosition,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import CalloutBlock from '$app/components/document/CalloutBlock';
|
||||
import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
|
||||
import CodeBlock from '$app/components/document/CodeBlock';
|
||||
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import EquationBlock from '$app/components/document/EquationBlock';
|
||||
|
||||
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { node, childIds, isSelected, ref } = useNode(id);
|
||||
@@ -26,38 +27,50 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
|
||||
case BlockType.TextBlock: {
|
||||
return <TextBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
|
||||
case BlockType.HeadingBlock: {
|
||||
return <HeadingBlock node={node} />;
|
||||
}
|
||||
|
||||
case BlockType.TodoListBlock: {
|
||||
return <TodoListBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
|
||||
case BlockType.QuoteBlock: {
|
||||
return <QuoteBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
|
||||
case BlockType.BulletedListBlock: {
|
||||
return <BulletedListBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
|
||||
case BlockType.NumberedListBlock: {
|
||||
return <NumberedListBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
|
||||
case BlockType.ToggleListBlock: {
|
||||
return <ToggleListBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
|
||||
case BlockType.DividerBlock: {
|
||||
return <DividerBlock />;
|
||||
}
|
||||
|
||||
case BlockType.CalloutBlock: {
|
||||
return <CalloutBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
|
||||
case BlockType.CodeBlock:
|
||||
return <CodeBlock node={node} />;
|
||||
case BlockType.EquationBlock:
|
||||
return <EquationBlock node={node} />;
|
||||
default:
|
||||
return <UnSupportedBlock />;
|
||||
}
|
||||
}, [node, childIds]);
|
||||
|
||||
const className = props.className ? ` ${props.className}` : '';
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy
|
||||
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
|
||||
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
|
||||
import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
|
||||
import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover';
|
||||
|
||||
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
||||
useCopy(container);
|
||||
@@ -19,6 +20,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
|
||||
<BlockSelection container={container} />
|
||||
<BlockSlash container={container} />
|
||||
<LinkEditPopover />
|
||||
<TemporaryPopover />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { TextAction, TextActionMenuProps } from '$app/interfaces/document';
|
||||
|
||||
export const defaultTextActionItems = [
|
||||
TextAction.Turn,
|
||||
TextAction.Link,
|
||||
TextAction.Bold,
|
||||
TextAction.Italic,
|
||||
TextAction.Underline,
|
||||
TextAction.Strikethrough,
|
||||
TextAction.Code,
|
||||
TextAction.Equation,
|
||||
];
|
||||
const groupKeys = {
|
||||
comment: [],
|
||||
format: [
|
||||
TextAction.Bold,
|
||||
TextAction.Italic,
|
||||
TextAction.Underline,
|
||||
TextAction.Strikethrough,
|
||||
TextAction.Code,
|
||||
TextAction.Equation,
|
||||
],
|
||||
link: [TextAction.Link],
|
||||
turn: [TextAction.Turn],
|
||||
};
|
||||
|
||||
export const multiLineTextActionProps: TextActionMenuProps = {
|
||||
customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
|
||||
};
|
||||
export const multiLineTextActionGroups = [groupKeys.format];
|
||||
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
|
||||
+4
-2
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { calcToolbarPosition } from '$app/utils/document/toolbar';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { getNode } from '$app/utils/document/node';
|
||||
import { debounce } from '$app/utils/tool';
|
||||
import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||
@@ -15,9 +14,11 @@ export function useMenuStyle(container: HTMLDivElement) {
|
||||
|
||||
const reCalculatePosition = useCallback(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el || !id) return;
|
||||
|
||||
const node = getNode(id);
|
||||
|
||||
if (!node) return;
|
||||
const position = calcToolbarPosition(el, node, container);
|
||||
|
||||
@@ -50,6 +51,7 @@ export function useMenuStyle(container: HTMLDivElement) {
|
||||
setIsScrolling(true);
|
||||
debounceScrollEnd();
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
debounceScrollEnd.cancel();
|
||||
|
||||
+40
-6
@@ -1,8 +1,11 @@
|
||||
import { useMenuStyle } from './index.hooks';
|
||||
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
|
||||
import BlockPortal from '$app/components/document/BlockPortal';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||
import { debounce } from '$app/utils/tool';
|
||||
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
|
||||
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
||||
const { ref, id } = useMenuStyle(container);
|
||||
@@ -27,26 +30,57 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
||||
</BlockPortal>
|
||||
);
|
||||
};
|
||||
|
||||
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
|
||||
const range = useSubscribeRanges();
|
||||
const { docId } = useSubscribeDocument();
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const debounceShow = useMemo(() => {
|
||||
return debounce(() => {
|
||||
setShow(true);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const canShow = useMemo(() => {
|
||||
const { isDragging, focus, anchor, ranges, caret } = range;
|
||||
|
||||
// don't show if dragging
|
||||
if (isDragging) return false;
|
||||
// don't show if no focus or anchor
|
||||
if (!caret) return false;
|
||||
const isSameLine = anchor?.id === focus?.id;
|
||||
if (!anchor || !focus) return false;
|
||||
|
||||
const anchorNode = getBlock(docId, anchor.id);
|
||||
const focusNode = getBlock(docId, focus.id);
|
||||
|
||||
// include document title
|
||||
if (!anchorNode.parent || !focusNode.parent) return false;
|
||||
|
||||
const isSameLine = anchor.id === focus.id;
|
||||
|
||||
// show toolbar if range has multiple nodes
|
||||
if (!isSameLine) return true;
|
||||
const caretRange = ranges[caret.id];
|
||||
// don't show if no caret range
|
||||
|
||||
const caretRange = ranges?.[caret.id];
|
||||
|
||||
if (!caretRange) return false;
|
||||
|
||||
// show toolbar if range is not collapsed
|
||||
return caretRange.length > 0;
|
||||
}, [range]);
|
||||
}, [docId, range]);
|
||||
|
||||
if (!canShow) return null;
|
||||
useEffect(() => {
|
||||
if (!canShow) {
|
||||
debounceShow.cancel();
|
||||
setShow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceShow();
|
||||
}, [canShow, debounceShow]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return <TextActionComponent container={container} />;
|
||||
};
|
||||
|
||||
+79
-25
@@ -1,37 +1,36 @@
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import FormatIcon from './FormatIcon';
|
||||
import React, { useCallback, useEffect, useMemo, useContext } from 'react';
|
||||
import { TextAction } from '$app/interfaces/document';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { TemporaryType, TextAction } from '$app/interfaces/document';
|
||||
import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
|
||||
import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
|
||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { RANGE_NAME } from '$app/constants/document/name';
|
||||
import { createTemporary } from '$app_reducers/document/async-actions/temporary';
|
||||
import {
|
||||
CodeOutlined,
|
||||
FormatBold,
|
||||
FormatItalic,
|
||||
FormatUnderlined,
|
||||
Functions,
|
||||
StrikethroughSOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import LinkIcon from '@mui/icons-material/AddLink';
|
||||
|
||||
export const iconSize = { width: 18, height: 18 };
|
||||
|
||||
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { docId, controller } = useSubscribeDocument();
|
||||
|
||||
const focusId = useAppSelector((state) => state.documentRange[docId]?.focus?.id || '');
|
||||
const focusId = useAppSelector((state) => state[RANGE_NAME][docId]?.focus?.id || '');
|
||||
const { node: focusNode } = useSubscribeNode(focusId);
|
||||
|
||||
const [isActive, setIsActive] = React.useState(false);
|
||||
const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
|
||||
|
||||
const formatTooltips: Record<string, string> = useMemo(
|
||||
() => ({
|
||||
[TextAction.Bold]: 'Bold',
|
||||
[TextAction.Italic]: 'Italic',
|
||||
[TextAction.Underline]: 'Underline',
|
||||
[TextAction.Strikethrough]: 'Strike through',
|
||||
[TextAction.Code]: 'Mark as Code',
|
||||
[TextAction.Link]: 'Add Link',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const isFormatActive = useCallback(async () => {
|
||||
if (!focusNode) return false;
|
||||
const { payload: isActive } = await dispatch(
|
||||
@@ -40,6 +39,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
||||
docId,
|
||||
})
|
||||
);
|
||||
|
||||
return !!isActive;
|
||||
}, [docId, dispatch, format, focusNode]);
|
||||
|
||||
@@ -65,6 +65,34 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
||||
);
|
||||
}, [dispatch, docId]);
|
||||
|
||||
const addTemporaryInput = useCallback(
|
||||
(type: TemporaryType) => {
|
||||
dispatch(createTemporary({ type, docId }));
|
||||
},
|
||||
[dispatch, docId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const isActive = await isFormatActive();
|
||||
|
||||
setIsActive(isActive);
|
||||
})();
|
||||
}, [isFormatActive]);
|
||||
|
||||
const formatTooltips: Record<string, string> = useMemo(
|
||||
() => ({
|
||||
[TextAction.Bold]: 'Bold',
|
||||
[TextAction.Italic]: 'Italic',
|
||||
[TextAction.Underline]: 'Underline',
|
||||
[TextAction.Strikethrough]: 'Strike through',
|
||||
[TextAction.Code]: 'Mark as Code',
|
||||
[TextAction.Link]: 'Add Link',
|
||||
[TextAction.Equation]: 'Create equation',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const formatClick = useCallback(
|
||||
(format: TextAction) => {
|
||||
switch (format) {
|
||||
@@ -76,22 +104,48 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
||||
return toggleFormat(format);
|
||||
case TextAction.Link:
|
||||
return addLink();
|
||||
case TextAction.Equation:
|
||||
return addTemporaryInput(TemporaryType.Equation);
|
||||
}
|
||||
},
|
||||
[addLink, toggleFormat]
|
||||
[addLink, addTemporaryInput, toggleFormat]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const isActive = await isFormatActive();
|
||||
setIsActive(isActive);
|
||||
})();
|
||||
}, [isFormatActive]);
|
||||
const formatIcon = useMemo(() => {
|
||||
switch (icon) {
|
||||
case TextAction.Bold:
|
||||
return <FormatBold sx={iconSize} />;
|
||||
case TextAction.Underline:
|
||||
return <FormatUnderlined sx={iconSize} />;
|
||||
case TextAction.Italic:
|
||||
return <FormatItalic sx={iconSize} />;
|
||||
case TextAction.Code:
|
||||
return <CodeOutlined sx={iconSize} />;
|
||||
case TextAction.Strikethrough:
|
||||
return <StrikethroughSOutlined sx={iconSize} />;
|
||||
case TextAction.Link:
|
||||
return (
|
||||
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
|
||||
<LinkIcon
|
||||
sx={{
|
||||
fontSize: '1.2rem',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
<div className={'underline'}>Link</div>
|
||||
</div>
|
||||
);
|
||||
case TextAction.Equation:
|
||||
return <Functions sx={iconSize} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [icon]);
|
||||
|
||||
return (
|
||||
<MenuTooltip title={formatTooltips[format]}>
|
||||
<IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
|
||||
<FormatIcon icon={icon} />
|
||||
{formatIcon}
|
||||
</IconButton>
|
||||
</MenuTooltip>
|
||||
);
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
|
||||
import { TextAction } from '$app/interfaces/document';
|
||||
import LinkIcon from '@mui/icons-material/AddLink';
|
||||
export const iconSize = { width: 18, height: 18 };
|
||||
|
||||
export default function FormatIcon({ icon }: { icon: string }) {
|
||||
switch (icon) {
|
||||
case TextAction.Bold:
|
||||
return <FormatBold sx={iconSize} />;
|
||||
case TextAction.Underline:
|
||||
return <FormatUnderlined sx={iconSize} />;
|
||||
case TextAction.Italic:
|
||||
return <FormatItalic sx={iconSize} />;
|
||||
case TextAction.Code:
|
||||
return <CodeOutlined sx={iconSize} />;
|
||||
case TextAction.Strikethrough:
|
||||
return <StrikethroughSOutlined sx={iconSize} />;
|
||||
case TextAction.Link:
|
||||
return (
|
||||
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
|
||||
<LinkIcon
|
||||
sx={{
|
||||
fontSize: '1.2rem',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
<div className={'underline'}>Link</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+16
-11
@@ -6,18 +6,26 @@ import MenuTooltip from './MenuTooltip';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
|
||||
function TurnIntoSelect({ id }: { id: string }) {
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
||||
const [anchorPosition, setAnchorPosition] = React.useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
}>();
|
||||
|
||||
const { node } = useSubscribeNode(id);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
setAnchorPosition({
|
||||
top: rect.top + rect.height + 5,
|
||||
left: rect.left,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setAnchorPosition(undefined);
|
||||
}, []);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const open = Boolean(anchorPosition);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -33,14 +41,11 @@ function TurnIntoSelect({ id }: { id: string }) {
|
||||
id={id}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
anchorReference={'anchorPosition'}
|
||||
anchorPosition={anchorPosition}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'center',
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
+9
-12
@@ -1,14 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { BlockType, TextAction } from '$app/interfaces/document';
|
||||
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||
import {
|
||||
blockConfig,
|
||||
defaultTextActionProps,
|
||||
defaultTextActionItems,
|
||||
multiLineTextActionGroups,
|
||||
multiLineTextActionProps,
|
||||
textActionGroups,
|
||||
} from '$app/constants/document/config';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { TextAction } from '$app/interfaces/document';
|
||||
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||
} from '$app/components/document/TextActionMenu/config';
|
||||
|
||||
export function useTextActionMenu() {
|
||||
const range = useSubscribeRanges();
|
||||
@@ -22,12 +21,9 @@ export function useTextActionMenu() {
|
||||
const items = useMemo(() => {
|
||||
if (!node) return [];
|
||||
if (isSingleLine) {
|
||||
const config = blockConfig[node.type];
|
||||
const { customItems, excludeItems } = {
|
||||
...defaultTextActionProps,
|
||||
...config.textActionMenuProps,
|
||||
};
|
||||
return customItems?.filter((item) => !excludeItems?.includes(item)) || [];
|
||||
const excludeItems = node.type === BlockType.CodeBlock ? [TextAction.Code] : [];
|
||||
|
||||
return defaultTextActionItems?.filter((item) => !excludeItems?.includes(item)) || [];
|
||||
} else {
|
||||
return multiLineTextActionProps.customItems || [];
|
||||
}
|
||||
@@ -36,6 +32,7 @@ export function useTextActionMenu() {
|
||||
// the groups have default items, so we need to filter the items if this node has excluded items
|
||||
const groupItems: TextAction[][] = useMemo(() => {
|
||||
const groups = node ? textActionGroups : multiLineTextActionGroups;
|
||||
|
||||
return groups.map((group) => {
|
||||
return group.filter((item) => items.includes(item));
|
||||
});
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ function TextActionMenuList() {
|
||||
case TextAction.Underline:
|
||||
case TextAction.Strikethrough:
|
||||
case TextAction.Code:
|
||||
case TextAction.Equation:
|
||||
return <FormatButton format={action} icon={action} />;
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
import { BlockType } from '$app/interfaces/document';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
export const turnIntoConfig: Record<
|
||||
BlockType,
|
||||
{
|
||||
type: BlockType;
|
||||
markdownRegexp: RegExp;
|
||||
triggerKey: string;
|
||||
}
|
||||
> = {
|
||||
[BlockType.HeadingBlock]: {
|
||||
type: BlockType.HeadingBlock,
|
||||
markdownRegexp: /^(#{1,3})(\s)+$/,
|
||||
triggerKey: Keyboard.keys.SPACE,
|
||||
},
|
||||
[BlockType.TodoListBlock]: {
|
||||
type: BlockType.TodoListBlock,
|
||||
markdownRegexp: /^((-)?\[(x|\s)?\])(\s)+$/,
|
||||
triggerKey: Keyboard.keys.SPACE,
|
||||
},
|
||||
[BlockType.BulletedListBlock]: {
|
||||
type: BlockType.BulletedListBlock,
|
||||
markdownRegexp: /^(\s*[-+*])(\s)+$/,
|
||||
triggerKey: Keyboard.keys.SPACE,
|
||||
},
|
||||
[BlockType.NumberedListBlock]: {
|
||||
type: BlockType.NumberedListBlock,
|
||||
markdownRegexp: /^(\s*[\d|a-zA-Z]+\.)(\s)+$/,
|
||||
triggerKey: Keyboard.keys.SPACE,
|
||||
},
|
||||
[BlockType.QuoteBlock]: {
|
||||
type: BlockType.QuoteBlock,
|
||||
markdownRegexp: /^("|“|”)(\s)+$/,
|
||||
triggerKey: Keyboard.keys.SPACE,
|
||||
},
|
||||
[BlockType.ToggleListBlock]: {
|
||||
type: BlockType.ToggleListBlock,
|
||||
markdownRegexp: /^(>)(\s)+$/,
|
||||
triggerKey: Keyboard.keys.SPACE,
|
||||
},
|
||||
[BlockType.CalloutBlock]: {
|
||||
type: BlockType.CalloutBlock,
|
||||
markdownRegexp: /^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/,
|
||||
triggerKey: Keyboard.keys.SPACE,
|
||||
},
|
||||
[BlockType.EquationBlock]: {
|
||||
type: BlockType.EquationBlock,
|
||||
markdownRegexp: /^(\${2})(\s)*(.+)(\s)*(\${2})$/,
|
||||
triggerKey: Keyboard.keys.DOLLAR,
|
||||
},
|
||||
[BlockType.DividerBlock]: {
|
||||
type: BlockType.DividerBlock,
|
||||
markdownRegexp: /^(-{3,})$/,
|
||||
triggerKey: Keyboard.keys.REDUCE,
|
||||
},
|
||||
[BlockType.CodeBlock]: {
|
||||
type: BlockType.CodeBlock,
|
||||
markdownRegexp: /^(```)$/,
|
||||
triggerKey: Keyboard.keys.BACK_QUOTE,
|
||||
},
|
||||
};
|
||||
+2
-2
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import {
|
||||
enterActionForBlockThunk,
|
||||
tabActionForBlockThunk,
|
||||
@@ -90,6 +89,7 @@ export function useKeyDown(id: string) {
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
|
||||
|
||||
filteredEvents.forEach((event) => {
|
||||
e.stopPropagation();
|
||||
event.handler(e);
|
||||
|
||||
+57
-14
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { BlockType } from '$app/interfaces/document';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
|
||||
@@ -10,9 +9,9 @@ import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection
|
||||
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { slashCommandActions } from '$app_reducers/document/slice';
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { turnIntoConfig } from './shortchut';
|
||||
|
||||
export function useTurnIntoBlockEvents(id: string) {
|
||||
const { docId, controller } = useSubscribeDocument();
|
||||
@@ -22,27 +21,34 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
|
||||
const getFlag = useCallback(() => {
|
||||
const range = rangeRef.current?.caret;
|
||||
|
||||
if (!range || range.id !== id) return;
|
||||
const node = getBlock(docId, id);
|
||||
const delta = new Delta(node.data.delta || []);
|
||||
|
||||
return getDeltaText(delta.slice(0, range.index));
|
||||
}, [docId, id, rangeRef]);
|
||||
|
||||
const getDeltaContent = useCallback(() => {
|
||||
const range = rangeRef.current?.caret;
|
||||
|
||||
if (!range || range.id !== id) return;
|
||||
const node = getBlock(docId, id);
|
||||
const delta = new Delta(node.data.delta || []);
|
||||
const content = delta.slice(range.index);
|
||||
|
||||
return new Delta(content);
|
||||
}, [docId, id, rangeRef]);
|
||||
|
||||
const canHandle = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
|
||||
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType) => {
|
||||
{
|
||||
const config = blockConfig[type];
|
||||
const triggerKey = event.key === turnIntoConfig[type].triggerKey ? event.key : undefined;
|
||||
|
||||
if (!triggerKey) return false;
|
||||
|
||||
const regex = turnIntoConfig[type].markdownRegexp;
|
||||
|
||||
const regex = config.markdownRegexps;
|
||||
// This error will be thrown if the block type is not in the config, and it will happen in development environment
|
||||
if (!regex) {
|
||||
throw new Error(`canHandle: block type ${type} is not supported`);
|
||||
@@ -53,10 +59,12 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
if (!isTrigger) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flag = getFlag();
|
||||
|
||||
if (!flag) return false;
|
||||
|
||||
return regex.some((r) => r.test(`${flag}${triggerKey}`));
|
||||
return regex.test(`${flag}${triggerKey}`);
|
||||
}
|
||||
},
|
||||
[getFlag]
|
||||
@@ -64,18 +72,35 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
|
||||
const getTurnIntoBlockDelta = useCallback(() => {
|
||||
const content = getDeltaContent();
|
||||
|
||||
if (!content) return;
|
||||
return {
|
||||
delta: content.ops,
|
||||
};
|
||||
}, [getDeltaContent]);
|
||||
|
||||
const getAttrs = useCallback(
|
||||
(type: BlockType) => {
|
||||
const flag = getFlag();
|
||||
|
||||
if (!flag) return;
|
||||
const triggerKey = turnIntoConfig[type].triggerKey;
|
||||
const regex = turnIntoConfig[type].markdownRegexp;
|
||||
const match = `${flag}${triggerKey}`.match(regex);
|
||||
|
||||
return match?.[3];
|
||||
},
|
||||
[getFlag]
|
||||
);
|
||||
|
||||
const spaceTriggerMap = useMemo(() => {
|
||||
return {
|
||||
[BlockType.HeadingBlock]: () => {
|
||||
const flag = getFlag();
|
||||
|
||||
if (!flag) return;
|
||||
const level = flag.match(/#/g)?.length;
|
||||
|
||||
if (!level || level > 3) return;
|
||||
return {
|
||||
level,
|
||||
@@ -84,6 +109,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
},
|
||||
[BlockType.TodoListBlock]: () => {
|
||||
const flag = getFlag();
|
||||
|
||||
if (!flag) return;
|
||||
|
||||
return {
|
||||
@@ -97,8 +123,10 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
[BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
|
||||
[BlockType.CalloutBlock]: () => {
|
||||
const flag = getFlag();
|
||||
|
||||
if (!flag) return;
|
||||
const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
|
||||
|
||||
if (!tag) return;
|
||||
const iconMap: Record<string, string> = {
|
||||
TIP: '💡',
|
||||
@@ -106,6 +134,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
WARNING: '⚠️',
|
||||
DANGER: '‼️',
|
||||
};
|
||||
|
||||
return {
|
||||
icon: iconMap[tag],
|
||||
...getTurnIntoBlockDelta(),
|
||||
@@ -117,24 +146,24 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
const turnIntoBlockEvents = useMemo(() => {
|
||||
const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
|
||||
const blockType = type as BlockType;
|
||||
const triggerKey = Keyboard.keys.Space;
|
||||
|
||||
return {
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType, triggerKey),
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType),
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (!controller) return;
|
||||
const data = getData();
|
||||
|
||||
if (!data) return;
|
||||
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...spaceTriggerEvents,
|
||||
{
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
canHandle(e, BlockType.DividerBlock, Keyboard.keys.Reduce),
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.DividerBlock),
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (!controller) return;
|
||||
@@ -153,8 +182,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
},
|
||||
},
|
||||
{
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
canHandle(e, BlockType.CodeBlock, Keyboard.keys.BackQuote),
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.CodeBlock),
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (!controller) return;
|
||||
@@ -163,13 +191,28 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
...defaultData,
|
||||
delta: getDeltaContent()?.ops as Op[],
|
||||
};
|
||||
|
||||
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
|
||||
},
|
||||
},
|
||||
{
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.EquationBlock),
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const formula = getAttrs(BlockType.EquationBlock);
|
||||
|
||||
const data = {
|
||||
formula,
|
||||
};
|
||||
|
||||
dispatch(turnToBlockThunk({ id, data, type: BlockType.EquationBlock, controller }));
|
||||
},
|
||||
},
|
||||
{
|
||||
// Here custom slash key event for TextBlock
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const flag = getFlag();
|
||||
|
||||
return isHotkey('/', e) && flag === '';
|
||||
},
|
||||
handler: (_: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
@@ -183,7 +226,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [canHandle, controller, dispatch, docId, getDeltaContent, getFlag, id, spaceTriggerMap]);
|
||||
}, [canHandle, controller, dispatch, docId, getAttrs, getDeltaContent, getFlag, id, spaceTriggerMap]);
|
||||
|
||||
return turnIntoBlockEvents;
|
||||
}
|
||||
+1
-1
@@ -32,7 +32,7 @@ export default function TodoListBlock({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<div className={`flex-1 ${checked ? 'text-shade-2 line-through' : ''}`}>
|
||||
<TextBlock node={node} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-4
@@ -61,19 +61,18 @@ export function useCommonKeyEvents(id: string) {
|
||||
{
|
||||
// handle left arrow key and no other key is pressed
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
return isHotkey(Keyboard.keys.LEFT, e) && caretRef.current?.index === 0 && caretRef.current?.length === 0;
|
||||
return isHotkey(Keyboard.keys.LEFT, e);
|
||||
},
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dispatch(leftActionForBlockThunk({ docId, id }));
|
||||
},
|
||||
},
|
||||
{
|
||||
// handle right arrow key and no other key is pressed
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const block = getBlock(docId, id);
|
||||
const isEndOfBlock = caretRef.current?.index === new Delta(block.data.delta).length();
|
||||
return isHotkey(Keyboard.keys.RIGHT, e) && isEndOfBlock && caretRef.current?.length === 0;
|
||||
return isHotkey(Keyboard.keys.RIGHT, e);
|
||||
},
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -86,6 +85,7 @@ export function useCommonKeyEvents(id: string) {
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!controller) return;
|
||||
const format = parseFormat(e);
|
||||
|
||||
if (!format) return;
|
||||
dispatch(
|
||||
toggleFormatThunk({
|
||||
@@ -97,5 +97,6 @@ export function useCommonKeyEvents(id: string) {
|
||||
},
|
||||
];
|
||||
}, [docId, caretRef, controller, dispatch, focused, id]);
|
||||
|
||||
return commonKeyEvents;
|
||||
}
|
||||
+10
-2
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { RangeStatic } from 'quill';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
@@ -44,7 +44,15 @@ export function useSelection(id: string) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (rangeRef.current && rangeRef.current?.isDragging) return;
|
||||
if (rangeRef.current) {
|
||||
const { isDragging, anchor, focus } = rangeRef.current;
|
||||
const mouseDownFocused = anchor?.point.x === focus?.point.x && anchor?.point.y === focus?.point.y;
|
||||
|
||||
if (isDragging && !mouseDownFocused) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!focusCaret) {
|
||||
setSelection(undefined);
|
||||
return;
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import React, { useCallback, useContext, useEffect, useRef } from 'react';
|
||||
import './inline.css';
|
||||
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||
import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { createTemporary } from '$app_reducers/document/async-actions/temporary';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import KatexMath from '$app/components/document/_shared/KatexMath';
|
||||
|
||||
const LEFT_CARET_CLASS = 'inline-block-with-cursor-left';
|
||||
const RIGHT_CARET_CLASS = 'inline-block-with-cursor-right';
|
||||
|
||||
function InlineContainer({
|
||||
isFirst,
|
||||
isLast,
|
||||
children,
|
||||
getSelection,
|
||||
selectedText,
|
||||
data,
|
||||
temporaryType,
|
||||
}: {
|
||||
getSelection: (node: Element) => RangeStaticNoId | null;
|
||||
children: React.ReactNode;
|
||||
formula: string;
|
||||
selectedText: string;
|
||||
isLast: boolean;
|
||||
isFirst: boolean;
|
||||
data: {
|
||||
latex?: string;
|
||||
};
|
||||
temporaryType: TemporaryType;
|
||||
}) {
|
||||
const id = useContext(NodeIdContext);
|
||||
const { docId } = useSubscribeDocument();
|
||||
const { focused, focusCaret } = useFocused(id);
|
||||
const rangeRef = useRangeRef();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(
|
||||
(node: HTMLSpanElement) => {
|
||||
const selection = getSelection(node);
|
||||
|
||||
if (!selection) return;
|
||||
const temporaryData = temporaryType === TemporaryType.Equation ? { latex: data.latex } : {};
|
||||
|
||||
dispatch(
|
||||
createTemporary({
|
||||
docId,
|
||||
state: {
|
||||
id,
|
||||
selection,
|
||||
selectedText,
|
||||
type: temporaryType,
|
||||
data: temporaryData as { latex: string },
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[getSelection, temporaryType, data.latex, dispatch, docId, id, selectedText]
|
||||
);
|
||||
|
||||
const renderNode = useCallback(() => {
|
||||
switch (temporaryType) {
|
||||
case TemporaryType.Equation:
|
||||
return <KatexMath latex={data.latex!} isInline />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [data, temporaryType]);
|
||||
|
||||
const resetCaret = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
ref.current.classList.remove(RIGHT_CARET_CLASS);
|
||||
ref.current.classList.remove(LEFT_CARET_CLASS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
resetCaret();
|
||||
if (!ref.current) return;
|
||||
if (!focused || !focusCaret || rangeRef.current?.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inlineBlockSelection = getSelection(ref.current);
|
||||
|
||||
if (!inlineBlockSelection) return;
|
||||
const distance = inlineBlockSelection.index - focusCaret.index;
|
||||
|
||||
if (distance === 0 && isFirst) {
|
||||
ref.current.classList.add(LEFT_CARET_CLASS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (distance === -1) {
|
||||
ref.current.classList.add(RIGHT_CARET_CLASS);
|
||||
return;
|
||||
}
|
||||
}, [focused, focusCaret, getSelection, resetCaret, isFirst, rangeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.target === ref.current) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// prevent page scroll when the caret change by mouse down
|
||||
document.addEventListener('mousedown', onMouseDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!selectedText) return null;
|
||||
|
||||
return (
|
||||
<span className={'inline-block-with-cursor relative'} ref={ref} onClick={() => onClick(ref.current!)}>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
className={`absolute caret-transparent opacity-0`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span
|
||||
data-slate-placeholder={true}
|
||||
contentEditable={false}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{renderNode()}
|
||||
</span>
|
||||
{isLast && <span data-slate-string={false}></span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default InlineContainer;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
.inline-block-with-cursor {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.inline-block-with-cursor-left::before,
|
||||
.inline-block-with-cursor-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: rgb(55, 53, 47);
|
||||
opacity: 0.5;
|
||||
animation: cursor-blink 1s infinite;
|
||||
}
|
||||
|
||||
.inline-block-with-cursor-left::before {
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.inline-block-with-cursor-right::after {
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
|
||||
.katex-html {
|
||||
white-space: normal;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { BlockMath, InlineMath } from 'react-katex';
|
||||
import './index.css';
|
||||
|
||||
function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
|
||||
return isInline ? <InlineMath math={latex} /> : <BlockMath math={latex} />;
|
||||
}
|
||||
|
||||
export default KatexMath;
|
||||
@@ -49,7 +49,11 @@ const MenuItem = forwardRef(function (
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`mr-2 flex h-[${imgSize.height}px] w-[${imgSize.width}px] items-center justify-center rounded border border-shade-5`}
|
||||
style={{
|
||||
width: imgSize.width,
|
||||
height: imgSize.height,
|
||||
}}
|
||||
className={`mr-2 flex items-center justify-center rounded border border-shade-5`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
+35
-1
@@ -4,6 +4,9 @@ import { useCallback, useRef } from 'react';
|
||||
import TextLink from '../TextLink';
|
||||
import { converToIndexLength } from '$app/utils/document/slate_editor';
|
||||
import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
|
||||
import TemporaryInput from '$app/components/document/_shared/TemporaryInput';
|
||||
import InlineContainer from '$app/components/document/_shared/InlineBlock/InlineContainer';
|
||||
import { TemporaryType } from '$app/interfaces/document';
|
||||
|
||||
interface Attributes {
|
||||
bold?: boolean;
|
||||
@@ -16,6 +19,8 @@ interface Attributes {
|
||||
prism_token?: string;
|
||||
link_selection_lighted?: boolean;
|
||||
link_placeholder?: string;
|
||||
temporary?: boolean;
|
||||
formula?: string;
|
||||
}
|
||||
interface TextLeafProps extends RenderLeafProps {
|
||||
leaf: BaseText & Attributes;
|
||||
@@ -27,6 +32,9 @@ const TextLeaf = (props: TextLeafProps) => {
|
||||
const { attributes, children, leaf, isCodeBlock, editor } = props;
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const customAttributes = {
|
||||
...attributes,
|
||||
};
|
||||
let newChildren = children;
|
||||
|
||||
if (leaf.code) {
|
||||
@@ -51,6 +59,7 @@ const TextLeaf = (props: TextLeafProps) => {
|
||||
anchor: { path, offset: 0 },
|
||||
focus: { path, offset: leaf.text.length },
|
||||
});
|
||||
|
||||
return selection;
|
||||
},
|
||||
[editor, leaf]
|
||||
@@ -64,6 +73,26 @@ const TextLeaf = (props: TextLeafProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (leaf.formula) {
|
||||
const { isLast, text, parent } = children.props;
|
||||
const temporaryType = TemporaryType.Equation;
|
||||
const data = { latex: leaf.formula };
|
||||
|
||||
newChildren = (
|
||||
<InlineContainer
|
||||
isLast={isLast}
|
||||
isFirst={text === parent.children[0]}
|
||||
getSelection={getSelection}
|
||||
formula={leaf.formula}
|
||||
data={data}
|
||||
temporaryType={temporaryType}
|
||||
selectedText={leaf.text}
|
||||
>
|
||||
{newChildren}
|
||||
</InlineContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const className = [
|
||||
isCodeBlock && 'token',
|
||||
leaf.prism_token && leaf.prism_token,
|
||||
@@ -83,8 +112,13 @@ const TextLeaf = (props: TextLeafProps) => {
|
||||
</LinkHighLight>
|
||||
);
|
||||
}
|
||||
|
||||
if (leaf.temporary) {
|
||||
newChildren = <TemporaryInput leaf={leaf}>{newChildren}</TemporaryInput>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span ref={ref} {...attributes} className={className.join(' ')}>
|
||||
<span ref={ref} {...customAttributes} className={className.join(' ')}>
|
||||
{newChildren}
|
||||
</span>
|
||||
);
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import { TextAction } from '$app/interfaces/document';
|
||||
import { Keyboard } from '$app/constants/document/keyboard';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Editor, Range } from 'slate';
|
||||
import { converToSlatePoint } from '$app/utils/document/slate_editor';
|
||||
import { EQUATION_PLACEHOLDER } from '$app/constants/document/name';
|
||||
|
||||
const bold = {
|
||||
type: TextAction.Bold,
|
||||
/**
|
||||
* ** or __
|
||||
*/
|
||||
markdownRegexp: /(\*\*|__)([^\s](?:[^\s]*?[^\s])?)(\*\*|__)$/,
|
||||
};
|
||||
const italic = {
|
||||
type: TextAction.Italic,
|
||||
/**
|
||||
* * or _
|
||||
*/
|
||||
markdownRegexp: /(\*|_)([^\s](?:[^\s]*?[^\s])?)(\*|_)$/,
|
||||
};
|
||||
const strikethrough = {
|
||||
type: TextAction.Strikethrough,
|
||||
|
||||
/**
|
||||
* ~~
|
||||
*/
|
||||
markdownRegexp: /(~~)([^\s](?:[^\s]*?[^\s])?)(~~)$/,
|
||||
};
|
||||
const inlineCode = {
|
||||
type: TextAction.Code,
|
||||
/**
|
||||
* `
|
||||
*/
|
||||
markdownRegexp: /(`)([^\s](?:[^\s]*?[^\s])?)(`)$/,
|
||||
};
|
||||
const inlineEquation = {
|
||||
type: TextAction.Equation,
|
||||
/**
|
||||
* $
|
||||
*/
|
||||
markdownRegexp: /(\$)([^\s](?:[^\s]*?[^\s])?)(\$)$/,
|
||||
};
|
||||
const config: Record<
|
||||
string,
|
||||
{
|
||||
type: TextAction;
|
||||
getValue?: (matchStr: string) => string | boolean;
|
||||
markdownRegexp: RegExp;
|
||||
}[]
|
||||
> = {
|
||||
[Keyboard.keys.ASTERISK]: [bold, italic],
|
||||
[Keyboard.keys.UNDER_SCORE]: [bold, italic],
|
||||
[Keyboard.keys.TILDE]: [strikethrough],
|
||||
[Keyboard.keys.BACK_QUOTE]: [inlineCode],
|
||||
[Keyboard.keys.DOLLAR]: [inlineEquation],
|
||||
};
|
||||
|
||||
export const withMarkdown = (editor: ReactEditor) => {
|
||||
const { insertText } = editor;
|
||||
|
||||
editor.insertText = (text) => {
|
||||
const { selection } = editor;
|
||||
const char = text.charAt(text.length - 1);
|
||||
const matchFormatTypes = config[char];
|
||||
|
||||
if (matchFormatTypes && matchFormatTypes.length > 0 && selection && Range.isCollapsed(selection)) {
|
||||
const { anchor } = selection;
|
||||
const start = Editor.start(editor, []);
|
||||
const range = { anchor, focus: start };
|
||||
const textString = Editor.string(editor, range) + text;
|
||||
const prevChar = textString.charAt(textString.length - 2);
|
||||
|
||||
// If the previous character is a space, we don't want to trigger the markdown
|
||||
if (prevChar === ' ') {
|
||||
return insertText(text);
|
||||
}
|
||||
|
||||
for (const formatType of matchFormatTypes) {
|
||||
const match = textString.match(formatType.markdownRegexp);
|
||||
|
||||
if (match) {
|
||||
const pluralStart = match[0].substring(0, 2) === char.padStart(2, char);
|
||||
const pluralEnd = prevChar === char;
|
||||
|
||||
if (pluralStart && !pluralEnd) {
|
||||
break;
|
||||
}
|
||||
|
||||
const matchIndex = match.index || 0;
|
||||
|
||||
if (formatType.type === TextAction.Equation) {
|
||||
formatEquation(editor, matchIndex, match[2]);
|
||||
return;
|
||||
}
|
||||
|
||||
// format already applied
|
||||
editor.select({
|
||||
anchor,
|
||||
focus: converToSlatePoint(editor, matchIndex),
|
||||
});
|
||||
if (isMarkAction(editor, formatType.type)) {
|
||||
editor.select(anchor);
|
||||
break;
|
||||
}
|
||||
|
||||
Editor.addMark(editor, formatType.type, true);
|
||||
|
||||
// delete extra characters
|
||||
editor.select(converToSlatePoint(editor, matchIndex));
|
||||
editor.delete({
|
||||
distance: pluralStart ? 2 : 1,
|
||||
});
|
||||
|
||||
editor.select(converToSlatePoint(editor, matchIndex + match[2].length));
|
||||
if (pluralStart) {
|
||||
editor.delete({
|
||||
distance: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insertText(text);
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
function isMarkAction(editor: Editor, format: string) {
|
||||
const marks = Editor.marks(editor) as Record<string, boolean> | null;
|
||||
|
||||
return marks ? !!marks[format] : false;
|
||||
}
|
||||
|
||||
function formatEquation(editor: Editor, index: number, latex: string) {
|
||||
editor.select(converToSlatePoint(editor, index));
|
||||
editor.delete({
|
||||
distance: latex.length + 1,
|
||||
});
|
||||
|
||||
editor.insertNode(
|
||||
{
|
||||
text: EQUATION_PLACEHOLDER,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
formula: latex,
|
||||
},
|
||||
{
|
||||
select: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
+21
-3
@@ -15,6 +15,8 @@ import Delta from 'quill-delta';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
|
||||
|
||||
const AFTER_RENDER_DELAY = 100;
|
||||
|
||||
export function useEditor({
|
||||
onChange,
|
||||
onSelectionChange,
|
||||
@@ -24,6 +26,7 @@ export function useEditor({
|
||||
onKeyDown,
|
||||
isCodeBlock,
|
||||
linkDecorateSelection,
|
||||
temporarySelection,
|
||||
}: EditorProps) {
|
||||
const { editor } = useSlateYjs({ delta });
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
@@ -31,6 +34,7 @@ export function useEditor({
|
||||
const onSelectionChangeHandler = useCallback(
|
||||
(slateSelection: Selection) => {
|
||||
const rangeStatic = converToIndexLength(editor, slateSelection);
|
||||
|
||||
onSelectionChange?.(rangeStatic, null);
|
||||
},
|
||||
[editor, onSelectionChange]
|
||||
@@ -39,6 +43,7 @@ export function useEditor({
|
||||
const onChangeHandler = useCallback(
|
||||
(slateValue: Descendant[]) => {
|
||||
const oldContents = delta || new Delta();
|
||||
|
||||
onChange?.(convertToDelta(slateValue), oldContents);
|
||||
onSelectionChangeHandler(editor.selection);
|
||||
},
|
||||
@@ -67,8 +72,10 @@ export function useEditor({
|
||||
) => {
|
||||
if (!selection) return null;
|
||||
const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
|
||||
|
||||
if (range && !Range.isCollapsed(range)) {
|
||||
const intersection = Range.intersection(range, Editor.range(editor, path));
|
||||
|
||||
if (intersection) {
|
||||
return {
|
||||
...intersection,
|
||||
@@ -76,6 +83,7 @@ export function useEditor({
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[editor]
|
||||
@@ -93,11 +101,14 @@ export function useEditor({
|
||||
link_selection_lighted: true,
|
||||
link_placeholder: linkDecorateSelection?.placeholder,
|
||||
}),
|
||||
getDecorateRange(path, temporarySelection, {
|
||||
temporary: true,
|
||||
}),
|
||||
].filter((range) => range !== null) as Range[];
|
||||
|
||||
return ranges;
|
||||
},
|
||||
[decorateSelection, linkDecorateSelection, getDecorateRange]
|
||||
[temporarySelection, decorateSelection, linkDecorateSelection, getDecorateRange]
|
||||
);
|
||||
|
||||
const onKeyDownRewrite = useCallback(
|
||||
@@ -107,6 +118,7 @@ export function useEditor({
|
||||
event.preventDefault();
|
||||
editor.insertText('\n');
|
||||
};
|
||||
|
||||
// There is different behavior for code block and normal text
|
||||
// In code block, we press enter to insert a new line
|
||||
// In normal text, we press shift + enter to insert a new line
|
||||
@@ -115,11 +127,13 @@ export function useEditor({
|
||||
insertBreak();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHotkey(Keyboard.keys.TAB, event)) {
|
||||
event.preventDefault();
|
||||
indent(editor, 2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
|
||||
event.preventDefault();
|
||||
outdent(editor, 2);
|
||||
@@ -141,13 +155,16 @@ export function useEditor({
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const isFocused = ReactEditor.isFocused(editor);
|
||||
|
||||
if (!selection) {
|
||||
isFocused && editor.deselect();
|
||||
return;
|
||||
}
|
||||
|
||||
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
|
||||
|
||||
if (!slateSelection) return;
|
||||
|
||||
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
|
||||
@@ -156,15 +173,16 @@ export function useEditor({
|
||||
// because the slate must be focused before change selection,
|
||||
// but then it will trigger selection change, and the selection is not what we want
|
||||
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
|
||||
|
||||
if (!isSuccess) {
|
||||
Transforms.select(editor, slateSelection);
|
||||
} else {
|
||||
// Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (window.getSelection()?.type === 'None' && !editor.selection) {
|
||||
Transforms.select(editor, slateSelection);
|
||||
}
|
||||
});
|
||||
}, AFTER_RENDER_DELAY);
|
||||
}
|
||||
}, [editor, selection]);
|
||||
|
||||
|
||||
+4
-1
@@ -5,6 +5,7 @@ import { convertToSlateValue } from '$app/utils/document/slate_editor';
|
||||
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
|
||||
import { withReact } from 'slate-react';
|
||||
import { createEditor } from 'slate';
|
||||
import { withMarkdown } from '$app/components/document/_shared/SlateEditor/markdown';
|
||||
|
||||
export function useSlateYjs({ delta }: { delta?: Delta }) {
|
||||
const [yText, setYText] = useState<Y.Text | undefined>(undefined);
|
||||
@@ -13,13 +14,14 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
|
||||
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
|
||||
const value = convertToSlateValue(delta || new Delta());
|
||||
const insertDelta = slateNodesToInsertDelta(value);
|
||||
|
||||
sharedType.applyDelta(insertDelta);
|
||||
setYText(insertDelta[0].insert as Y.Text);
|
||||
return sharedType;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const editor = useMemo(() => withReact(withYjs(createEditor(), sharedType)), []);
|
||||
const editor = useMemo(() => withYjs(withMarkdown(withReact(createEditor())), sharedType), []);
|
||||
|
||||
// Connect editor in useEffect to comply with concurrent mode requirements.
|
||||
useEffect(() => {
|
||||
@@ -33,6 +35,7 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
|
||||
if (!yText) return;
|
||||
const oldContents = new Delta(yText.toDelta());
|
||||
const diffDelta = oldContents.diff(delta || new Delta());
|
||||
|
||||
if (diffDelta.ops.length === 0) return;
|
||||
yText.applyDelta(diffDelta.ops);
|
||||
}, [delta, editor, yText]);
|
||||
|
||||
+4
-1
@@ -1,10 +1,12 @@
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { useContext } from 'react';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||
|
||||
export function useSubscribeDocument() {
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
const docId = controller.documentId;
|
||||
|
||||
return {
|
||||
docId,
|
||||
controller,
|
||||
@@ -14,7 +16,8 @@ export function useSubscribeDocument() {
|
||||
export function useSubscribeDocumentData() {
|
||||
const { docId } = useSubscribeDocument();
|
||||
const data = useAppSelector((state) => {
|
||||
return state.document[docId];
|
||||
return state[DOCUMENT_NAME][docId];
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { TEXT_LINK_NAME } from '$app/constants/document/name';
|
||||
|
||||
export function useSubscribeLinkPopover() {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const linkPopover = useAppSelector((state) => {
|
||||
return state.documentLinkPopover[docId];
|
||||
return state[TEXT_LINK_NAME][docId];
|
||||
});
|
||||
|
||||
return linkPopover;
|
||||
|
||||
+7
-3
@@ -2,6 +2,7 @@ import { store, useAppSelector } from '@/appflowy_app/stores/store';
|
||||
import { createContext, useMemo } from 'react';
|
||||
import { Node } from '$app/interfaces/document';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
|
||||
|
||||
/**
|
||||
* Subscribe node information
|
||||
@@ -11,20 +12,23 @@ export function useSubscribeNode(id: string) {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const node = useAppSelector<Node>((state) => {
|
||||
const documentState = state.document[docId];
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
|
||||
return documentState?.nodes[id];
|
||||
});
|
||||
|
||||
const childIds = useAppSelector<string[] | undefined>((state) => {
|
||||
const documentState = state.document[docId];
|
||||
const documentState = state[DOCUMENT_NAME][docId];
|
||||
|
||||
if (!documentState) return;
|
||||
const childrenId = documentState.nodes[id]?.children;
|
||||
|
||||
if (!childrenId) return;
|
||||
return documentState.children[childrenId];
|
||||
});
|
||||
|
||||
const isSelected = useAppSelector<boolean>((state) => {
|
||||
return state.documentRectSelection[docId]?.selection.includes(id) || false;
|
||||
return state[RECT_RANGE_NAME][docId]?.selection.includes(id) || false;
|
||||
});
|
||||
|
||||
// Memoize the node and its children
|
||||
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { RECT_RANGE_NAME } from '$app/constants/document/name';
|
||||
|
||||
export function useSubscribeRectRange() {
|
||||
const { docId } = useSubscribeDocument();
|
||||
const rectRange = useAppSelector((state) => {
|
||||
return state.documentRectSelection[docId];
|
||||
return state[RECT_RANGE_NAME][docId];
|
||||
});
|
||||
|
||||
return rectRange;
|
||||
}
|
||||
+21
-6
@@ -2,16 +2,25 @@ import { useAppSelector } from '$app/stores/store';
|
||||
import { RangeState, RangeStatic } from '$app/interfaces/document';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { RANGE_NAME, TEMPORARY_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
|
||||
|
||||
export function useSubscribeDecorate(id: string) {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const decorateSelection = useAppSelector((state) => {
|
||||
return state.documentRange[docId]?.ranges[id];
|
||||
return state[RANGE_NAME][docId]?.ranges[id];
|
||||
});
|
||||
|
||||
const temporarySelection = useAppSelector((state) => {
|
||||
const temporary = state[TEMPORARY_NAME][docId];
|
||||
|
||||
if (!temporary || temporary.id !== id) return;
|
||||
return temporary.selection;
|
||||
});
|
||||
|
||||
const linkDecorateSelection = useAppSelector((state) => {
|
||||
const linkPopoverState = state.documentLinkPopover[docId];
|
||||
const linkPopoverState = state[TEXT_LINK_NAME][docId];
|
||||
|
||||
if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
|
||||
return {
|
||||
selection: linkPopoverState.selection,
|
||||
@@ -22,18 +31,22 @@ export function useSubscribeDecorate(id: string) {
|
||||
return {
|
||||
decorateSelection,
|
||||
linkDecorateSelection,
|
||||
temporarySelection,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFocused(id: string) {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const caretRef = useRef<RangeStatic>();
|
||||
const focusCaret = useAppSelector((state) => {
|
||||
const currentCaret = state.documentRange[docId]?.caret;
|
||||
const currentCaret = state[RANGE_NAME][docId]?.caret;
|
||||
|
||||
caretRef.current = currentCaret;
|
||||
if (currentCaret?.id === id) {
|
||||
return currentCaret;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -52,8 +65,10 @@ export function useRangeRef() {
|
||||
const { docId, controller } = useSubscribeDocument();
|
||||
|
||||
const rangeRef = useRef<RangeState>();
|
||||
|
||||
useAppSelector((state) => {
|
||||
const currentRange = state.documentRange[docId];
|
||||
const currentRange = state[RANGE_NAME][docId];
|
||||
|
||||
rangeRef.current = currentRange;
|
||||
});
|
||||
return rangeRef;
|
||||
@@ -63,7 +78,7 @@ export function useSubscribeRanges() {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const rangeState = useAppSelector((state) => {
|
||||
return state.documentRange[docId];
|
||||
return state[RANGE_NAME][docId];
|
||||
});
|
||||
|
||||
return rangeState;
|
||||
@@ -73,7 +88,7 @@ export function useSubscribeCaret() {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const caret = useAppSelector((state) => {
|
||||
return state.documentRange[docId]?.caret;
|
||||
return state[RANGE_NAME][docId]?.caret;
|
||||
});
|
||||
|
||||
return caret;
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { SLASH_COMMAND_NAME } from '$app/constants/document/name';
|
||||
|
||||
export function useSubscribeSlashState() {
|
||||
const { docId } = useSubscribeDocument();
|
||||
|
||||
const slashCommandState = useAppSelector((state) => {
|
||||
return state.documentSlashCommand[docId];
|
||||
return state[SLASH_COMMAND_NAME][docId];
|
||||
});
|
||||
|
||||
return slashCommandState;
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useAppSelector } from '$app/stores/store';
|
||||
import { TemporaryState } from '$app/interfaces/document';
|
||||
import { TEMPORARY_NAME } from '$app/constants/document/name';
|
||||
|
||||
export function useSubscribeTemporary(): TemporaryState {
|
||||
const { docId } = useSubscribeDocument();
|
||||
const temporaryState = useAppSelector((state) => state[TEMPORARY_NAME][docId]);
|
||||
|
||||
return temporaryState;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { CheckOutlined, FunctionsOutlined } from '@mui/icons-material';
|
||||
import { Divider, IconButton, InputAdornment } from '@mui/material';
|
||||
|
||||
function EquationEditContent({
|
||||
value,
|
||||
onChange,
|
||||
onConfirm,
|
||||
placeholder = 'E = mc^2',
|
||||
multiline = false,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (newVal: string) => void;
|
||||
onConfirm: () => void;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={'flex p-2'}>
|
||||
<TextField
|
||||
placeholder={placeholder}
|
||||
autoFocus={true}
|
||||
multiline={multiline}
|
||||
label='Equation'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position='start'>
|
||||
<FunctionsOutlined />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
variant='standard'
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.value;
|
||||
|
||||
if (newVal === value) return;
|
||||
onChange(newVal);
|
||||
}}
|
||||
/>
|
||||
<Divider sx={{ height: 'initial', marginLeft: '10px' }} orientation='vertical' />
|
||||
<IconButton onClick={onConfirm} color='primary' sx={{ p: '10px' }} aria-label='directions'>
|
||||
<CheckOutlined />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EquationEditContent;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Functions } from '@mui/icons-material';
|
||||
import KatexMath from '$app/components/document/_shared/KatexMath';
|
||||
|
||||
function TemporaryEquation({ latex }: { latex: string }) {
|
||||
return (
|
||||
<span className={'rounded bg-shade-6 px-1 py-0.5'} contentEditable={false}>
|
||||
{latex ? (
|
||||
<KatexMath latex={latex} isInline />
|
||||
) : (
|
||||
<span className={'text-shade-3'}>
|
||||
<Functions /> {'New equation'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemporaryEquation;
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { RangeStaticNoId, TemporaryData, TemporaryState, TemporaryType } from '$app/interfaces/document';
|
||||
import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
|
||||
import { temporaryActions } from '$app_reducers/document/temporary_slice';
|
||||
import { rangeActions } from '$app_reducers/document/slice';
|
||||
import { formatTemporary } from '$app_reducers/document/async-actions/temporary';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
|
||||
|
||||
const AFTER_RENDER_DELAY = 100;
|
||||
|
||||
function TemporaryPopover() {
|
||||
const temporaryState = useSubscribeTemporary();
|
||||
const anchorPosition = useMemo(() => temporaryState?.popoverPosition, [temporaryState]);
|
||||
const open = Boolean(anchorPosition);
|
||||
const id = temporaryState?.id;
|
||||
const dispatch = useAppDispatch();
|
||||
const { docId, controller } = useSubscribeDocument();
|
||||
|
||||
const onChangeData = useCallback(
|
||||
(data: TemporaryData) => {
|
||||
dispatch(
|
||||
temporaryActions.updateTemporaryState({
|
||||
id: docId,
|
||||
state: {
|
||||
data,
|
||||
id,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, docId, id]
|
||||
);
|
||||
|
||||
const resetCaret = useCallback(
|
||||
(id: string, selection: RangeStaticNoId) => {
|
||||
dispatch(
|
||||
rangeActions.setCaret({
|
||||
docId,
|
||||
caret: {
|
||||
id,
|
||||
index: selection.index + selection.length,
|
||||
length: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, docId]
|
||||
);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(
|
||||
temporaryActions.updateTemporaryState({
|
||||
id: docId,
|
||||
state: {
|
||||
id,
|
||||
popoverPosition: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [dispatch, docId, id]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!temporaryState) return;
|
||||
onClose();
|
||||
dispatch(temporaryActions.deleteTemporaryState(docId));
|
||||
resetCaret(temporaryState.id, temporaryState.selection);
|
||||
}, [dispatch, docId, onClose, resetCaret, temporaryState]);
|
||||
|
||||
const onConfirm = useCallback(async () => {
|
||||
const res = await dispatch(
|
||||
formatTemporary({
|
||||
controller,
|
||||
})
|
||||
);
|
||||
const state = res.payload as TemporaryState;
|
||||
|
||||
if (!state) return;
|
||||
const { id, selection } = state;
|
||||
|
||||
onClose();
|
||||
dispatch(rangeActions.clearRanges({ docId }));
|
||||
dispatch(temporaryActions.deleteTemporaryState(docId));
|
||||
// wait slate to update the dom
|
||||
setTimeout(() => {
|
||||
resetCaret(id, selection);
|
||||
}, AFTER_RENDER_DELAY);
|
||||
}, [dispatch, controller, onClose, docId, resetCaret]);
|
||||
|
||||
const renderPopoverContent = useCallback(() => {
|
||||
if (!temporaryState) return null;
|
||||
const { type, data } = temporaryState;
|
||||
|
||||
switch (type) {
|
||||
case TemporaryType.Equation:
|
||||
return (
|
||||
<EquationEditContent
|
||||
value={data.latex}
|
||||
onChange={(latex: string) =>
|
||||
onChangeData({
|
||||
latex,
|
||||
})
|
||||
}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [onChangeData, onConfirm, temporaryState]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onClose={handleClose}
|
||||
open={open}
|
||||
anchorPosition={anchorPosition ? anchorPosition : undefined}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
disableAutoFocus={true}
|
||||
disableRestoreFocus={true}
|
||||
anchorReference={'anchorPosition'}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
{renderPopoverContent()}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemporaryPopover;
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { TemporaryType } from '$app/interfaces/document';
|
||||
import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation';
|
||||
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
|
||||
import { isOverlappingPrefix } from '$app/utils/document/temporary';
|
||||
import { PopoverPosition } from '@mui/material';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { temporaryActions } from '$app_reducers/document/temporary_slice';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
|
||||
function TemporaryInput({ leaf, children }: { leaf: { text: string }; children: React.ReactNode }) {
|
||||
const temporaryState = useSubscribeTemporary();
|
||||
const id = temporaryState?.id;
|
||||
const dispatch = useAppDispatch();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const { docId } = useSubscribeDocument();
|
||||
const match = useMemo(() => {
|
||||
if (!leaf.text) return false;
|
||||
if (!temporaryState) return false;
|
||||
const { selectedText, type } = temporaryState;
|
||||
|
||||
switch (type) {
|
||||
case TemporaryType.Equation:
|
||||
// when the leaf is split, the placeholder is not the same as the leaf text,
|
||||
// so we can only check for overlapping prefix and hidden other leafs
|
||||
return leaf.text === selectedText || isOverlappingPrefix(leaf.text, selectedText);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [temporaryState, leaf.text]);
|
||||
|
||||
const renderPlaceholder = useCallback(() => {
|
||||
if (!temporaryState) return null;
|
||||
const { type, data } = temporaryState;
|
||||
|
||||
switch (type) {
|
||||
case TemporaryType.Equation:
|
||||
return <TemporaryEquation latex={data.latex} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [temporaryState]);
|
||||
|
||||
const setAnchorPosition = useCallback(
|
||||
(position: PopoverPosition | null) => {
|
||||
dispatch(
|
||||
temporaryActions.updateTemporaryState({
|
||||
id: docId,
|
||||
state: {
|
||||
id,
|
||||
popoverPosition: position,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, docId, id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !match) return;
|
||||
const { width, height, top, left } = ref.current.getBoundingClientRect();
|
||||
|
||||
setAnchorPosition({
|
||||
top: top + height,
|
||||
left: left + width / 2,
|
||||
});
|
||||
}, [dispatch, docId, id, match, setAnchorPosition]);
|
||||
|
||||
return (
|
||||
<span ref={ref}>
|
||||
{match ? renderPlaceholder() : null}
|
||||
<span className={'absolute opacity-0'}>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemporaryInput;
|
||||
+1
-12
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { isOverlappingPrefix } from '$app/utils/document/temporary';
|
||||
|
||||
function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -19,15 +20,3 @@ function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; titl
|
||||
}
|
||||
|
||||
export default LinkHighLight;
|
||||
|
||||
function isOverlappingPrefix(first: string, second: string): boolean {
|
||||
if (first.length === 0 || second.length === 0) return false;
|
||||
let i = 0;
|
||||
while (i < first.length) {
|
||||
const chars = first.substring(i);
|
||||
if (chars.length > second.length) return false;
|
||||
if (second.startsWith(chars)) return true;
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+49
-10
@@ -4,11 +4,43 @@ import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import Delta from 'quill-delta';
|
||||
import { getDeltaText } from '$app/utils/document/delta';
|
||||
import { rangeActions, rectSelectionActions } from '$app_reducers/document/slice';
|
||||
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
|
||||
|
||||
export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { controller } = useSubscribeDocument();
|
||||
const { controller, docId } = useSubscribeDocument();
|
||||
|
||||
const getTurnIntoData = useCallback(
|
||||
(targetType: BlockType, sourceNode: NestedBlock) => {
|
||||
if (targetType === sourceNode.type) return;
|
||||
const config = blockConfig[targetType];
|
||||
const defaultData = config.defaultData;
|
||||
const data: BlockData<any> = {
|
||||
...defaultData,
|
||||
delta: sourceNode?.data?.delta || [],
|
||||
};
|
||||
|
||||
if (targetType === BlockType.EquationBlock) {
|
||||
data.formula = getDeltaText(new Delta(sourceNode.data.delta));
|
||||
delete data.delta;
|
||||
}
|
||||
|
||||
if (sourceNode.type === BlockType.EquationBlock) {
|
||||
data.delta = [
|
||||
{
|
||||
insert: node.data.formula,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
[node.data.formula]
|
||||
);
|
||||
|
||||
const turnIntoBlock = useCallback(
|
||||
async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {
|
||||
@@ -17,27 +49,34 @@ export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: ()
|
||||
return;
|
||||
}
|
||||
|
||||
const config = blockConfig[type];
|
||||
await dispatch(
|
||||
const updateData = {
|
||||
...getTurnIntoData(type, node),
|
||||
...data,
|
||||
};
|
||||
|
||||
const { payload: newBlockId } = await dispatch(
|
||||
turnToBlockThunk({
|
||||
id: node.id,
|
||||
controller,
|
||||
type,
|
||||
data: {
|
||||
...config.defaultData,
|
||||
delta: node?.data?.delta || [],
|
||||
...data,
|
||||
},
|
||||
data: updateData,
|
||||
})
|
||||
);
|
||||
|
||||
onClose?.();
|
||||
dispatch(
|
||||
setRectSelectionThunk({
|
||||
docId,
|
||||
selection: [newBlockId as string],
|
||||
})
|
||||
);
|
||||
},
|
||||
[onClose, controller, dispatch, node]
|
||||
[controller, getTurnIntoData, node, dispatch, onClose, docId]
|
||||
);
|
||||
|
||||
const turnIntoHeading = useCallback(
|
||||
(level: number, isSelected: boolean) => {
|
||||
turnIntoBlock(BlockType.HeadingBlock, isSelected, { level });
|
||||
return turnIntoBlock(BlockType.HeadingBlock, isSelected, { level });
|
||||
},
|
||||
[turnIntoBlock]
|
||||
);
|
||||
|
||||
+6
-5
@@ -120,11 +120,12 @@ const TurnIntoPopover = ({
|
||||
title: 'Callout',
|
||||
icon: <Lightbulb />,
|
||||
},
|
||||
// {
|
||||
// type: BlockType.EquationBlock,
|
||||
// title: 'Block Equation',
|
||||
// icon: <Functions />,
|
||||
// },
|
||||
{
|
||||
key: SlashCommandOptionKey.EQUATION,
|
||||
type: BlockType.EquationBlock,
|
||||
title: 'Block Equation',
|
||||
icon: <Functions />,
|
||||
},
|
||||
],
|
||||
[node?.data?.level, turnIntoHeading]
|
||||
);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const BLOCK_MAP_NAME = 'blocks';
|
||||
export const META_NAME = 'meta';
|
||||
export const CHILDREN_MAP_NAME = 'children_map';
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user