diff --git a/INCLUDED.md b/INCLUDED.md index cf9af00e..94a43202 100644 --- a/INCLUDED.md +++ b/INCLUDED.md @@ -3829,6 +3829,11 @@ - Licence: MIT - https://github.com/sindresorhus/opn +## OpenDyslexic +- Published By: Abbie Gonzalez +- Licence: SIL Open Font License 1.1 +- https://github.com/antijingoist/opendyslexic + ## optimist@0.6.1 - Published By: James Halliday - Licence: MIT* diff --git a/app/package.json b/app/package.json index 9113af12..5abc5a5a 100644 --- a/app/package.json +++ b/app/package.json @@ -3,12 +3,12 @@ "version": "4.7.0", "private": true, "scripts": { - "preinstall": "python3 ../libs/build-libs.py && ./get_precache_files.py > src/extraPrecacheFiles.ts", + "preinstall": "python3 ../libs/build-libs.py && python3 get_precache_files.py > src/extraPrecacheFiles.ts", "prestart": "tsgo -p _build/tsconfig.json", - "start": "NODE_ENV='' node --experimental-import-meta-resolve _build/build.mjs", + "start": "cross-env NODE_ENV= node --experimental-import-meta-resolve _build/build.mjs", "prebuild": "tsgo -p _build/tsconfig.json", - "build": "NODE_ENV=production node --experimental-import-meta-resolve _build/build.mjs", - "test": "TZ=NZ jest", + "build": "cross-env NODE_ENV=production node --experimental-import-meta-resolve _build/build.mjs", + "test": "cross-env TZ=NZ jest", "typecheck": "tsgo --noEmit --p ./tsconfig.json", "typecheck:watch": "tsgo --noEmit --watch -p ./tsconfig.json", "lint": "eslint --flag unstable_ts_config src/", @@ -101,8 +101,10 @@ "@types/react-dom": "^17.0.25", "@types/semver": "^7.5.8", "@types/showdown": "^1.9.4", + "@typescript/native-preview": "7.0.0-dev.20250809.1", "babel-eslint": "^10.1.0", "browserslist": "^4.23.3", + "cross-env": "^10.1.0", "esbuild": "~0.16.17", "esbuild-plugin-browserslist": "~0.6.0", "esbuild-plugin-manifest": "~0.5.0", @@ -123,8 +125,7 @@ "servor": "^4.0.2", "ts-jest": "^27.1.5", "typescript-eslint": "^8.1.0", - "workbox-build": "^6.6.1", - "@typescript/native-preview": "7.0.0-dev.20250809.1" + "workbox-build": "^6.6.1" }, "resolutions": { "@types/react": "^17", diff --git a/app/public/assets/opendyslexic/LICENSE.txt b/app/public/assets/opendyslexic/LICENSE.txt new file mode 100644 index 00000000..bb867823 --- /dev/null +++ b/app/public/assets/opendyslexic/LICENSE.txt @@ -0,0 +1,94 @@ +Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es), +with Reserved Font Name OpenDyslexic. +Copyright (c) 12/2012 - 2019 +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/public/assets/opendyslexic/OpenDyslexic-Bold-Italic.woff b/app/public/assets/opendyslexic/OpenDyslexic-Bold-Italic.woff new file mode 100644 index 00000000..2cf3d21f Binary files /dev/null and b/app/public/assets/opendyslexic/OpenDyslexic-Bold-Italic.woff differ diff --git a/app/public/assets/opendyslexic/OpenDyslexic-Bold-Italic.woff2 b/app/public/assets/opendyslexic/OpenDyslexic-Bold-Italic.woff2 new file mode 100644 index 00000000..9e8a5f80 Binary files /dev/null and b/app/public/assets/opendyslexic/OpenDyslexic-Bold-Italic.woff2 differ diff --git a/app/public/assets/opendyslexic/OpenDyslexic-Bold.woff b/app/public/assets/opendyslexic/OpenDyslexic-Bold.woff new file mode 100644 index 00000000..5b686371 Binary files /dev/null and b/app/public/assets/opendyslexic/OpenDyslexic-Bold.woff differ diff --git a/app/public/assets/opendyslexic/OpenDyslexic-Bold.woff2 b/app/public/assets/opendyslexic/OpenDyslexic-Bold.woff2 new file mode 100644 index 00000000..b5fd5e4f Binary files /dev/null and b/app/public/assets/opendyslexic/OpenDyslexic-Bold.woff2 differ diff --git a/app/public/assets/opendyslexic/OpenDyslexic-Italic.woff b/app/public/assets/opendyslexic/OpenDyslexic-Italic.woff new file mode 100644 index 00000000..2a7521c6 Binary files /dev/null and b/app/public/assets/opendyslexic/OpenDyslexic-Italic.woff differ diff --git a/app/public/assets/opendyslexic/OpenDyslexic-Italic.woff2 b/app/public/assets/opendyslexic/OpenDyslexic-Italic.woff2 new file mode 100644 index 00000000..9b78966b Binary files /dev/null and b/app/public/assets/opendyslexic/OpenDyslexic-Italic.woff2 differ diff --git a/app/public/assets/opendyslexic/OpenDyslexic-Regular.woff b/app/public/assets/opendyslexic/OpenDyslexic-Regular.woff new file mode 100644 index 00000000..b6f20fe7 Binary files /dev/null and b/app/public/assets/opendyslexic/OpenDyslexic-Regular.woff differ diff --git a/app/public/assets/opendyslexic/OpenDyslexic-Regular.woff2 b/app/public/assets/opendyslexic/OpenDyslexic-Regular.woff2 new file mode 100644 index 00000000..4ee220be Binary files /dev/null and b/app/public/assets/opendyslexic/OpenDyslexic-Regular.woff2 differ diff --git a/app/public/assets/opendyslexic/index.css b/app/public/assets/opendyslexic/index.css new file mode 100644 index 00000000..068b6583 --- /dev/null +++ b/app/public/assets/opendyslexic/index.css @@ -0,0 +1,38 @@ +/* OpenDyslexic — https://opendyslexic.org + Licensed under the SIL Open Font License 1.1 (see LICENSE.txt) */ + +@font-face { + font-family: 'OpenDyslexic'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('OpenDyslexic-Regular.woff2') format('woff2'), + url('OpenDyslexic-Regular.woff') format('woff'); +} + +@font-face { + font-family: 'OpenDyslexic'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('OpenDyslexic-Italic.woff2') format('woff2'), + url('OpenDyslexic-Italic.woff') format('woff'); +} + +@font-face { + font-family: 'OpenDyslexic'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('OpenDyslexic-Bold.woff2') format('woff2'), + url('OpenDyslexic-Bold.woff') format('woff'); +} + +@font-face { + font-family: 'OpenDyslexic'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url('OpenDyslexic-Bold-Italic.woff2') format('woff2'), + url('OpenDyslexic-Bold-Italic.woff') format('woff'); +} diff --git a/app/public/index.html b/app/public/index.html index 60e59dbf..c1c82aa2 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -14,6 +14,7 @@ + diff --git a/app/src/app/actions.ts b/app/src/app/actions.ts index a70dbe86..a84319d7 100644 --- a/app/src/app/actions.ts +++ b/app/src/app/actions.ts @@ -21,7 +21,7 @@ import { IInsertElementState } from './reducers/NoteReducer'; import { CombinedNotepadSyncList, SyncLoginRequest, SyncUser } from './types/SyncTypes'; import { FlatNotepad, Notepad, Translators } from 'upad-parse/dist'; import { NoteElement } from 'upad-parse/dist/Note'; -import { SearchResults } from './reducers/SearchReducer'; +import { SearchResultsPayload } from './reducers/SearchReducer'; import { ThemeName } from './types/Themes'; import { DueItem } from './services/DueDates'; import { DrawMode } from './reducers/EditorReducer'; @@ -76,7 +76,7 @@ export const actions = { getHelp: actionCreator.async('GET_HELP'), getDueDates: actionCreator.async('GET_DUE_DATES'), moveObjAcrossNotepads: actionCreator.async('CROSS_NOTEPAD_MOVE'), - search: actionCreator.async('SEARCH'), + search: actionCreator.async('SEARCH'), forgetSavedPasswords: actionCreator.async('FORGET_SAVED_PASSWORDS'), restoreJsonNotepad: actionCreator('PARSE_JSON_NOTEPAD'), @@ -144,6 +144,7 @@ export const actions = { filePasted: actionCreator('FILE_PASTED'), updateEncryptionStatus: actionCreator('UPDATE_CRYPTO_STATUS'), hashtagSearchOrJump: actionCreator('HASHTAG_SEARCH_OR_JUMP'), + jumpToNoteElement: actionCreator('JUMP_TO_NOTE_ELEMENT'), setShowHistoricalDueDates: actionCreator('SET_SHOW_HISTORICAL_DUE_DATES'), }; diff --git a/app/src/app/components/explorer/NotepadExplorerComponent.css b/app/src/app/components/explorer/NotepadExplorerComponent.css index e9a1cc21..3d5283d8 100644 --- a/app/src/app/components/explorer/NotepadExplorerComponent.css +++ b/app/src/app/components/explorer/NotepadExplorerComponent.css @@ -140,3 +140,35 @@ width: 100%; white-space: nowrap; } + +/* Drag-and-drop move/reorder */ + +.explorer-section-label { + display: inline-block; +} + +/* Insertion indicators use box-shadow/outline instead of borders so nothing shifts mid-drag */ +.explorer-drop--before { + box-shadow: inset 0 3px 0 0 var(--mp-theme-accent); +} + +.explorer-drop--after { + box-shadow: inset 0 -3px 0 0 var(--mp-theme-accent); +} + +.explorer-drop--into { + outline: 2px dashed var(--mp-theme-accent); + outline-offset: -2px; + border-radius: 2px; +} + +.explorer-drag-source { + opacity: 0.45; +} + +/* While dragging, child elements must not steal dragenter/dragleave from their row, + or the drop indicators flicker. Scoped to the drag so normal clicks are unaffected. */ +.notepad-explorer--dragging .explorer-note *, +.notepad-explorer--dragging .explorer-section-label * { + pointer-events: none; +} diff --git a/app/src/app/components/explorer/NotepadExplorerComponent.tsx b/app/src/app/components/explorer/NotepadExplorerComponent.tsx index 19292987..d68ca870 100644 --- a/app/src/app/components/explorer/NotepadExplorerComponent.tsx +++ b/app/src/app/components/explorer/NotepadExplorerComponent.tsx @@ -3,7 +3,7 @@ import './NotepadExplorerComponent.css'; import { Icon } from 'react-materialize'; import TreeView from 'react-treeview'; import ExplorerOptionsComponent from './explorer-options/ExplorerOptionsContainer'; -import { NewNotepadObjectAction } from '../../types/ActionTypes'; +import { MoveNotepadObjectAction, NewNotepadObjectAction } from '../../types/ActionTypes'; import HelpMessageComponent from '../help-message/HelpMessageContainer'; import { Dialog } from '../../services/dialogs'; import SyncOptionsComponent from '../sync/sync-options/SyncOptionsContainer'; @@ -22,13 +22,71 @@ import { notepadExplorerConnector } from './NotepadExplorerContainer'; import { ConnectedProps } from 'react-redux'; import { Resizable } from 're-resizable'; import Button2 from '../Button'; +import { EXPLORER_DRAG_MIME, ExplorerDragItem } from './explorer-drag'; type Props = ConnectedProps; +type DropZone = 'before' | 'into' | 'after'; + +type DragState = { + item: ExplorerDragItem | null; + dropTarget: string | null; + setItem: (item: ExplorerDragItem | null) => void; + setTarget: (target: string | null) => void; + hoverCollapsedSection: (ref: string) => void; + clearHoverTimer: () => void; +}; + +type DragCtx = { + /** internalRef of the section the current row's siblings live in, or 'notepad' at the root */ + parentRef: string; + /** internalRefs of every ancestor section of the current row */ + ancestors: readonly string[]; + prevSiblingRef?: string; + nextSiblingRef?: string; + drag: DragState; +}; + const NotepadExplorerComponent = (props: Props) => { const { notepad, theme } = props; const openSections = new Set(props.openSections); + const [dragItem, setDragItem] = React.useState(null); + const [dropTarget, setDropTarget] = React.useState(null); + const hoverExpandTimer = React.useRef<{ ref: string, handle: number } | null>(null); + + const clearHoverTimer = () => { + if (hoverExpandTimer.current !== null) { + window.clearTimeout(hoverExpandTimer.current.handle); + hoverExpandTimer.current = null; + } + }; + + const drag: DragState = { + item: dragItem, + dropTarget, + setItem: item => { + setDragItem(item); + if (!item) { + setDropTarget(null); + clearHoverTimer(); + } + }, + setTarget: setDropTarget, + hoverCollapsedSection: ref => { + if (hoverExpandTimer.current?.ref === ref) return; + clearHoverTimer(); + hoverExpandTimer.current = { + ref, + handle: window.setTimeout(() => { + hoverExpandTimer.current = null; + props.expandSection(ref); + }, 700) + }; + }, + clearHoverTimer + }; + const notepadExplorerStyle: CSSProperties = { display: 'initial', transition: 'background-color .3s', @@ -40,11 +98,17 @@ const NotepadExplorerComponent = (props: Props) => { // Generate TreeViews const treeViews: JSX.Element[] = []; - notepad?.sections?.forEach(section => treeViews.push(generateSectionTreeView(props, section, openSections))); + notepad?.sections?.forEach((section, i) => treeViews.push(generateSectionTreeView(props, section, openSections, { + parentRef: 'notepad', + ancestors: [], + prevSiblingRef: notepad.sections[i - 1]?.internalRef, + nextSiblingRef: notepad.sections[i + 1]?.internalRef, + drag + }))); return ( { !!notepad &&
+ className={dropTarget === 'root:end' ? 'explorer-drop--into' : undefined} + onContextMenu={handleRightClick} + onDragOver={e => { + // Only sections may live at the notebook root + if (dragItem?.type !== 'section') return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + if (dropTarget !== 'root:end') setDropTarget('root:end'); + }} + onDrop={e => { + if (dragItem?.type !== 'section') return; + e.preventDefault(); + e.stopPropagation(); + props.moveObj({ type: 'section', objectRef: dragItem.internalRef, newParent: 'notepad', position: 'end' }); + drag.setItem(null); + }} + onDragLeave={e => { + if (!e.currentTarget.contains(e.relatedTarget as Node) && dropTarget === 'root:end') setDropTarget(null); + }}> {notepad.title} {props.isReadOnly && (Read-Only)} @@ -159,8 +242,9 @@ async function newNotepadObject({ newNote, newSection }: Props, type: 'note' | ' } } -function generateSectionTreeView(props: Props, section: Section, openSections: Set): JSX.Element { +function generateSectionTreeView(props: Props, section: Section, openSections: Set, ctx: DragCtx): JSX.Element { const { theme, loadNote } = props; + const { drag } = ctx; const nodeLabelStyle = { display: 'inline-flex', @@ -169,13 +253,47 @@ function generateSectionTreeView(props: Props, section: Section, openSections: S paddingTop: '10px' }; + const isExpanded = openSections.has(section.internalRef); + const childSections: JSX.Element[] = []; - section.sections.forEach((child: Section) => childSections.push(generateSectionTreeView(props, child, openSections))); + section.sections.forEach((child: Section, i: number) => childSections.push(generateSectionTreeView(props, child, openSections, { + parentRef: section.internalRef, + ancestors: [...ctx.ancestors, section.internalRef], + prevSiblingRef: section.sections[i - 1]?.internalRef, + nextSiblingRef: section.sections[i + 1]?.internalRef, + drag + }))); const childNotes: JSX.Element[] = []; - section.notes.forEach((child: Note) => + section.notes.forEach((child: Note, i: number) => { + const prevNoteRef: string | undefined = section.notes[i - 1]?.internalRef; + const nextNoteRef: string | undefined = section.notes[i + 1]?.internalRef; + + // Gaps between note rows only accept notes (the save format can't interleave notes with sections) + const notePayload = (zone: DropZone): MoveNotepadObjectAction | null => { + const item = drag.item; + if (!item || item.type !== 'note' || item.internalRef === child.internalRef) return null; + if (zone === 'before') { + if (prevNoteRef === item.internalRef) return null; // already right above + return { type: 'note', objectRef: item.internalRef, newParent: section.internalRef, position: { beforeRef: child.internalRef } }; + } + if (nextNoteRef === item.internalRef) return null; // already right below + return { type: 'note', objectRef: item.internalRef, newParent: section.internalRef, position: nextNoteRef ? { beforeRef: nextNoteRef } : 'end' }; + }; + childNotes.push( -
+
startDrag(e, { type: 'note', internalRef: child.internalRef }, drag)} + onDragEnd={() => drag.setItem(null)} + onDragOver={e => { + const zone = zoneFromEvent(e, 'note'); + handleRowDragOver(e, child.internalRef, zone, notePayload(zone), drag); + }} + onDrop={e => handleRowDrop(e, notePayload(zoneFromEvent(e, 'note')), drag, props.moveObj)} + onDragLeave={e => handleRowDragLeave(e, child.internalRef, drag)}>
- ) - ); + ); + }); + + const sectionZone = (e: React.DragEvent): DropZone => + zoneFromEvent(e, isExpanded ? 'section-expanded' : 'section-collapsed'); + + const sectionPayload = (zone: DropZone): MoveNotepadObjectAction | null => { + const item = drag.item; + if (!item) return null; + // Never into/around itself; nor may a section land inside its own subtree + if (item.internalRef === section.internalRef) return null; + if (item.type === 'section' && ctx.ancestors.includes(item.internalRef)) return null; + + if (zone === 'into') return { type: item.type, objectRef: item.internalRef, newParent: section.internalRef, position: 'end' }; + + // Gaps between section rows only accept sections + if (item.type !== 'section') return null; + if (zone === 'before') { + if (ctx.prevSiblingRef === item.internalRef) return null; // already right above + return { type: 'section', objectRef: item.internalRef, newParent: ctx.parentRef, position: { beforeRef: section.internalRef } }; + } + if (ctx.nextSiblingRef === item.internalRef) return null; // already right below + return { type: 'section', objectRef: item.internalRef, newParent: ctx.parentRef, position: ctx.nextSiblingRef ? { beforeRef: ctx.nextSiblingRef } : 'end' }; + }; return ( sectionArrowClick(props, section.internalRef, openSections)} nodeLabel={ - + startDrag(e, { type: 'section', internalRef: section.internalRef }, drag)} + onDragEnd={() => drag.setItem(null)} + onDragOver={e => { + const zone = sectionZone(e); + const payload = sectionPayload(zone); + handleRowDragOver(e, section.internalRef, zone, payload, drag); + if (!drag.item) return; + if (!!payload && zone === 'into' && !isExpanded) { + drag.hoverCollapsedSection(section.internalRef); + } else { + drag.clearHoverTimer(); + } + }} + onDrop={e => { + const zone = sectionZone(e); + handleRowDrop(e, sectionPayload(zone), drag, props.moveObj, + zone === 'into' ? () => props.expandSection(section.internalRef) : undefined); + }} + onDragLeave={e => handleRowDragLeave(e, section.internalRef, drag)}> sectionArrowClick(props, section.internalRef, openSections)} @@ -205,7 +366,7 @@ function generateSectionTreeView(props: Props, section: Section, openSections: S } - collapsed={!openSections.has(section.internalRef)}> + collapsed={!isExpanded}>
newNotepadObject(props, 'note', section)}> add Note @@ -221,6 +382,74 @@ function generateSectionTreeView(props: Props, section: Section, openSections: S ); } +function startDrag(e: React.DragEvent, item: ExplorerDragItem, drag: DragState): void { + e.stopPropagation(); + // getData() is blocked during dragover ("protected mode"), so the live payload lives in + // DragState; setData still runs so Firefox starts the drag and other drop targets can + // recognise explorer drags via dataTransfer.types. + e.dataTransfer.setData(EXPLORER_DRAG_MIME, JSON.stringify(item)); + e.dataTransfer.effectAllowed = 'move'; + drag.setItem(item); +} + +function zoneFromEvent(e: React.DragEvent, kind: 'section-collapsed' | 'section-expanded' | 'note'): DropZone { + const rect = e.currentTarget.getBoundingClientRect(); + const frac = rect.height === 0 ? 0.5 : (e.clientY - rect.top) / rect.height; + + if (kind === 'note') return frac < 0.5 ? 'before' : 'after'; + if (frac < 0.25) return 'before'; + // An expanded section's subtree renders below its label, so a drop "after" it would look + // like a drop inside it. Only collapsed sections offer the "after" gap (VS Code behaviour). + if (kind === 'section-collapsed' && frac > 0.75) return 'after'; + return 'into'; +} + +function handleRowDragOver(e: React.DragEvent, ref: string, zone: DropZone, payload: MoveNotepadObjectAction | null, drag: DragState): void { + if (!drag.item) return; // not an explorer drag (e.g. an OS file): let it fall through + if (!payload) { + e.dataTransfer.dropEffect = 'none'; + if (drag.dropTarget?.startsWith(`${ref}:`)) drag.setTarget(null); + return; + } + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + const key = `${ref}:${zone}`; + if (drag.dropTarget !== key) drag.setTarget(key); // dragover fires per frame: only setState on change +} + +function handleRowDrop( + e: React.DragEvent, + payload: MoveNotepadObjectAction | null, + drag: DragState, + moveObj: (payload: MoveNotepadObjectAction) => void, + onMoved?: () => void +): void { + if (!drag.item) return; + e.preventDefault(); + e.stopPropagation(); + if (payload) { + moveObj(payload); + if (onMoved) onMoved(); + } + drag.setItem(null); +} + +function handleRowDragLeave(e: React.DragEvent, ref: string, drag: DragState): void { + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + if (drag.dropTarget?.startsWith(`${ref}:`)) drag.setTarget(null); + drag.clearHoverTimer(); +} + +function dropClasses(base: string, ref: string, drag: DragState): string { + const classes = [base]; + if (drag.item?.internalRef === ref) classes.push('explorer-drag-source'); + if (drag.dropTarget === `${ref}:before`) classes.push('explorer-drop--before'); + else if (drag.dropTarget === `${ref}:into`) classes.push('explorer-drop--into'); + else if (drag.dropTarget === `${ref}:after`) classes.push('explorer-drop--after'); + return classes.join(' '); +} + function sectionArrowClick({ expandSection, collapseSection }: Props, guid: string, openSections: Set) { if (openSections.has(guid)) { collapseSection(guid); diff --git a/app/src/app/components/explorer/NotepadExplorerContainer.ts b/app/src/app/components/explorer/NotepadExplorerContainer.ts index 0b600142..1b77ecb3 100644 --- a/app/src/app/components/explorer/NotepadExplorerContainer.ts +++ b/app/src/app/components/explorer/NotepadExplorerContainer.ts @@ -5,6 +5,7 @@ import { Action, Dispatch } from 'redux'; import { actions } from '../../actions'; import { FlatNotepad, Note, Notepad } from 'upad-parse/dist'; import { ThemeValues } from '../../ThemeValues'; +import { MoveNotepadObjectAction } from '../../types/ActionTypes'; let notepad: FlatNotepad | undefined; let memoisedFullNotepad: Notepad | undefined; @@ -41,6 +42,7 @@ export function mapDispatchToProps(dispatch: Dispatch) { collapseAll: () => dispatch(actions.collapseAllExplorer()), newSection: obj => dispatch(actions.newSection(obj)), newNote: obj => dispatch(actions.newNote(obj)), + moveObj: (payload: MoveNotepadObjectAction) => dispatch(actions.moveNotepadObject(payload)), expandFromNote: note => { if (!notepad) return; dispatch(actions.expandFromNote({ diff --git a/app/src/app/components/explorer/explorer-drag.ts b/app/src/app/components/explorer/explorer-drag.ts new file mode 100644 index 00000000..7cbba10e --- /dev/null +++ b/app/src/app/components/explorer/explorer-drag.ts @@ -0,0 +1,15 @@ +/** + * Shared bits for drag-and-drop of sections/notes in the notepad explorer. + * + * The live drag payload is held in component state rather than read from + * `dataTransfer` because `getData()` is blocked during `dragover` events + * ("protected mode"). `setData()` is still called with this MIME type so that + * Firefox starts the drag and so other drop targets (like the note viewer's + * file drop) can recognise and ignore explorer drags via `dataTransfer.types`. + */ +export const EXPLORER_DRAG_MIME = 'application/x-micropad-move'; + +export type ExplorerDragItem = { + type: 'section' | 'note', + internalRef: string +}; diff --git a/app/src/app/components/note-viewer/NoteViewerComponent.tsx b/app/src/app/components/note-viewer/NoteViewerComponent.tsx index 59b87eb6..1fdac4fd 100644 --- a/app/src/app/components/note-viewer/NoteViewerComponent.tsx +++ b/app/src/app/components/note-viewer/NoteViewerComponent.tsx @@ -12,6 +12,7 @@ import * as FullScreenService from '../../services/FullscreenService'; import { IInsertElementState } from '../../reducers/NoteReducer'; import { TOAST_HANDLER } from '../../root'; import { elementFromInteraction } from '../../services/quick-insert'; +import { EXPLORER_DRAG_MIME } from '../explorer/explorer-drag'; export interface INoteViewerComponentProps { isLoading: boolean; @@ -222,6 +223,9 @@ export default class NoteViewerComponent extends React.Component { event.preventDefault(); + // Sections/notes dragged from the explorer are not file drops + if (event.dataTransfer?.types.includes(EXPLORER_DRAG_MIME)) return; + const { note, isFullscreen } = this.props; if (!note) return; diff --git a/app/src/app/components/note-viewer/elements/NoteElementComponent.css b/app/src/app/components/note-viewer/elements/NoteElementComponent.css index 944b5000..4f877dc3 100644 --- a/app/src/app/components/note-viewer/elements/NoteElementComponent.css +++ b/app/src/app/components/note-viewer/elements/NoteElementComponent.css @@ -19,6 +19,18 @@ min-height: 130px; } +/* The wrapper has 1000px right/bottom padding, so the flash targets the visible inner box */ +.noteElement--flash > .z-depth-2 { + animation: note-element-flash 1.5s ease-out 1; +} + +@keyframes note-element-flash { + 0% { box-shadow: 0 0 0 4px var(--mp-theme-accent), 0 0 24px 8px var(--mp-theme-accent); } + 60% { box-shadow: 0 0 0 4px var(--mp-theme-accent), 0 0 16px 4px var(--mp-theme-accent); } + /* fade back into materialize's z-depth-2 shadow so there's no pop at the end */ + 100% { box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12), 0 2px 4px -1px rgba(0, 0, 0, 0.3); } +} + .handle { text-align: center; user-select: none; diff --git a/app/src/app/components/note-viewer/elements/markdown/MarkdownElementComponent.tsx b/app/src/app/components/note-viewer/elements/markdown/MarkdownElementComponent.tsx index ba356c88..483cda3d 100644 --- a/app/src/app/components/note-viewer/elements/markdown/MarkdownElementComponent.tsx +++ b/app/src/app/components/note-viewer/elements/markdown/MarkdownElementComponent.tsx @@ -174,6 +174,7 @@ export default class MarkdownElementComponent extends React.Component { height: '400px', backgroundColor: theme.background, color: theme.text, + fontFamily: '"OpenDyslexic", sans-serif', whiteSpace: this.props.shouldWordWrap ? 'break-spaces' : 'pre', overflowWrap: 'normal', overflowX: 'auto' @@ -197,6 +198,7 @@ export default class MarkdownElementComponent extends React.Component { theme="micropad" options={{ automaticLayout: true, + fontFamily: '"OpenDyslexic", sans-serif', minimap: { enabled: false }, wordWrap: this.props.shouldWordWrap ? 'on' : 'off', wrappingIndent: 'same', @@ -232,6 +234,9 @@ export default class MarkdownElementComponent extends React.Component { componentDidMount() { this.componentDidUpdate(this.props); window.addEventListener('message', this.handleMessages); + + // Monaco measures glyph sizes on startup, which can happen before OpenDyslexic has loaded + document.fonts.load('1em OpenDyslexic').then(() => monaco.editor.remeasureFonts()); } componentWillUnmount() { diff --git a/app/src/app/components/note-viewer/elements/markdown/MarkdownViewerHtml.tsx b/app/src/app/components/note-viewer/elements/markdown/MarkdownViewerHtml.tsx index 677dc90e..b06f1426 100644 --- a/app/src/app/components/note-viewer/elements/markdown/MarkdownViewerHtml.tsx +++ b/app/src/app/components/note-viewer/elements/markdown/MarkdownViewerHtml.tsx @@ -17,6 +17,7 @@ export const getHtml = (id: string, theme: ITheme, fontSize: string = '16px'): s +