refactor changes

This commit is contained in:
pj committed 2026-08-30 16:58:02 +05:30
1 parent c1c47bc513
commit f7a536b2c3
58 files changed
+2507 -303

No files matched your search

+5 -1
View File
@@ -11,7 +11,9 @@
"tauri": "tauri", "tauri": "tauri",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ui": "playwright test" "test:ui": "playwright test",
"fonts:sync": "node node_modules/margin-shared/bin/sync-fonts.mjs .",
"fonts:check": "node node_modules/margin-shared/bin/sync-fonts.mjs . --check"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
@@ -28,8 +30,10 @@
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"katex": "^0.18.4", "katex": "^0.18.4",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"margin-shared": "file:../../python/margin/shared",
"mdast-util-to-markdown": "^2", "mdast-util-to-markdown": "^2",
"mermaid": "^11.17.1", "mermaid": "^11.17.1",
"pdfjs-dist": "^6.2.108",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"remark-frontmatter": "^5", "remark-frontmatter": "^5",
+138
View File
@@ -50,12 +50,18 @@ importers:
lowlight: lowlight:
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.3.0 version: 3.3.0
margin-shared:
specifier: file:../../python/margin/shared
version: file:../../python/margin/shared
mdast-util-to-markdown: mdast-util-to-markdown:
specifier: ^2 specifier: ^2
version: 2.1.2 version: 2.1.2
mermaid: mermaid:
specifier: ^11.17.1 specifier: ^11.17.1
version: 11.17.1 version: 11.17.1
pdfjs-dist:
specifier: ^6.2.108
version: 6.2.108
react: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.2.8 version: 19.2.8
@@ -402,6 +408,76 @@ packages:
'@mermaid-js/[email protected]': '@mermaid-js/[email protected]':
resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==}
'@napi-rs/[email protected]':
resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==}
engines: {node: '>= 10'}
'@napi-rs/[email protected]': '@napi-rs/[email protected]':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25} engines: {node: ^22.20 || ^24.12 || >=25}
@@ -1370,6 +1446,10 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
margin-shared@file:../../python/margin/shared:
resolution: {directory: ../../python/margin/shared, type: directory}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -1538,6 +1618,10 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'} engines: {node: '>= 14.16'}
[email protected]:
resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==}
engines: {node: '>=22.13.0 || >=24'}
[email protected]: [email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2112,6 +2196,54 @@ snapshots:
dependencies: dependencies:
'@chevrotain/types': 11.1.2 '@chevrotain/types': 11.1.2
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optional: true
'@napi-rs/[email protected]':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 1.0.8
'@napi-rs/canvas-darwin-arm64': 1.0.8
'@napi-rs/canvas-darwin-x64': 1.0.8
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8
'@napi-rs/canvas-linux-arm64-gnu': 1.0.8
'@napi-rs/canvas-linux-arm64-musl': 1.0.8
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.8
'@napi-rs/canvas-linux-x64-gnu': 1.0.8
'@napi-rs/canvas-linux-x64-musl': 1.0.8
'@napi-rs/canvas-win32-arm64-msvc': 1.0.8
'@napi-rs/canvas-win32-x64-msvc': 1.0.8
optional: true
'@napi-rs/[email protected]': '@napi-rs/[email protected]':
optional: true optional: true
@@ -3072,6 +3204,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
margin-shared@file:../../python/margin/shared: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -3450,6 +3584,10 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
optionalDependencies:
'@napi-rs/canvas': 1.0.8
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
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:
https://openfontlicense.org
-----------------------------------------------------------
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.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
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.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
Copyright 2021 The Hanken Grotesk Project Authors (https://github.com/marcologous/hanken-grotesk)
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.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
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.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
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:
https://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.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
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:
https://openfontlicense.org
-----------------------------------------------------------
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.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 685 B

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1022 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+13 -11
View File
@@ -1,12 +1,14 @@
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- Margin Docs mark: a page with a margin rule. Glyph only, matching margin's logo-dark.svg conventions. <!-- Margin Docs mark: stacked text bars, a paragraph seen as an alignment glyph. Source of truth
App icon composition on a 512 canvas: squircle rect x=49 y=49 w=414 h=414 rx=92.5 fill #0d0c0a, for every file in this directory; regenerate them with `pnpm tauri icon` after editing.
then this glyph under transform="translate(56,48) scale(1.142857142857)". Squircle 828 wide on a 1024 canvas is the macOS icon inset, and every coordinate is a multiple
That scale puts every stroke centreline on a pixel centre at 32x32, and lifts the glyph 8px of 32 so bars and gaps land on whole pixels at 32x32. The block spans y 224..768 against a
above the geometric centre for optical balance. --> centre of 512, which lifts it 16 above the geometric centre for optical balance. -->
<rect x="70" y="35" width="210" height="280" rx="14" stroke="#fcfbf7" stroke-width="10"/> <rect x="98" y="98" width="828" height="828" rx="185" fill="#0d0c0a"/>
<rect x="93" y="72" width="10" height="206" rx="5" fill="#fcfbf7"/> <g fill="#fcfbf7">
<rect x="135" y="100" width="122" height="10" rx="5" fill="#fcfbf7"/> <rect x="224" y="224" width="576" height="64" rx="32"/>
<rect x="135" y="170" width="122" height="10" rx="5" fill="#fcfbf7"/> <rect x="224" y="384" width="416" height="64" rx="32"/>
<rect x="135" y="240" width="80" height="10" rx="5" fill="#fcfbf7"/> <rect x="224" y="544" width="576" height="64" rx="32"/>
<rect x="224" y="704" width="288" height="64" rx="32"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 915 B

After

Width:  |  Height:  |  Size: 895 B

+39
View File
@@ -141,3 +141,42 @@ fn collect_installed(
.map(|family| (*family).to_string()) .map(|family| (*family).to_string())
.collect() .collect()
} }
// ------------------------------------------------------------------------------------------------
// The document's own face
//
// Everything above is about the two families the bundle is missing. What follows is about the one
// the author chose: src/model/fonts.ts offers six bundled families and whatever this machine has,
// and an export has to be handed the bytes of whichever it names.
// ------------------------------------------------------------------------------------------------
/// Every family this machine has, for the "System" group of the font picker.
///
/// Sorted and deduplicated here rather than in the UI, because a font book has one entry per face
/// and the picker wants one per family: a machine with the full Helvetica Neue family would
/// otherwise show it nine times.
#[tauri::command(async)]
pub fn fonts_list_system() -> Vec<String> {
let db = system_db();
let mut names: Vec<String> = db
.faces()
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
.collect();
names.sort();
names.dedup();
names
}
/// The upright and italic cuts of one installed family, for a document set in a face off the
/// machine.
///
/// Four queries and not two, because a family whose bold is a separate file gives fontdb a
/// different id for it and the collection has to be loaded either way. `seen` is what stops a `.ttc`
/// holding all four from being read four times.
pub fn system_faces(family: &str) -> Vec<Vec<u8>> {
let db = system_db();
let mut fonts = Vec::new();
let mut seen = HashSet::new();
faces_for(&db, family, &mut fonts, &mut seen);
fonts
}
+1
View File
@@ -297,6 +297,7 @@ pub fn run() {
pdf::pdf_write, pdf::pdf_write,
grammar::grammar_available, grammar::grammar_available,
grammar::grammar_check, grammar::grammar_check,
fonts::fonts_list_system,
]) ])
.run(context) .run(context)
.expect("error while running Margin Docs"); .expect("error while running Margin Docs");
+70 -2
View File
@@ -41,6 +41,46 @@ static FACES: [&[u8]; 9] = [
include_bytes!("../fonts/HankenGrotesk-BoldItalic.ttf"), include_bytes!("../fonts/HankenGrotesk-BoldItalic.ttf"),
]; ];
// The other four families src/model/fonts.ts offers, in the variable files public/fonts already
// ships for the editor, and loaded only when a document is actually set in one.
//
// These carry the caveat the nine above exist to avoid, and it is not worth cutting eight more
// static instances to fix. Typst lays a variable file out at its default instance, so a document
// set in EB Garamond exports with its headings at the same weight as its body: the face is right,
// the hierarchy is flatter than the screen. The two the app defaults to are the two that export
// perfectly, and choosing one of these is choosing a face rather than a fallback.
static EB_GARAMOND: [&[u8]; 2] = [
include_bytes!("../../public/fonts/EBGaramond-VF.ttf"),
include_bytes!("../../public/fonts/EBGaramond-Italic-VF.ttf"),
];
static LORA: [&[u8]; 2] = [
include_bytes!("../../public/fonts/Lora-VF.ttf"),
include_bytes!("../../public/fonts/Lora-Italic-VF.ttf"),
];
static SOURCE_SERIF: [&[u8]; 2] = [
include_bytes!("../../public/fonts/SourceSerif4-VF.ttf"),
include_bytes!("../../public/fonts/SourceSerif4-Italic-VF.ttf"),
];
static FRAUNCES: [&[u8]; 2] = [
include_bytes!("../../public/fonts/Fraunces-VF.ttf"),
include_bytes!("../../public/fonts/Fraunces-Italic-VF.ttf"),
];
/// The bytes behind one `BundledFont` id from src/model/fonts.ts.
///
/// Literata and Hanken Grotesk are absent on purpose rather than missing: `FACES` already holds
/// their nine static instances and they are loaded for every export, so naming either here would
/// hand Typst the same family twice and let the variable copy win.
fn bundled_faces(id: &str) -> &'static [&'static [u8]] {
match id {
"eb-garamond" => &EB_GARAMOND,
"lora" => &LORA,
"source-serif" => &SOURCE_SERIF,
"fraunces" => &FRAUNCES,
_ => &[],
}
}
// mitex 0.2.5, vendored under src-tauri/vendor/mitex with its LICENSE, and served as ordinary // mitex 0.2.5, vendored under src-tauri/vendor/mitex with its LICENSE, and served as ordinary
// paths under `/mitex/` rather than through Typst's package system. A package spec would mean a // paths under `/mitex/` rather than through Typst's package system. A package spec would mean a
// download on first export and a cache directory to keep, for a dependency that is 380K and never // download on first export and a cache directory to keep, for a dependency that is 380K and never
@@ -242,7 +282,7 @@ fn compile_once(
Ok((bytes, warnings.into_iter().collect())) Ok((bytes, warnings.into_iter().collect()))
} }
/// Compiles Typst source to PDF bytes, with whatever the compiler had to work around. /// Compiles Typst source to PDF bytes, in the app's default pair of faces.
/// ///
/// Separate from the command so a test can reach it: a `#[tauri::command]` taking an `AppHandle` /// Separate from the command so a test can reach it: a `#[tauri::command]` taking an `AppHandle`
/// needs a running app, and none of the work below wants one. /// needs a running app, and none of the work below wants one.
@@ -250,6 +290,22 @@ pub fn compile(
source: String, source: String,
images: &[ImageInput], images: &[ImageInput],
root_paths: &[String], root_paths: &[String],
) -> Result<(Vec<u8>, Vec<PdfWarning>), String> {
compile_in(source, images, root_paths, &[], &[])
}
/// The same, for a document set in something other than the default.
///
/// `bundled` are ids from src/model/fonts.ts and `system` are family names off this machine, both
/// as the converter named them in the preamble it wrote. Naming a face Typst has no bytes for is a
/// warning and a fallback rather than a failure, so an id this build does not know and a family
/// that has since been uninstalled both end in a PDF.
pub fn compile_in(
source: String,
images: &[ImageInput],
root_paths: &[String],
bundled: &[String],
system: &[String],
) -> Result<(Vec<u8>, Vec<PdfWarning>), String> { ) -> Result<(Vec<u8>, Vec<PdfWarning>), String> {
let mut warnings: Vec<PdfWarning> = Vec::new(); let mut warnings: Vec<PdfWarning> = Vec::new();
@@ -273,6 +329,16 @@ pub fn compile(
let mut fonts: Vec<Vec<u8>> = FACES.iter().map(|face| face.to_vec()).collect(); let mut fonts: Vec<Vec<u8>> = FACES.iter().map(|face| face.to_vec()).collect();
fonts.append(&mut fallbacks.fonts); fonts.append(&mut fallbacks.fonts);
// The face the author chose, if it is not one of the two already in `FACES`. A system family is
// read off this machine, which is the one place an export can differ between two computers, and
// is the caveat the picker states next to the "System" group.
for id in bundled {
fonts.extend(bundled_faces(id).iter().map(|face| face.to_vec()));
}
for family in system {
fonts.extend(crate::fonts::system_faces(family));
}
let source = format!("{}{source}", font_preamble(&fallbacks)); let source = format!("{}{source}", font_preamble(&fallbacks));
let failure = match compile_once(&source, MITEX_LIB, &binaries, &fonts) { let failure = match compile_once(&source, MITEX_LIB, &binaries, &fonts) {
@@ -322,6 +388,8 @@ pub fn pdf_compile(
app: tauri::AppHandle, app: tauri::AppHandle,
source: String, source: String,
images: Vec<ImageInput>, images: Vec<ImageInput>,
bundled_fonts: Vec<String>,
system_fonts: Vec<String>,
) -> Result<tauri::ipc::Response, String> { ) -> Result<tauri::ipc::Response, String> {
// The same gate `fs::checked` puts in front of every other read, reached the same way it is: // The same gate `fs::checked` puts in front of every other read, reached the same way it is:
// the open roots out of shared state, then `resolve_in_roots`. The lock is dropped before the // the open roots out of shared state, then `resolve_in_roots`. The lock is dropped before the
@@ -332,7 +400,7 @@ pub fn pdf_compile(
open.iter().map(|root| root.path.clone()).collect() open.iter().map(|root| root.path.clone()).collect()
}; };
let (bytes, warnings) = compile(source, &images, &root_paths)?; let (bytes, warnings) = compile_in(source, &images, &root_paths, &bundled_fonts, &system_fonts)?;
if !warnings.is_empty() { if !warnings.is_empty() {
app.emit("pdf-warnings", warnings).ok(); app.emit("pdf-warnings", warnings).ok();
} }
+47 -1
View File
@@ -6,7 +6,7 @@
use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::STANDARD;
use base64::Engine; use base64::Engine;
use margin_docs_lib::dto::ImageInput; use margin_docs_lib::dto::ImageInput;
use margin_docs_lib::pdf::compile; use margin_docs_lib::pdf::{compile, compile_in};
use tempfile::TempDir; use tempfile::TempDir;
/// A real 4x4 PNG, so an image that is meant to be read is one Typst can actually decode, and one /// A real 4x4 PNG, so an image that is meant to be read is one Typst can actually decode, and one
@@ -314,3 +314,49 @@ fn every_weight_the_converter_asks_for_has_a_face_of_its_own() {
} }
} }
} }
/// A document set in one of the four extra bundled families really is typeset in it.
///
/// The bytes are compiled into the binary but only loaded when the front end names the id, so the
/// thing worth proving is that naming it changes the page: the same word in EB Garamond and in the
/// default face has to be two different documents. A family Typst cannot see is not an error, it is
/// a warning and a fallback, so the negative case would pass without the loading working at all.
#[test]
fn a_document_set_in_a_bundled_face_is_typeset_in_it() {
let (_dir, roots) = root();
let word = |family: &str, bundled: &[String]| {
let source = format!("{PREAMBLE}#text(font: \"{family}\")[Margin]\n");
compile_in(source, &[], &roots, bundled, &[]).expect("the face typesets").0
};
let default = word("Literata", &[]);
for (id, family) in [
("eb-garamond", "EB Garamond"),
("lora", "Lora"),
("source-serif", "Source Serif 4"),
("fraunces", "Fraunces"),
] {
let chosen = word(family, &[id.to_string()]);
assert_ne!(default, chosen, "{family} came out as the default face");
}
}
/// A face the compiler cannot find costs a warning and never the export.
///
/// Both halves reach here in the ordinary run of things. A bundled id is a preference written by
/// some other version of the app, and a system family is one the user has uninstalled since they
/// chose it; neither is a reason to refuse somebody their PDF.
#[test]
fn a_face_that_is_not_there_still_exports() {
let (_dir, roots) = root();
let source = format!("{PREAMBLE}#text(font: \"Nothing Installed\")[Margin]\n");
let (bytes, _) = compile_in(
source,
&[],
&roots,
&["a-face-no-build-has".to_string()],
&["Nothing Installed".to_string()],
)
.expect("an unknown family falls back rather than failing");
assert!(bytes.starts_with(b"%PDF-"), "the export still happened");
}
+11
View File
@@ -15,6 +15,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { Backlinks } from "./components/Backlinks"; import { Backlinks } from "./components/Backlinks";
import { CommandPalette } from "./components/CommandPalette"; import { CommandPalette } from "./components/CommandPalette";
import { ConflictDialog } from "./components/ConflictDialog"; import { ConflictDialog } from "./components/ConflictDialog";
import { ExportPreview } from "./components/ExportPreview";
import { FindBar } from "./components/FindBar"; import { FindBar } from "./components/FindBar";
import { FindInFiles } from "./components/FindInFiles"; import { FindInFiles } from "./components/FindInFiles";
import { ProofPopover } from "./components/ProofPopover"; import { ProofPopover } from "./components/ProofPopover";
@@ -36,6 +37,7 @@ import { handleMenuAction } from "./keys/menu";
import { openLink } from "./links"; import { openLink } from "./links";
import { documentKindForPath } from "./model/doc"; import { documentKindForPath } from "./model/doc";
import { useDocument } from "./store/useDocument"; import { useDocument } from "./store/useDocument";
import { useDocumentFonts } from "./store/useDocumentFonts";
import { notify } from "./store/useToast"; import { notify } from "./store/useToast";
import { useWorkspace } from "./store/useWorkspace"; import { useWorkspace } from "./store/useWorkspace";
import { startUpdateChecks } from "./update"; import { startUpdateChecks } from "./update";
@@ -97,6 +99,14 @@ function App() {
}; };
}, []); }, []);
// The face the page is set in belongs to the document rather than to the app, so it is applied
// here, on the path, rather than restored once at boot the way the theme and the width are. A
// closed document takes the app back to its default pair, which is what the empty pane behind it
// is drawn in anyway.
useEffect(() => {
useDocumentFonts.getState().openFor(path);
}, [path]);
useEffect(() => { useEffect(() => {
const stops = [ const stops = [
onCommand("editor-width-narrow", () => applyWidth("narrow")), onCommand("editor-width-narrow", () => applyWidth("narrow")),
@@ -176,6 +186,7 @@ function App() {
<QuickOpen /> <QuickOpen />
<FindInFiles /> <FindInFiles />
<CommandPalette /> <CommandPalette />
<ExportPreview />
<ProofPopover /> <ProofPopover />
<Shortcuts /> <Shortcuts />
<Settings /> <Settings />
+14
View File
@@ -0,0 +1,14 @@
// The machine's font book, for the half of the font picker this app does not ship.
import { call } from "../ipc";
/**
* Every family installed on this machine, sorted and deduplicated by the backend.
*
* Answers with an empty list rather than rejecting when there is no backend to ask, because the
* picker's bundled half works perfectly well without this one and an empty "System" group is the
* honest way to say the machine was not asked. The list is worth caching in the caller: it walks
* several directories on the Rust side and cannot change while the app is running.
*/
export const fontsListSystem = (): Promise<string[]> =>
call<string[]>("fonts_list_system").catch(() => []);
+15 -2
View File
@@ -1,13 +1,26 @@
// PDF export. The source is built here and compiled there; these two calls are the boundary. // PDF export. The source is built here and compiled there; these two calls are the boundary.
import { call, type ImageInput } from "../ipc"; import { call, type ImageInput } from "../ipc";
import { fontsUsed, type DocumentFonts } from "../model/fonts";
/** /**
* Compiles Typst source to PDF bytes. Warnings do not come back through the return value, because * Compiles Typst source to PDF bytes. Warnings do not come back through the return value, because
* the answer is raw bytes and has no room for a second one: they arrive on `pdf-warnings` instead. * the answer is raw bytes and has no room for a second one: they arrive on `pdf-warnings` instead.
*
* `fonts` is the document's own pair, and what crosses the boundary is which faces to load rather
* than their bytes: a bundled id the backend already has compiled in, a system family name it can
* look up. The source names the families itself, so this only has to make sure the compiler can
* see them.
*/ */
export const pdfCompile = (source: string, images: readonly ImageInput[]) => export const pdfCompile = (source: string, images: readonly ImageInput[], fonts: DocumentFonts) => {
call<ArrayBuffer>("pdf_compile", { source, images }); const used = fontsUsed(fonts);
return call<ArrayBuffer>("pdf_compile", {
source,
images,
bundledFonts: used.bundled,
systemFonts: used.system,
});
};
/** /**
* Writes the finished file wherever the native save panel pointed. * Writes the finished file wherever the native save panel pointed.
+280
View File
@@ -0,0 +1,280 @@
// Document setup: the panel the filename in the title bar opens.
//
// The sibling book app puts the same thing behind the same gesture. Its title bar shows the book's
// title, clicking it opens Book setup, and the faces the book is set in are a section of that
// panel. This is that panel for a document editor, so the two apps answer a click on the title the
// same way, and the two font pickers are the same picker.
//
// What a document has to set up is a shorter list than a book's. There is no author, no ISBN and no
// trim size, because none of those are things a markdown file holds: its metadata is frontmatter,
// which src/model/doc.ts carries opaque and never parses. What is left is the two things that are
// genuinely the document's own, its name on disk and the faces it is set in.
//
// A draft, applied on Save, which is again the sibling's shape. Nothing here touches the open
// document until the button is pressed: changing a face and pressing Escape leaves the page exactly
// as it was. The specimen at the foot of the panel is what stands in for a live preview, and it is
// the better trade for a rename, where "applied as you type" would mean a file moving on disk once
// per keystroke.
import { useEffect, useRef, useState } from "react";
import { icons } from "margin-shared";
import { useEscapeLayer } from "../escape";
import { useKeyContext } from "../keys/keymap";
import {
BUNDLED_FONTS,
DEFAULT_FONTS,
FONT_PAIRINGS,
decodeRef,
encodeRef,
fontStack,
fontsEqual,
pairingFor,
type DocumentFonts,
type FontRef,
} from "../model/fonts";
import { fontsListSystem } from "../api/fonts";
import { useDocumentFonts } from "../store/useDocumentFonts";
import { notify } from "../store/useToast";
import { useWorkspace } from "../store/useWorkspace";
import { splitExtension } from "./FileTree";
import { Icon } from "./Icon";
/**
* The machine's font book, asked once per launch and kept for every later open of this panel.
*
* It walks several directories on the Rust side and cannot change while the app is running, so a
* second ask would be the same list at the same cost.
*/
let systemFontCache: string[] | null = null;
interface Draft {
name: string;
fonts: DocumentFonts;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
</label>
);
}
/**
* One slot's picker: the six that travel with the app, then whatever this machine has.
*
* A stored family the machine no longer has is put back at the top of its own group rather than
* dropped, so the select shows what the document actually asks for instead of silently reading as
* some other face.
*/
function FontSelect({
value,
system,
onChange,
}: {
value: FontRef;
system: readonly string[];
onChange: (ref: FontRef) => void;
}) {
const missing = value.kind === "system" && !system.includes(value.family) ? [value.family] : [];
return (
<select
value={encodeRef(value)}
style={{ fontFamily: fontStack(value) }}
onChange={(e) => onChange(decodeRef(e.target.value))}
>
<optgroup label="Bundled">
{BUNDLED_FONTS.map((f) => (
<option key={f.id} value={`b:${f.id}`}>
{f.label}
</option>
))}
</optgroup>
{(system.length > 0 || missing.length > 0) && (
<optgroup label="System">
{[...missing, ...system].map((family) => (
<option key={family} value={`s:${family}`}>
{family}
</option>
))}
</optgroup>
)}
</select>
);
}
export function DocumentSetup({ path, onClose }: { path: string; onClose: () => void }) {
const fonts = useDocumentFonts((s) => s.fonts);
const setFonts = useDocumentFonts((s) => s.setFonts);
const renameEntry = useWorkspace((s) => s.renameEntry);
const fileName = path.slice(path.lastIndexOf("/") + 1);
const { base, hidden } = splitExtension(fileName);
const [draft, setDraft] = useState<Draft>(() => ({ name: base, fonts }));
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
// Open on the two selects when the document is in a pair no preset names, because that is the
// state somebody arrived at by using them and the panel should not hide the controls that got
// them there.
const [advanced, setAdvanced] = useState(() => pairingFor(fonts) === null);
const nameRef = useRef<HTMLInputElement>(null);
useEscapeLayer(true, onClose);
useKeyContext("overlay");
useEffect(() => {
nameRef.current?.focus();
nameRef.current?.select();
}, []);
useEffect(() => {
if (systemFontCache) return;
let live = true;
void fontsListSystem().then((list) => {
systemFontCache = list;
if (live) setSystem(list);
});
return () => {
live = false;
};
}, []);
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
const setSlot = (slot: "body" | "heading", ref: FontRef) =>
set({ fonts: { ...draft.fonts, [slot]: ref } });
const activePreset = pairingFor(draft.fonts);
const usesSystem = draft.fonts.body.kind === "system" || draft.fonts.heading.kind === "system";
const save = () => {
if (!fontsEqual(draft.fonts, fonts)) setFonts(draft.fonts);
// The rename goes last and the panel closes either way. A file that could not be renamed is a
// toast and not a reason to throw away the face the user just chose, and the two are unrelated
// enough that failing one must not roll back the other.
const trimmed = draft.name.trim();
if (trimmed && trimmed !== base) {
renameEntry(path, `${trimmed}${hidden}`).catch((e) =>
notify(`Could not rename: ${String(e)}`),
);
}
onClose();
};
return (
<div className="overlay" onClick={onClose}>
<div
className="panel panel-setup"
role="dialog"
aria-modal="true"
aria-label="Document setup"
onClick={(e) => e.stopPropagation()}
>
<div className="panel-head">
<h2>Document setup</h2>
<button className="icon-button" onClick={onClose} title="Close (⎋)" aria-label="Close">
<Icon d={icons.CLOSE} />
</button>
</div>
<div className="panel-body">
{/* The file on disk, and the only thing that ever renames it. A document's H1 is content
and is left alone: a path is what git, every other editor and every relative link from
another document already agreed on. Markdown's own extension is hidden here the same
way the file tree hides it, and put back on the way out. */}
<Field label="Name">
<input
ref={nameRef}
value={draft.name}
placeholder={base}
spellCheck={false}
autoComplete="off"
onChange={(e) => set({ name: e.target.value })}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
save();
}}
/>
</Field>
<div className="field">
<span className="field-label">Typography</span>
<div className="font-presets">
{FONT_PAIRINGS.map((p) => (
<button
key={p.id}
type="button"
className="font-preset"
data-on={activePreset === p.id}
onClick={() => set({ fonts: { body: p.body, heading: p.heading } })}
>
<span className="font-preset-demo" style={{ fontFamily: fontStack(p.heading) }}>
Ag
</span>
<span className="font-preset-name">{p.label}</span>
</button>
))}
</div>
<button type="button" className="font-advanced" onClick={() => setAdvanced((v) => !v)}>
{advanced ? "Hide custom fonts" : "Customize fonts"}
</button>
</div>
{advanced && (
<>
<Field label="Body font">
<FontSelect
value={draft.fonts.body}
system={system}
onChange={(ref) => setSlot("body", ref)}
/>
</Field>
<Field label="Heading font">
<FontSelect
value={draft.fonts.heading}
system={system}
onChange={(ref) => setSlot("heading", ref)}
/>
</Field>
{usesSystem && (
<p className="font-note">
A font off this machine looks right here and in a PDF exported here, but it is not
part of the app, so another computer may not have it.
</p>
)}
</>
)}
{/* A heading and a line of prose in the pair being chosen, which is the smallest sample
that shows both slots and the relationship between them. */}
<div className="font-sample" style={{ fontFamily: fontStack(draft.fonts.body) }}>
<span
className="font-sample-title"
style={{ fontFamily: fontStack(draft.fonts.heading) }}
>
What lives where
</span>
She read the first line twice, and the whole quiet house seemed to lean in to listen.
</div>
</div>
<div className="panel-foot">
{!fontsEqual(draft.fonts, DEFAULT_FONTS) && (
<button
type="button"
className="btn-ghost"
onClick={() => set({ fonts: DEFAULT_FONTS })}
>
Reset fonts
</button>
)}
<button className="btn-primary" onClick={save}>
Save
</button>
</div>
</div>
</div>
);
}
+475
View File
@@ -0,0 +1,475 @@
// The PDF preview: what Cmd-E opens now, and where Save PDF lives.
//
// The sibling book app answers Export with this same panel, and both apps answer it this way for
// the same reason. A PDF is the one thing either of them produces that its author cannot see
// before it exists: the editor draws a document on a scrolling sheet with no page breaks in it, and
// where the pages fall, whether a diagram drew, whether a formula typeset and whether a table fits
// the measure are all only knowable after the compiler has run. Sending that straight to a save
// panel means the first look at the result happens in Preview.app, after a file has been written.
// So the compile happens first, the pages go on screen, and the save panel is a button somebody
// presses once they have seen what they are about to write.
//
// It is drawn over the editor pane rather than over the window, which is the sibling's shape too:
// the sidebar stays where it is, and the pages appear where the page was. That is also why the
// panel measures `.editor-pane` and pins itself to it instead of being a child of it. The pane
// scrolls, and a child of a scrolling container that has to stay still is a fight with the
// scrollbar rather than a layout.
//
// The document cannot change under a preview: nothing in this panel edits, and the pane behind it
// is covered. The sidebar is not, so switching documents closes the panel rather than leaving the
// pages of one document over the name of another.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import { icons } from "margin-shared";
import { useEscapeLayer } from "../escape";
import { compile, save, suggestedName, type CompiledPdf } from "../export/run";
import { onCommand } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { useDocument } from "../store/useDocument";
import { notify } from "../store/useToast";
import { Icon } from "./Icon";
/**
* pdf.js, fetched the first time somebody previews something and kept for every preview after.
*
* Imported here rather than at the top of the file because it is a megabyte and a half of reader
* and a separate worker, and an editor that has not been asked for a PDF has no use for either. It
* is the same call mermaid gets in src/export/typst.ts, for the same reason. The types above are
* `import type` and cost nothing: they are erased before anything runs.
*
* The worker is a bundled asset URL rather than a path, so the bundler emits it and the app's CSP
* sees it as its own origin. `worker-src 'self' blob:` in tauri.conf.json is what lets pdf.js
* start it at all.
*/
type Pdfjs = typeof import("pdfjs-dist");
let reader: Promise<Pdfjs> | null = null;
function loadPdfjs(): Promise<Pdfjs> {
reader ??= (async () => {
const [pdfjs, worker] = await Promise.all([
import("pdfjs-dist"),
import("pdfjs-dist/build/pdf.worker.min.mjs?url"),
]);
pdfjs.GlobalWorkerOptions.workerSrc = worker.default;
return pdfjs;
})();
return reader;
}
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3;
const ZOOM_STEP = 0.25;
/** Zoom glyphs, which are a minus and a plus and are not worth a name in margin-shared. */
const MINUS = "M5 12h14";
const PLUS = "M12 5v14M5 12h14";
/** How far outside the viewport a page is drawn before it is scrolled to. */
const AHEAD = "1400px 0px";
interface Frame {
left: number;
top: number;
width: number;
height: number;
}
function measurePane(): Frame | null {
const pane = document.querySelector(".editor-pane");
if (!pane) return null;
const r = pane.getBoundingClientRect();
return {
left: Math.round(r.left),
top: Math.round(r.top),
width: Math.round(r.width),
height: Math.round(r.height),
};
}
/**
* The mount, which is one `onCommand` and nothing else.
*
* src/keys/commands.ts dispatches `export-pdf` once it has checked there is a document and a window
* to put a file in, exactly as it does for quick open and the command palette. Nothing about the
* panel's existence is a precondition for that table to compile.
*/
export function ExportPreview() {
const [open, setOpen] = useState(false);
const path = useDocument((s) => s.path);
useEffect(() => onCommand("export-pdf", () => setOpen(true)), []);
useEffect(() => setOpen(false), [path]);
if (!open || path === null) return null;
return <Preview path={path} onClose={() => setOpen(false)} />;
}
function Preview({ path, onClose }: { path: string; onClose: () => void }) {
const [pdf, setPdf] = useState<CompiledPdf | null>(null);
const [error, setError] = useState<string | null>(null);
const [pages, setPages] = useState(0);
const [zoom, setZoom] = useState(1);
const [saving, setSaving] = useState(false);
const [frame, setFrame] = useState<Frame | null>(measurePane);
const panelRef = useRef<HTMLDivElement>(null);
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const started = useRef(false);
const live = useRef(true);
useEscapeLayer(true, onClose);
useKeyContext("overlay");
// Where the editor pane is, now and whenever it moves. Dragging the sidebar's resize handle
// moves it without the window changing size, hence the observer as well as the resize listener.
useLayoutEffect(() => {
const update = () => setFrame(measurePane());
update();
const pane = document.querySelector(".editor-pane");
const observer = new ResizeObserver(update);
if (pane) observer.observe(pane);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("resize", update);
};
}, []);
// Once, on the way in. The document behind the panel cannot change while it is up, so there is
// nothing to recompile for, and a compile per keystroke is not what a preview of a fifty page
// document should cost.
//
// Two refs rather than the usual `let cancelled` because of what React's development StrictMode
// does to an effect: it runs it, tears it down and runs it again on the same instance, to shake
// out cleanups that do not clean up. That is the right test for a subscription and the wrong one
// for a typesetter. `started` is what makes the compile happen once per open, and `live` is what
// an in-flight result is checked against, because the plain closure flag would have been set by
// the teardown between the two runs and the only answer this panel ever gets would be dropped.
// Both reset with the component, which is unmounted when the panel closes.
useEffect(() => {
live.current = true;
if (!started.current) {
started.current = true;
compile()
.then((result) => live.current && setPdf(result))
.catch((e) => live.current && setError(String(e)));
}
return () => {
live.current = false;
};
}, []);
// Pinch on a trackpad and Ctrl-scroll on a mouse, which are the two gestures a page of a document
// is expected to answer. Both have to be taken off the window rather than left to the browser:
// the webview's own zoom would scale the chrome around the pages as well.
useEffect(() => {
const el = panelRef.current;
if (!el) return;
const clamp = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
const snap = (z: number) => clamp(Math.round(z / ZOOM_STEP) * ZOOM_STEP);
let start = 1;
let accumulated = 0;
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault();
accumulated += e.deltaY;
if (accumulated <= -40) {
accumulated = 0;
setZoom((z) => clamp(z + ZOOM_STEP));
} else if (accumulated >= 40) {
accumulated = 0;
setZoom((z) => clamp(z - ZOOM_STEP));
}
};
const onGestureStart = (e: Event) => {
e.preventDefault();
start = zoomRef.current;
};
const onGestureChange = (e: Event) => {
e.preventDefault();
const next = snap(start * (e as unknown as { scale: number }).scale);
setZoom((z) => (z === next ? z : next));
};
const onGestureEnd = (e: Event) => e.preventDefault();
el.addEventListener("wheel", onWheel, { passive: false });
el.addEventListener("gesturestart", onGestureStart);
el.addEventListener("gesturechange", onGestureChange);
el.addEventListener("gestureend", onGestureEnd);
return () => {
el.removeEventListener("wheel", onWheel);
el.removeEventListener("gesturestart", onGestureStart);
el.removeEventListener("gesturechange", onGestureChange);
el.removeEventListener("gestureend", onGestureEnd);
};
}, []);
// The file that is about to be written, rather than the document it came from. The title bar an
// inch above already says which document this is.
const name = suggestedName(path);
// Closes on a written file and stays put on a cancelled panel, because cancelling means the user
// is not finished with the preview yet.
const write = async () => {
if (!pdf) return;
setSaving(true);
try {
if (await save(pdf)) onClose();
} catch (e) {
notify(`Could not export: ${String(e)}`);
} finally {
setSaving(false);
}
};
const adjustZoom = (delta: number) =>
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100)));
// Under this the bar has to drop the words and keep the buttons, which happens when the sidebar
// is wide and the window is not.
const compact = (frame?.width ?? 1000) < 460;
return (
<div className="overlay preview-overlay" onClick={onClose}>
<div
ref={panelRef}
className="panel preview-panel"
role="dialog"
aria-modal="true"
aria-label="PDF preview"
style={
frame
? { position: "fixed", left: frame.left, top: frame.top, width: frame.width, height: frame.height }
: undefined
}
onClick={(e) => e.stopPropagation()}
>
<header className="preview-bar">
<button className="icon-button" onClick={onClose} title="Close (⎋)" aria-label="Close">
<Icon d={icons.CLOSE} />
</button>
<div className="preview-title">{name}</div>
{pages > 0 && !compact && (
<span className="preview-count">
{pages} {pages === 1 ? "page" : "pages"}
</span>
)}
<div className="preview-zoom">
<button
className="icon-button"
disabled={!pdf || zoom <= ZOOM_MIN}
onClick={() => adjustZoom(-ZOOM_STEP)}
title="Zoom out"
aria-label="Zoom out"
>
<Icon d={MINUS} />
</button>
{!compact && <span>{zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`}</span>}
<button
className="icon-button"
disabled={!pdf || zoom >= ZOOM_MAX}
onClick={() => adjustZoom(ZOOM_STEP)}
title="Zoom in"
aria-label="Zoom in"
>
<Icon d={PLUS} />
</button>
</div>
<button className="btn-primary" disabled={!pdf || saving} onClick={() => void write()}>
{saving ? "Saving…" : compact ? "Save" : "Save PDF…"}
</button>
</header>
{/* What the compiler worked around, said before the file is written rather than after.
These are the same sentences the toast used to carry alone, and they are worth more
here: a diagram that did not draw is a thing to look at on the page below. */}
{pdf && pdf.trouble.length > 0 && (
<div className="preview-warn">Exported with {pdf.trouble.join("; ")}.</div>
)}
{error !== null ? (
<div className="preview-stage">
<pre className="preview-error">{error}</pre>
</div>
) : pdf ? (
<Pages bytes={pdf.bytes} zoom={zoom} onPages={setPages} />
) : (
<div className="preview-stage">
<div className="preview-loading">
<div className="preview-spinner" />
<p>Typesetting…</p>
</div>
</div>
)}
</div>
</div>
);
}
/**
* The scroller, and the arithmetic that decides how wide a page is drawn.
*
* At zoom 1 a page is whichever of the stage's two dimensions runs out first, so one whole page is
* visible and "Fit" means what it says. Every other zoom is a multiple of that, which is why the
* column is `width: max-content` and the stage scrolls in both directions.
*/
function Pages({
bytes,
zoom,
onPages,
}: {
bytes: Uint8Array;
zoom: number;
onPages: (n: number) => void;
}) {
const stageRef = useRef<HTMLDivElement>(null);
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [ratio, setRatio] = useState<number | null>(null);
const [stage, setStage] = useState({ width: 0, height: 0 });
useEffect(() => {
let cancelled = false;
let task: ReturnType<Pdfjs["getDocument"]> | undefined;
void (async () => {
try {
const pdfjs = await loadPdfjs();
if (cancelled) return;
// A copy, because pdf.js takes ownership of the buffer it is handed and the same bytes
// still have to go through the IPC boundary when Save is pressed.
task = pdfjs.getDocument({ data: bytes.slice() });
const opened = await task.promise;
if (cancelled) return;
const first = (await opened.getPage(1)).getViewport({ scale: 1 });
if (cancelled) return;
setDoc(opened);
setRatio(first.height / first.width);
onPages(opened.numPages);
} catch {
// A PDF this build cannot parse still saves: the bytes came from the compiler and the
// reader is the only thing that failed. The stage stays empty and Save stays live.
}
})();
return () => {
cancelled = true;
void task?.destroy();
};
}, [bytes, onPages]);
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const update = () => setStage({ width: el.clientWidth, height: el.clientHeight });
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [doc]);
const fit = Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / (ratio ?? 1)));
const width = Math.round(fit * zoom);
return (
<div className="preview-stage" ref={stageRef}>
{doc && ratio !== null && stage.width > 0 && (
<div className="preview-col">
{Array.from({ length: doc.numPages }, (_, i) => (
<Page
key={i + 1}
doc={doc}
number={i + 1}
root={stageRef.current}
width={width}
ratio={ratio}
/>
))}
</div>
)}
</div>
);
}
/**
* One page, drawn when it comes near the viewport and thrown away when it leaves.
*
* A hundred page document is a hundred canvases at device resolution, which is more memory than a
* preview is worth, so the holder keeps the page's shape and the canvas inside it comes and goes.
* The shape is the first page's until this one has been opened, which is right for every document
* whose pages are the same size and self-corrects for the ones that are not.
*/
function Page({
doc,
number,
root,
width,
ratio,
}: {
doc: PDFDocumentProxy;
number: number;
root: HTMLElement | null;
width: number;
ratio: number;
}) {
const holder = useRef<HTMLDivElement>(null);
const [near, setNear] = useState(false);
const [shape, setShape] = useState(ratio);
useEffect(() => {
const el = holder.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setNear(entry.isIntersecting), {
root,
rootMargin: AHEAD,
});
observer.observe(el);
return () => observer.disconnect();
}, [root]);
useEffect(() => {
const el = holder.current;
if (!el) return;
if (!near) {
el.replaceChildren();
return;
}
let cancelled = false;
let task: RenderTask | undefined;
void (async () => {
try {
const page = await doc.getPage(number);
if (cancelled) return;
const natural = page.getViewport({ scale: 1 });
setShape(natural.height / natural.width);
// Capped at 2, because a 3x screen would draw nine times the pixels for a difference
// nobody can see on a page of body text.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const viewport = page.getViewport({ scale: (width / natural.width) * dpr });
const canvas = document.createElement("canvas");
canvas.className = "preview-canvas";
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
task = page.render({ canvas, viewport });
await task.promise;
if (cancelled) return;
el.replaceChildren(canvas);
} catch {
// Cancelled by a zoom that arrived mid-render, or a page that would not draw. The canvas
// already on screen is the better thing to leave up either way.
}
})();
return () => {
cancelled = true;
task?.cancel();
};
}, [near, doc, number, width]);
return (
<div
ref={holder}
className="preview-page"
style={{ width, height: Math.round(width * shape) }}
/>
);
}
+119 -92
View File
@@ -1,28 +1,43 @@
// The macOS overlay title bar. The traffic lights float over its left end, which is why the row // The macOS overlay title bar. The traffic lights float over its left end, which is why the row
// itself carries `data-tauri-drag-region` and every control inside it does not: an interactive // itself carries `data-tauri-drag-region` and every control inside it does not: an interactive
// element that also drags the window swallows its own click. // element that also drags the window swallows its own click.
//
// The glyphs come from margin-shared, which is where the sibling book app takes them from too, so
// that the two do not each have their own idea of what a search or a moon looks like. They are
// paths and not a dependency: an icon set is six hundred kilobytes for the handful of shapes a
// title bar needs.
//
// What is in the row is a shorter list than what the app can do, and the cut is on purpose. A
// button here is for something whose result the user then looks at on this screen: the sidebar, the
// page's measure, its theme, whether it is underlined, where it goes as a PDF. Making a file is not
// that, which is why New Document is a keystroke and a row in the menu at the end and not a button
// of its own.
//
// The filename is the way in to everything the document itself holds, which is what the sibling
// book app does with the title it shows here. Clicking it opens Document setup, where the name is
// the first field and the faces the page is set in are the second, so neither spends a button on
// the row and neither is somewhere a reader would not think to look.
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { icons } from "margin-shared";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { keyLabel, keysFor } from "../keys/bindings"; import { keyLabel, keysFor } from "../keys/bindings";
import { commandLabel, onCommand, runCommand, type CommandId } from "../keys/commands"; import { commandLabel, onCommand, runCommand, type CommandId } from "../keys/commands";
import { useDocument } from "../store/useDocument"; import { useDocument } from "../store/useDocument";
import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme"; import { useTheme } from "../store/useTheme";
import { notify } from "../store/useToast"; import { DocumentSetup } from "./DocumentSetup";
import { useWorkspace } from "../store/useWorkspace";
import { splitExtension } from "./FileTree"; import { splitExtension } from "./FileTree";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { WidthMenu } from "./WidthMenu"; import { WidthMenu } from "./WidthMenu";
const SIDEBAR_KEY = "margindocs-sidebar"; const SIDEBAR_KEY = "margindocs-sidebar";
const SIDEBAR_ICON = "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M9 3v18"; // The glyphs themselves live in margin-shared, so the two apps cannot drift into two ideas of what
const NEW_DOC = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M12 12v5 M9.5 14.5h5"; // a search or a moon looks like. Which of them this row uses, and in what order, is still this
const SEARCH = "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M20 20l-3.6-3.6"; // app's decision.
const SUN = "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10z M12 1v2 M12 21v2 M4.2 4.2l1.4 1.4 M18.4 18.4l1.4 1.4 M1 12h2 M21 12h2 M4.2 19.8l1.4-1.4 M18.4 5.6l1.4-1.4"; const { SIDEBAR: SIDEBAR_ICON, SEARCH, SPELLING, GRAMMAR, EXPORT, MOON, MORE, SUN_RAYS, SUN_DISC } =
const MOON = "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"; icons;
const MORE = "M5 12h.01M12 12h.01M19 12h.01";
/** A tooltip that names the action and prints its key in the glyphs the sheet uses. */ /** A tooltip that names the action and prints its key in the glyphs the sheet uses. */
export function shortcutTitle(id: CommandId): string { export function shortcutTitle(id: CommandId): string {
@@ -30,19 +45,36 @@ export function shortcutTitle(id: CommandId): string {
return keys.length ? `${commandLabel(id)} (${keyLabel(keys[0])})` : commandLabel(id); return keys.length ? `${commandLabel(id)} (${keyLabel(keys[0])})` : commandLabel(id);
} }
/** The sun, which is the one glyph here that is not a single path. */
function Sun() {
return (
<Icon>
<circle cx={SUN_DISC.cx} cy={SUN_DISC.cy} r={SUN_DISC.r} />
<path d={SUN_RAYS} />
</Icon>
);
}
export function Titlebar() { export function Titlebar() {
const path = useDocument((s) => s.path); const path = useDocument((s) => s.path);
const dirty = useDocument((s) => s.dirty); const dirty = useDocument((s) => s.dirty);
const externalChange = useDocument((s) => s.externalChange); const externalChange = useDocument((s) => s.externalChange);
const renameEntry = useWorkspace((s) => s.renameEntry);
const theme = useTheme((s) => s.theme); const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle); const toggleTheme = useTheme((s) => s.toggle);
const spelling = useProofing((s) => s.enabled);
const toggleSpelling = useProofing((s) => s.toggle);
const spellingAvailability = useProofing((s) => s.availability);
const grammar = useProofing((s) => s.grammar);
const toggleGrammar = useProofing((s) => s.toggleGrammar);
const grammarAvailability = useProofing((s) => s.grammarAvailability);
const ensureAvailable = useProofing((s) => s.ensureAvailable);
const [sidebar, setSidebar] = useState( const [sidebar, setSidebar] = useState(
() => document.documentElement.getAttribute("data-sidebar") !== "false", () => document.documentElement.getAttribute("data-sidebar") !== "false",
); );
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState(false);
const [renaming, setRenaming] = useState(false); const [setup, setSetup] = useState(false);
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute("data-sidebar", String(sidebar)); document.documentElement.setAttribute("data-sidebar", String(sidebar));
@@ -54,24 +86,22 @@ export function Titlebar() {
}, [sidebar]); }, [sidebar]);
useEffect(() => onCommand("toggle-sidebar", () => setSidebar((v) => !v)), []); useEffect(() => onCommand("toggle-sidebar", () => setSidebar((v) => !v)), []);
useEffect(() => setRenaming(false), [path]); useEffect(() => setSetup(false), [path]);
// Asked once per launch, and asked here because this is the first thing on screen that has to
// know: a machine with no checker gets no button rather than a button that toggles a setting
// nothing acts on. The store answers each question once whichever of its callers gets there
// first, so this costs nothing when the settings panel or the editor asked already.
useEffect(() => ensureAvailable(), [ensureAvailable]);
useEscapeLayer(menu, () => setMenu(false)); useEscapeLayer(menu, () => setMenu(false));
// What the title bar shows is the file on disk, never the document's H1. The two are unrelated
// and this is the place that promise is easiest to break: editing a heading is a content edit and
// nothing else, because a path is what git, every other editor and every relative link from
// another document already agreed on.
const fileName = path ? path.slice(path.lastIndexOf("/") + 1) : ""; const fileName = path ? path.slice(path.lastIndexOf("/") + 1) : "";
const { base, hidden } = splitExtension(fileName); const { base } = splitExtension(fileName);
// The filename and the document's H1 are unrelated, and this is the place that promise is
// easiest to break. What the title bar shows is the file on disk, and the only thing that ever
// renames it is the user typing here. Editing a heading is a content edit and nothing else: it
// does not move the file, because a path is what git, every other editor and every relative
// link from another document already agreed on.
const commitRename = (next: string) => {
setRenaming(false);
const trimmed = next.trim();
if (!path || !trimmed || trimmed === base) return;
renameEntry(path, `${trimmed}${hidden}`).catch((e) => notify(`Could not rename: ${String(e)}`));
};
const menuItem = (id: CommandId) => ( const menuItem = (id: CommandId) => (
<button <button
@@ -85,6 +115,13 @@ export function Titlebar() {
</button> </button>
); );
// A checker this machine does not have takes its button off the row rather than showing a dead
// one, which is the same call src/store/useProofing.ts makes about the underlines themselves. The
// settings panel keeps its row and says which checker is missing, because a panel is where
// somebody goes to look for a setting and a row that is simply absent reads as a missing feature.
const canSpell = spellingAvailability !== "missing";
const canCheckGrammar = grammarAvailability !== "missing";
return ( return (
<header className="titlebar" data-tauri-drag-region> <header className="titlebar" data-tauri-drag-region>
<div className="lead"> <div className="lead">
@@ -100,34 +137,26 @@ export function Titlebar() {
</button> </button>
</div> </div>
{path && {path && (
(renaming ? (
<TitleRename value={base} onCommit={commitRename} onCancel={() => setRenaming(false)} />
) : (
<button <button
className="doc-title" className="doc-title"
data-active={setup}
data-external={externalChange === "changed-on-disk"} data-external={externalChange === "changed-on-disk"}
aria-haspopup="dialog"
aria-expanded={setup}
title={ title={
externalChange === "changed-on-disk" externalChange === "changed-on-disk"
? `${fileName} (changed on disk). Click to rename.` ? `${fileName} (changed on disk). Document setup.`
: `${fileName}. Click to rename.` : `${fileName}. Document setup.`
} }
onClick={() => setRenaming(true)} onClick={() => setSetup(true)}
> >
{base} {base}
{dirty && <span className="dirty-dot" />} {dirty && <span className="dirty-dot" />}
</button> </button>
))} )}
<div className="actions"> <div className="actions">
<button
className="icon-button"
title={shortcutTitle("new-doc")}
aria-label={commandLabel("new-doc")}
onClick={() => runCommand("new-doc")}
>
<Icon d={NEW_DOC} />
</button>
<button <button
className="icon-button" className="icon-button"
title={shortcutTitle("quick-open")} title={shortcutTitle("quick-open")}
@@ -136,15 +165,59 @@ export function Titlebar() {
> >
<Icon d={SEARCH} /> <Icon d={SEARCH} />
</button> </button>
{/* The two checkers, which are two buttons because they are two checkers: spelling is the
system's and grammar is Harper's, and wanting one underlined without the other is not a
strange thing to want. Both are settings rather than actions, so they are pressed
toggles and say so. */}
{canSpell && (
<button
className="icon-button"
data-active={spelling}
title={shortcutTitle("toggle-spelling")}
aria-label={commandLabel("toggle-spelling")}
aria-pressed={spelling}
onClick={toggleSpelling}
>
<Icon d={SPELLING} />
</button>
)}
{canCheckGrammar && (
<button
className="icon-button"
data-active={grammar}
title={shortcutTitle("toggle-grammar")}
aria-label={commandLabel("toggle-grammar")}
aria-pressed={grammar}
onClick={toggleGrammar}
>
<Icon d={GRAMMAR} />
</button>
)}
{/* The measure, which belongs to the app's view of every document. The face, which belongs
to the one document, is in the setup panel behind the filename instead: that is the
difference between them, and src/store/useDocumentFonts.ts is where it is spelled out. */}
<WidthMenu />
<button
className="icon-button"
title={shortcutTitle("export-pdf")}
aria-label={commandLabel("export-pdf")}
onClick={() => runCommand("export-pdf")}
>
<Icon d={EXPORT} />
</button>
<button <button
className="icon-button" className="icon-button"
title={theme === "dark" ? "Light theme" : "Dark theme"} title={theme === "dark" ? "Light theme" : "Dark theme"}
aria-label={commandLabel("toggle-theme")} aria-label={commandLabel("toggle-theme")}
onClick={toggleTheme} onClick={toggleTheme}
> >
<Icon d={theme === "dark" ? SUN : MOON} /> {theme === "dark" ? <Sun /> : <Icon d={MOON} />}
</button> </button>
<WidthMenu />
<div className="menu-wrap"> <div className="menu-wrap">
<button <button
className="icon-button" className="icon-button"
@@ -161,6 +234,7 @@ export function Titlebar() {
<div className="menu-backdrop" onClick={() => setMenu(false)} /> <div className="menu-backdrop" onClick={() => setMenu(false)} />
<div className="menu" role="menu"> <div className="menu" role="menu">
{menuItem("open-folder")} {menuItem("open-folder")}
{menuItem("new-doc")}
{menuItem("new-folder")} {menuItem("new-folder")}
<div className="menu-sep" /> <div className="menu-sep" />
{menuItem("find-in-files")} {menuItem("find-in-files")}
@@ -174,54 +248,7 @@ export function Titlebar() {
)} )}
</div> </div>
</div> </div>
{setup && path && <DocumentSetup path={path} onClose={() => setSetup(false)} />}
</header> </header>
); );
} }
function TitleRename({
value,
onCommit,
onCancel,
}: {
value: string;
onCommit: (next: string) => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLInputElement>(null);
const settled = useRef(false);
useEffect(() => {
const input = ref.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = (next: string) => {
if (settled.current) return;
settled.current = true;
onCommit(next);
};
useEscapeLayer(true, () => {
settled.current = true;
onCancel();
});
return (
<input
ref={ref}
className="doc-title title-rename"
defaultValue={value}
spellCheck={false}
autoComplete="off"
aria-label="Rename this file"
onBlur={(e) => commit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
commit(e.currentTarget.value);
}}
/>
);
}
+9 -15
View File
@@ -1,5 +1,5 @@
// The width control there was no way to click: a title bar button that shows the applied width and // The width control there was no way to click: a title bar button that opens the three named steps
// opens the three named steps with the current one marked. // with the applied one ticked, and names it in its own tooltip.
// //
// It belongs beside the theme toggle rather than in the editor pill. Everything in the pill edits // It belongs beside the theme toggle rather than in the editor pill. Everything in the pill edits
// the file; this edits the app's view of it and touches no byte on disk, and the title bar already // the file; this edits the app's view of it and touches no byte on disk, and the title bar already
@@ -21,24 +21,18 @@
// the menu, and closing puts it back where it came from. // the menu, and closing puts it back where it came from.
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { icons } from "margin-shared";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { applyWidth, WIDTHS, type EditorWidth } from "../width"; import { applyWidth, WIDTHS, type EditorWidth } from "../width";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const ITEM = ".width-menu-item"; const ITEM = ".width-menu-item";
const CHECK_D = "M20 6L9 17l-5-5"; // Both glyphs are margin-shared's, so this control looks the same in the book app. The arrow does
// not change with the applied width and deliberately does not: an earlier version drew three lines
/** The page's two edges with three lines of text between them, so the button says which width is // of text at three measures so the button said which width was on, and the two apps then had
* applied without spending a word of the title bar on it. The edges never move and only the // different pictures of the same control. The width is on the menu, ticked, one click away.
* measure does, which is the whole of what the setting changes. The sibling's glyph for this is a const { CHECK: CHECK_D, WIDTH: WIDTH_ICON } = icons;
* double headed arrow, and it is not ported: an arrow six units long is a smudge at 16px, which is
* the only size this is ever drawn at. */
const WIDTH_ICON: Record<EditorWidth, string> = {
narrow: "M3 4v16M21 4v16M9 7h6M9 12h6M9 17h6",
normal: "M3 4v16M21 4v16M7 7h10M7 12h10M7 17h10",
wide: "M3 4v16M21 4v16M5 7h14M5 12h14M5 17h14",
};
function isWidth(value: string | null): value is EditorWidth { function isWidth(value: string | null): value is EditorWidth {
return WIDTHS.includes(value as EditorWidth); return WIDTHS.includes(value as EditorWidth);
@@ -146,7 +140,7 @@ export function WidthMenu() {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={toggle} onClick={toggle}
> >
<Icon d={WIDTH_ICON[width]} /> <Icon d={WIDTH_ICON} />
</button> </button>
{open && ( {open && (
<> <>
+6
View File
@@ -563,6 +563,12 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
case "pdf_write": case "pdf_write":
return undefined as T; return undefined as T;
// Three families a browser is certain to resolve, so the "System" half of the font picker has
// rows in it and can be clicked. The real list is whatever this machine has installed, which is
// not something a fixture gets to pretend to know.
case "fonts_list_system":
return ["Courier New", "Georgia", "Helvetica"] as unknown as T;
default: default:
throw new Error(`dev mock has no handler for ${command}`); throw new Error(`dev mock has no handler for ${command}`);
} }
+94 -47
View File
@@ -7,6 +7,17 @@
// tree it is already holding. That is not an accident of the current code, it is the requirement. // tree it is already holding. That is not an accident of the current code, it is the requirement.
// A save before an export would mean choosing to export a document turns into writing over a file // A save before an export would mean choosing to export a document turns into writing over a file
// the user had not decided to write yet, and there is no undo for that on disk. // the user had not decided to write yet, and there is no undo for that on disk.
//
// The export is two halves, and the seam between them is the point. `compile` produces the bytes;
// `save` puts them on disk; between the two sits src/components/ExportPreview.tsx, which draws the
// pages the compiler actually produced and does not call `save` until somebody has looked at them
// and asked for a file. The sibling book app puts the same panel behind the same command, and the
// reason is the same in both: a PDF is the one thing either app makes that its author cannot see
// before it exists, and a save panel is a poor place to find out that a diagram did not draw.
//
// The guard that used to live here, a module-level phase refusing a second export while the first
// was running, is gone with it. The panel is the guard now: there is one of it, opening it while
// it is open is nothing happening, and the compile belongs to its lifetime.
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { save as savePanel } from "@tauri-apps/plugin-dialog"; import { save as savePanel } from "@tauri-apps/plugin-dialog";
@@ -14,22 +25,34 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { pdfCompile, pdfWrite } from "../api/pdf"; import { pdfCompile, pdfWrite } from "../api/pdf";
import { PDF_WARNINGS_EVENT, isDesktop, isTauri, type PdfWarning } from "../ipc"; import { PDF_WARNINGS_EVENT, isDesktop, isTauri, type PdfWarning } from "../ipc";
import { useDocument } from "../store/useDocument"; import { useDocument } from "../store/useDocument";
import { useDocumentFonts } from "../store/useDocumentFonts";
import { notify } from "../store/useToast"; import { notify } from "../store/useToast";
import type { DocumentFonts } from "../model/fonts";
import { diagramSources, documentToTypst, renderDiagrams, type Diagrams, type TypstDocument } from "./typst"; import { diagramSources, documentToTypst, renderDiagrams, type Diagrams, type TypstDocument } from "./typst";
type Phase = "idle" | "converting" | "compiling" | "writing";
/** /**
* Where the export has got to. A phase rather than a boolean because there are four answers and * A compiled document, waiting to be looked at and then written.
* only one of them is "nothing is happening", and because the second Cmd-E while a fifty page *
* document is compiling has to be refused rather than queued behind the first. * `path` travels with the bytes rather than being read again at save time, so the file the panel
* offers to write is named after the document the pages on screen came from, even if the sidebar
* has moved on underneath.
*/ */
let phase: Phase = "idle"; export interface CompiledPdf {
path: string;
bytes: Uint8Array;
/** What the compile worked around, phrased to be joined into the sentence a toast ends with. */
trouble: string[];
}
const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path; const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path;
/** The same name with a .pdf on it, which is what the save panel opens with. */ /**
function suggestedName(path: string): string { * The same name with a .pdf on it, which is what the save panel opens with.
*
* Exported because the preview's bar shows it: naming the file that is about to be written is more
* use there than repeating the document name the title bar is already showing an inch above.
*/
export function suggestedName(path: string): string {
const name = baseName(path); const name = baseName(path);
const dot = name.lastIndexOf("."); const dot = name.lastIndexOf(".");
return `${dot > 0 ? name.slice(0, dot) : name}.pdf`; return `${dot > 0 ? name.slice(0, dot) : name}.pdf`;
@@ -95,79 +118,103 @@ async function compileWithFallback(
doc: ProseMirrorNode, doc: ProseMirrorNode,
path: string, path: string,
diagrams: Diagrams, diagrams: Diagrams,
fonts: DocumentFonts,
first: TypstDocument, first: TypstDocument,
): Promise<{ bytes: ArrayBuffer; warnings: PdfWarning[]; retried: boolean }> { ): Promise<{ bytes: ArrayBuffer; warnings: PdfWarning[]; retried: boolean }> {
try { try {
const attempt = await withWarnings(() => pdfCompile(first.source, first.images)); const attempt = await withWarnings(() => pdfCompile(first.source, first.images, fonts));
return { bytes: attempt.result, warnings: attempt.warnings, retried: false }; return { bytes: attempt.result, warnings: attempt.warnings, retried: false };
} catch (e) { } catch (e) {
if (!hasMath(doc)) throw e; if (!hasMath(doc)) throw e;
const literal = documentToTypst(doc, path, { diagrams, math: "literal" }); const literal = documentToTypst(doc, path, { diagrams, math: "literal", fonts });
const attempt = await withWarnings(() => pdfCompile(literal.source, literal.images)); const attempt = await withWarnings(() => pdfCompile(literal.source, literal.images, fonts));
return { bytes: attempt.result, warnings: attempt.warnings, retried: true }; return { bytes: attempt.result, warnings: attempt.warnings, retried: true };
} }
} }
/** /**
* The whole export, from the tree in the editor to a file on disk. * Whether there is anything to export, and the sentence saying why not when there is not.
* *
* Bound to the `export-pdf` command in src/keys/commands.ts, which is the only caller. * Asked by the `export-pdf` entry in src/keys/commands.ts before it opens the panel, because a
* panel that opens onto "no document" is a worse answer than never opening. The save panel is a
* window API, so it is not merely absent in a browser, it is refused, and there is no point
* compiling a PDF this build has nowhere to put.
*/ */
export async function exportPdf(): Promise<void> { export function canExport(): boolean {
if (phase !== "idle") {
notify("An export is already running.");
return;
}
const { path, content } = useDocument.getState(); const { path, content } = useDocument.getState();
if (path === null || content === null) { if (path === null || content === null) {
notify("Open a document to export it."); notify("Open a document to export it.");
return; return false;
} }
// The save panel is a window API, so it is not merely absent in a browser, it is refused. There
// is no point compiling a PDF this build has nowhere to put.
if (!isDesktop) { if (!isDesktop) {
notify("PDF export needs the desktop app."); notify("PDF export needs the desktop app.");
return; return false;
}
return true;
} }
phase = "converting"; /**
try { * The document on screen, typeset.
*
* Throws rather than notifying, because its caller is a panel with somewhere to put the message:
* a compile that failed is the whole of what that panel has to show, and a toast behind an empty
* preview would be the wrong place to read it.
*/
export async function compile(): Promise<CompiledPdf> {
const { path, content } = useDocument.getState();
if (path === null || content === null) throw new Error("no document is open");
// Drawn before the conversion rather than during it, because mermaid is asynchronous and wants // Drawn before the conversion rather than during it, because mermaid is asynchronous and wants
// a DOM, and the converter is neither. A diagram that will not draw is simply missing from the // a DOM, and the converter is neither. A diagram that will not draw is simply missing from the
// map and comes out of the converter as the fence it always was. // map and comes out of the converter as the fence it always was.
const drawings = await renderDiagrams(content); const drawings = await renderDiagrams(content);
const undrawn = diagramSources(content).length - drawings.size; const undrawn = diagramSources(content).length - drawings.size;
const converted = documentToTypst(content, path, { diagrams: drawings }); // The faces the page is in on screen, so an export is the document as its author sees it.
const fonts = useDocumentFonts.getState().fonts;
const converted = documentToTypst(content, path, { diagrams: drawings, fonts });
phase = "compiling"; const { bytes, warnings, retried } = await compileWithFallback(
const { bytes, warnings, retried } = await compileWithFallback(content, path, drawings, converted); content,
path,
drawings,
fonts,
converted,
);
phase = "writing"; return {
const target = await savePanel({ path,
title: "Export as PDF", bytes: new Uint8Array(bytes),
defaultPath: suggestedName(path), trouble: [
filters: [{ name: "PDF", extensions: ["pdf"] }],
});
if (target === null) return;
await pdfWrite(target, new Uint8Array(bytes));
const trouble = [
...converted.warnings, ...converted.warnings,
...(undrawn > 0 ? [`${undrawn} diagram${undrawn === 1 ? "" : "s"} could not be drawn`] : []), ...(undrawn > 0 ? [`${undrawn} diagram${undrawn === 1 ? "" : "s"} could not be drawn`] : []),
...(retried ? ["every formula shown as the source it was written in"] : []), ...(retried ? ["every formula shown as the source it was written in"] : []),
...(warnings.length > 0 ? [describe(warnings)] : []), ...(warnings.length > 0 ? [describe(warnings)] : []),
]; ],
};
}
/**
* The native save panel, and the write behind it.
*
* Answers whether a file was written, which is not the same question as whether anything went
* wrong: cancelling the panel is an ordinary way to end an export and gets no toast, because
* nothing happened and the user is the one who decided that.
*/
export async function save(pdf: CompiledPdf): Promise<boolean> {
const target = await savePanel({
title: "Export as PDF",
defaultPath: suggestedName(pdf.path),
filters: [{ name: "PDF", extensions: ["pdf"] }],
});
if (target === null) return false;
await pdfWrite(target, pdf.bytes);
notify( notify(
trouble.length === 0 pdf.trouble.length === 0
? `Exported ${baseName(target)}` ? `Exported ${baseName(target)}`
: `Exported ${baseName(target)}, with ${trouble.join("; ")}`, : `Exported ${baseName(target)}, with ${pdf.trouble.join("; ")}`,
); );
} catch (e) { return true;
notify(`Could not export: ${String(e)}`);
} finally {
phase = "idle";
}
} }
+23 -10
View File
@@ -23,6 +23,7 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { ImageInput } from "../ipc"; import type { ImageInput } from "../ipc";
import { resolveRelative } from "../links"; import { resolveRelative } from "../links";
import { DEFAULT_FONTS, fontFamilyName, type DocumentFonts } from "../model/fonts";
import { CALLOUT_LABELS, calloutKindFromLabel, type CalloutKind } from "../model/doc"; import { CALLOUT_LABELS, calloutKindFromLabel, type CalloutKind } from "../model/doc";
export interface TypstDocument { export interface TypstDocument {
@@ -48,6 +49,10 @@ export type MathMode = "typeset" | "literal";
export interface TypstOptions { export interface TypstOptions {
diagrams?: Diagrams; diagrams?: Diagrams;
math?: MathMode; math?: MathMode;
/** The two faces the document is set in, which default to what tokens.css sets an untouched one
* in. Only the body and heading slots follow the document: code and formulas are set in the
* machine's own faces either way, which is src-tauri/src/pdf.rs's business and not this one's. */
fonts?: DocumentFonts;
} }
const MERMAID = "mermaid"; const MERMAID = "mermaid";
@@ -128,15 +133,20 @@ function flow(value: string): string {
// chosen here, because an export is the document on screen and not a second design. The light // chosen here, because an export is the document on screen and not a second design. The light
// palette specifically: a PDF is a white page whatever `data-theme` says. // palette specifically: a PDF is a white page whatever `data-theme` says.
// //
// Literata and Hanken Grotesk are the two families src-tauri/src/pdf.rs compiles into the binary, // The body and heading faces are the document's own and arrive in `TypstOptions`, because a font
// so they are always there and can be named here. Monospace and the maths face are not named here // is a per-document setting here the way it is in src/store/useDocumentFonts.ts. Whichever family
// at all: neither is bundled, both come off the machine, and Typst warns once for every family it // they name, src-tauri/src/pdf.rs is handed its bytes before this preamble reaches the compiler.
// was asked for and could not find, so a hopeful list written on this side would end every export //
// with a toast about fonts nobody chose. src-tauri/src/fonts.rs asks the system what it actually // Monospace and the maths face are not named here at all: neither is bundled, both come off the
// has, and pdf.rs puts the answer in front of this preamble. // machine, and Typst warns once for every family it was asked for and could not find, so a hopeful
// list written on this side would end every export with a toast about fonts nobody chose.
// src-tauri/src/fonts.rs asks the system what it actually has, and pdf.rs puts the answer in front
// of this preamble.
// ------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------
const BODY_FONT = "Literata"; // The app's own face, and the one face on the page that is not a document decision. prose.css does
// not make it one either: the fifth and sixth heading levels are labels rather than headings, and
// the sheet sets them in the UI family on screen whatever the document is set in.
const UI_FONT = "Hanken Grotesk"; const UI_FONT = "Hanken Grotesk";
// The light palette of src/styles/tokens.css, written out because Typst source cannot read a CSS // The light palette of src/styles/tokens.css, written out because Typst source cannot read a CSS
@@ -183,11 +193,13 @@ const CALLOUT_COLOURS: Record<CalloutKind, { edge: string; fill: string; label:
* with the backend, and a missing package is a hard compile error rather than a warning, so a * with the backend, and a missing package is a hard compile error rather than a warning, so a
* document with no maths in it is not made to depend on it. * document with no maths in it is not made to depend on it.
*/ */
function preamble(title: string, math: boolean): string { function preamble(title: string, math: boolean, fonts: DocumentFonts): string {
const mitex = math ? `#import "/mitex/lib.typ": mitex, mi\n\n` : ""; const mitex = math ? `#import "/mitex/lib.typ": mitex, mi\n\n` : "";
const body = fontFamilyName(fonts.body);
const heading = fontFamilyName(fonts.heading);
return `${mitex}#set document(title: ${str(title)}) return `${mitex}#set document(title: ${str(title)})
#set page(paper: "a4", margin: (x: 2.4cm, y: 2.6cm), numbering: "1", number-align: center) #set page(paper: "a4", margin: (x: 2.4cm, y: 2.6cm), numbering: "1", number-align: center)
#set text(font: ${str(BODY_FONT)}, size: 11pt, fill: ${hex(INK)}, lang: "en") #set text(font: ${str(body)}, size: 11pt, fill: ${hex(INK)}, lang: "en")
#set par(leading: 0.7em, spacing: 1.05em, justify: false) #set par(leading: 0.7em, spacing: 1.05em, justify: false)
#set heading(numbering: none) #set heading(numbering: none)
@@ -195,6 +207,7 @@ function preamble(title: string, math: boolean): string {
// the reader stops seeing a hierarchy. Size for the first three, a weight step for the fourth, the // the reader stops seeing a hierarchy. Size for the first three, a weight step for the fourth, the
// UI face for the last two, case for the sixth. prose.css's scale, in print units. // UI face for the last two, case for the sixth. prose.css's scale, in print units.
#show heading: set block(above: 1.9em, below: 0.55em) #show heading: set block(above: 1.9em, below: 0.55em)
#show heading: set text(font: ${str(heading)})
#show heading.where(level: 1): set text(size: 1.77em, weight: 600, tracking: -0.016em) #show heading.where(level: 1): set text(size: 1.77em, weight: 600, tracking: -0.016em)
#show heading.where(level: 2): set text(size: 1.35em, weight: 600, tracking: -0.008em) #show heading.where(level: 2): set text(size: 1.35em, weight: 600, tracking: -0.008em)
#show heading.where(level: 3): set text(size: 1.12em, weight: 600) #show heading.where(level: 3): set text(size: 1.12em, weight: 600)
@@ -766,7 +779,7 @@ export function documentToTypst(doc: ProseMirrorNode, path: string, options: Typ
}; };
const rendered = blocks(ctx, doc); const rendered = blocks(ctx, doc);
return { return {
source: `${preamble(baseName(path), ctx.math)}\n${rendered}\n`, source: `${preamble(baseName(path), ctx.math, options.fonts ?? DEFAULT_FONTS)}\n${rendered}\n`,
images: ctx.images, images: ctx.images,
warnings: ctx.warnings, warnings: ctx.warnings,
}; };
+11 -2
View File
@@ -13,7 +13,7 @@
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { runWritingTool } from "../editor/writing"; import { runWritingTool } from "../editor/writing";
import { exportPdf } from "../export/run"; import { canExport } from "../export/run";
import { checkForUpdates } from "../update"; import { checkForUpdates } from "../update";
import { useDocument } from "../store/useDocument"; import { useDocument } from "../store/useDocument";
import { useProofing } from "../store/useProofing"; import { useProofing } from "../store/useProofing";
@@ -248,7 +248,16 @@ const TABLE: Record<CommandId, Omit<Command, "id">> = {
"new-folder": { label: "New Folder", palette: true, run: () => void createFolder() }, "new-folder": { label: "New Folder", palette: true, run: () => void createFolder() },
"close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder }, "close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder },
save: { label: "Save", palette: true, run: () => void saveDocument() }, save: { label: "Save", palette: true, run: () => void saveDocument() },
"export-pdf": { label: "Export as PDF…", palette: true, run: () => void exportPdf() }, // Dispatched rather than run, because what Export produces first is a panel: the pages go on
// screen and the save panel is a button on that panel. The guard stays here, because a preview
// that opens onto "no document open" is a worse answer than one that never opens.
"export-pdf": {
label: "Export as PDF…",
palette: true,
run: () => {
if (canExport()) dispatch("export-pdf");
},
},
"rename-file": { label: "Rename", palette: false, run: () => void renameSelected() }, "rename-file": { label: "Rename", palette: false, run: () => void renameSelected() },
"duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() }, "duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() },
"delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() }, "delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() },
+4 -2
View File
@@ -8,8 +8,8 @@
// order their rules expect to win: the document's typography, then the three block sheets that // order their rules expect to win: the document's typography, then the three block sheets that
// answer it for a construct with a node view or a decoration of its own, then the page the document // answer it for a construct with a node view or a decoration of its own, then the page the document
// sits on and the section drawn under the end of it, the pill that floats over that page and the // sits on and the section drawn under the end of it, the pill that floats over that page and the
// two menus that hang off it, and last the shell, which is the one sheet that reaches back into // three menus that hang off the title bar, and last the shell, which is the one sheet that reaches
// the title bar app.css already styled. The palettes come last of all: they override .overlay from // back into the title bar app.css already styled. The palettes come last of all: they override .overlay from
// app.css and .key-cap from tree.css, so they have to be able to see both. // app.css and .key-cap from tree.css, so they have to be able to see both.
// //
// katex.min.css is not here. src/editor/blocks/math.ts imports it itself so that the bundler // katex.min.css is not here. src/editor/blocks/math.ts imports it itself so that the bundler
@@ -26,6 +26,8 @@ import "./styles/sheet.css";
import "./styles/backlinks.css"; import "./styles/backlinks.css";
import "./styles/toolbar.css"; import "./styles/toolbar.css";
import "./styles/width-menu.css"; import "./styles/width-menu.css";
import "./styles/document-setup.css";
import "./styles/export-preview.css";
import "./styles/link-picker.css"; import "./styles/link-picker.css";
import "./styles/tree.css"; import "./styles/tree.css";
import "./styles/palette.css"; import "./styles/palette.css";
+40
View File
@@ -0,0 +1,40 @@
// The faces a document can be set in.
//
// The catalogue itself is margin-shared's, because Margin and Margin Docs have to offer the same
// six families under the same ids: a book and a document set in "EB Garamond" must mean the same
// file, and a stored preference written by one app has to be readable by the other. What is left
// here is the one thing that is this app's own, which is the noun.
export type {
BundledFont,
FontCategory,
FontPairing,
FontRef,
} from "margin-shared/fonts";
export {
BUNDLED_FONTS,
DEFAULT_FONTS,
FONT_PAIRINGS,
bundledFont,
decodeRef,
encodeRef,
fontFamilyName,
fontLabel,
fontStack,
fontsEqual,
fontsUsed,
isFontRef,
pairingFor,
refsEqual,
} from "margin-shared/fonts";
import type { FontPair } from "margin-shared/fonts";
/**
* What one document is set in.
*
* An alias rather than a second interface, so every call site in this app keeps reading in the
* app's own noun while the shape stays the shared one. Margin calls the same pair `BookFonts`.
*/
export type DocumentFonts = FontPair;
+153
View File
@@ -0,0 +1,153 @@
// What the open document is set in, remembered per file.
//
// Per file and not per app, which is the whole point of the feature: a set of meeting notes and a
// short story are not the same kind of page, and a preference that applies to both is the one the
// app already made in src/styles/tokens.css. The model is Google Docs', where the face belongs to
// the document rather than to the editor.
//
// Where it is kept, and why it is not the file
// --------------------------------------------
// A path to a `DocumentFonts`, in localStorage, beside the theme and the width. The obvious other
// home is the document's own frontmatter, and it is deliberately not used: src/model/doc.ts holds
// frontmatter as an opaque string precisely so that the app never re-emits somebody's YAML, and
// writing a key into it would mean parsing and re-serialising the one part of the file this editor
// promises not to touch. A font is also not content. `margin-docs-fonts: literata` committed into a
// repository is a line every other markdown tool has to ignore, and a diff on every document whose
// author tried a face and changed their mind.
//
// The cost is honest and worth stating: this is a preference on this machine, so the same file
// opened on another one, or after the app's storage is cleared, opens in the default pair. Nothing
// is lost when that happens, because a font is not text.
//
// A renamed or moved file loses its entry, for the same reason: the key is the path, and the app
// has no identity for a document other than where it lives.
import { create } from "zustand";
import {
DEFAULT_FONTS,
fontStack,
fontsEqual,
isFontRef,
type DocumentFonts,
type FontRef,
} from "../model/fonts";
const KEY = "margindocs-fonts";
/**
* How many documents are remembered, most recently set first.
*
* A cap because this is a map that only ever grows: every document somebody tries a face on stays
* in it forever otherwise, and localStorage is a few megabytes shared with everything else the app
* keeps. Two hundred is far past the number of documents anybody sets a font on by hand, and the
* entry that falls off the end is the one nobody has touched in the longest time.
*/
const LIMIT = 200;
type Stored = Record<string, DocumentFonts>;
/**
* The map as it sits on disk, or an empty one.
*
* Guarded for the Node test environment and for a webview with storage denied, the same way
* src/store/useProofing.ts guards its two booleans. Anything that is not a pair of `FontRef`s is
* dropped rather than repaired: a preference written by a version that named its faces differently
* is not worth guessing at, and the default pair is always a correct answer.
*/
function read(): Stored {
if (typeof localStorage === "undefined") return {};
try {
const raw = localStorage.getItem(KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
const out: Stored = {};
for (const [path, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value !== "object" || value === null) continue;
const { body, heading } = value as { body?: unknown; heading?: unknown };
if (isFontRef(body) && isFontRef(heading)) out[path] = { body, heading };
}
return out;
} catch {
return {};
}
}
function write(map: Stored): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(KEY, JSON.stringify(map));
} catch {
// A webview with storage denied still changes the face, it just forgets between launches.
}
}
/**
* Writes the two custom properties src/styles/prose.css sets the page from, and nothing else.
*
* The default pair removes them rather than restating the tokens.css values, so there is one place
* that decides what an unset document looks like. Same shape as src/width.ts and src/theme.ts: a
* setting is an attribute or a property on the root element, never a length or a family written
* into a component.
*/
function apply(fonts: DocumentFonts): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (fontsEqual(fonts, DEFAULT_FONTS)) {
root.style.removeProperty("--font-book");
root.style.removeProperty("--font-heading");
return;
}
root.style.setProperty("--font-book", fontStack(fonts.body));
root.style.setProperty("--font-heading", fontStack(fonts.heading));
}
interface DocumentFontsState {
/** What the open document is set in, or the default pair when nothing is open. */
fonts: DocumentFonts;
/** Which file `fonts` is about, so a write cannot land on the document that has since replaced
* it. Null between a close and the next open. */
path: string | null;
/** Called when the open document changes, including to nothing. Applies what was remembered. */
openFor: (path: string | null) => void;
setFonts: (fonts: DocumentFonts) => void;
setSlot: (slot: "body" | "heading", ref: FontRef) => void;
reset: () => void;
}
export const useDocumentFonts = create<DocumentFontsState>((set, get) => ({
fonts: DEFAULT_FONTS,
path: null,
openFor: (path) => {
const fonts = path === null ? DEFAULT_FONTS : (read()[path] ?? DEFAULT_FONTS);
apply(fonts);
set({ fonts, path });
},
// Applied before it is stored, because the face on screen is the thing the user asked for and
// storage is the part that is allowed to fail.
setFonts: (fonts) => {
const { path } = get();
apply(fonts);
set({ fonts });
if (path === null) return;
const map = read();
// Reinserted rather than assigned in place, so the key order is least-recently-set first and
// the trim below drops the right entries. A document set back to the default is removed
// instead of stored: an entry that says "what everything else already says" is only there to
// be evicted later.
delete map[path];
if (!fontsEqual(fonts, DEFAULT_FONTS)) map[path] = fonts;
const paths = Object.keys(map);
for (const stale of paths.slice(0, Math.max(0, paths.length - LIMIT))) delete map[stale];
write(map);
},
setSlot: (slot, ref) => get().setFonts({ ...get().fonts, [slot]: ref }),
reset: () => get().setFonts(DEFAULT_FONTS),
}));
+5 -1
View File
@@ -144,9 +144,13 @@ select {
color: var(--ink); color: var(--ink);
} }
/* On, or open. The two are the same picture on purpose: every one of these is a control whose
result is on this screen, so "this button is doing something right now" is the only distinction
worth drawing. The inset ring is what separates it from a hover, which shares the wash. */
.icon-button[data-active="true"] { .icon-button[data-active="true"] {
background: var(--accent-wash); background: var(--accent-wash);
color: var(--ink); color: var(--accent);
box-shadow: inset 0 0 0 1px var(--line-strong);
} }
:root[data-touch] .icon-button { :root[data-touch] .icon-button {
+142
View File
@@ -0,0 +1,142 @@
/* The Document setup panel, opened by the filename in the title bar.
*
* It borrows .overlay, .panel, .panel-head, .panel-body and .panel-foot from app.css and the two
* buttons from tree.css, for the same reason src/styles/settings.css does: a dialog that looked
* like its own app would be the only thing on screen that did. What is here is the labelled field
* and the typography section, both ported from the sibling book app's Book setup so that the two
* panels are the same object rather than two designs of one idea. */
.panel-setup {
width: min(520px, calc(100vw - 32px));
}
/* A label over its control, which is the shape every field in this panel has. The settings panel
next door puts its label beside a switch instead, because a switch is an answer and these are
values. */
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-family: var(--font-ui);
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-faint);
}
.field input,
.field select {
font-family: var(--font-ui);
font-size: var(--t-4);
color: var(--ink);
background: var(--raised);
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
padding: 9px 11px;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: var(--accent);
}
/* A select showing a font is set in that font, which is the point of the control. The rule above
would otherwise put the UI face on it and every option would read the same. */
.field select {
font-family: inherit;
}
.font-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* A specimen with its name under it, rather than a row of five names. Pairing two faces is the part
of this that takes an eye, and five words set in one face say nothing about what any of them
looks like. */
.font-preset {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 9px 12px;
min-width: 70px;
border: 1px solid var(--line-strong);
border-radius: var(--r-md);
background: var(--raised);
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
}
.font-preset:hover {
border-color: var(--ink-faint);
}
.font-preset[data-on="true"] {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.font-preset-demo {
font-size: 23px;
line-height: 1;
color: var(--ink);
}
.font-preset-name {
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-soft);
}
/* The way down to the two selects, and a link rather than a button because what it does is reveal
rather than change. Nothing in the panel moves when it is pressed except what appears below it. */
.font-advanced {
align-self: flex-start;
margin-top: 4px;
padding: 0;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.font-note {
margin: 0;
font-family: var(--font-ui);
font-size: var(--t-1);
line-height: 1.5;
color: var(--ink-faint);
}
/* What the page will look like, since the page itself does not change until Save. A heading and a
line of prose, which is the smallest sample that shows both slots and the relation between
them. On --paper rather than --raised, because that is what a document sits on. */
.font-sample {
padding: 15px 17px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--paper);
color: var(--ink);
font-size: 15px;
line-height: 1.6;
}
.font-sample-title {
display: block;
font-size: 21px;
font-weight: 500;
line-height: 1.2;
margin-bottom: 6px;
}
/* The title bar's own button, which is now a way into this panel and says so while it is open. */
.titlebar .doc-title[data-active="true"] {
color: var(--ink);
}
+168
View File
@@ -0,0 +1,168 @@
/* The PDF preview, drawn over the editor pane.
Square and unshadowed, unlike every other panel in the app, because it is not floating over the
page: it is standing where the page was. The pane it covers runs to the window's edges on three
sides, so a rounded corner would show a sliver of the document underneath and read as a bug. The
scrim behind it still dims the sidebar, which is what says the app is waiting on this.
The pages themselves are white on --shell rather than on --paper, in both themes. A PDF is white
paper, that is what the file will look like when it is opened anywhere else, and a preview that
inverted with the app's theme would be showing something that does not exist. */
.preview-overlay {
z-index: 30;
}
.preview-panel {
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
box-shadow: none;
}
.preview-bar {
flex: none;
height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
background: var(--paper);
border-bottom: 1px solid var(--line);
}
.preview-title {
flex: 1;
min-width: 0;
font-family: var(--font-book);
font-size: var(--t-4);
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-count {
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.preview-zoom {
display: flex;
align-items: center;
gap: 2px;
}
/* A fixed width and tabular figures, so stepping through 75%, 100%, 125% does not shuffle the
buttons either side of it. */
.preview-zoom span {
width: 44px;
text-align: center;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
}
.preview-bar .icon-button:disabled {
opacity: 0.4;
pointer-events: none;
}
.preview-warn {
flex: none;
padding: 9px 18px;
background: var(--accent-wash);
color: var(--accent-ink);
font-family: var(--font-ui);
font-size: var(--t-2);
text-align: center;
border-bottom: 1px solid var(--line);
}
.preview-stage {
flex: 1;
min-height: 0;
overflow: auto;
padding: 28px;
background: var(--shell);
}
/* max-content so a page zoomed past the stage's width scrolls sideways instead of being squeezed,
and min-width 100% so one narrower than the stage still centres. */
.preview-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: max-content;
min-width: 100%;
margin: 0 auto;
}
.preview-page {
flex: none;
background: #fff;
border-radius: 2px;
box-shadow: var(--shadow-page);
overflow: hidden;
}
.preview-canvas {
display: block;
width: 100%;
height: 100%;
}
.preview-loading {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.preview-loading p {
margin: 0;
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink-soft);
}
.preview-spinner {
width: 30px;
height: 30px;
border: 3px solid var(--line-strong);
border-top-color: var(--accent);
border-radius: 50%;
animation: preview-spin 0.7s linear infinite;
}
@keyframes preview-spin {
to {
transform: rotate(360deg);
}
}
/* A compile that failed says why, in the compiler's own words and on its own lines. Wrapped rather
than scrolled sideways: a Typst error is a paragraph, not a line of code. */
.preview-error {
margin: 0 auto;
max-width: 60ch;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
font-size: var(--t-3);
line-height: 1.6;
color: var(--danger-ink);
}
@media (prefers-reduced-motion: reduce) {
.preview-spinner {
animation-duration: 2.4s;
}
}
+4 -31
View File
@@ -1,31 +1,4 @@
@font-face { /* The six families, declared once in margin-shared so that Margin and Margin Docs bind the same
font-family: "Hanken Grotesk"; files to the same family names. The faces themselves are served from this app's public/fonts,
src: url("/fonts/HankenGrotesk-VF.ttf") format("truetype-variations"); which `pnpm fonts:sync` fills from the package and `pnpm fonts:check` verifies. */
font-weight: 100 900; @import "margin-shared/css/fonts.css";
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Hanken Grotesk";
src: url("/fonts/HankenGrotesk-Italic-VF.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: italic;
font-display: block;
}
@font-face {
font-family: "Literata";
src: url("/fonts/Literata-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "Literata";
src: url("/fonts/Literata-Italic-VF.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: italic;
font-display: block;
}
+9 -78
View File
@@ -1,92 +1,23 @@
/* The palette, the type scale and the shape constants come from margin-shared, which is the one
copy of the values both apps have to agree on. Everything below is what only a document editor
needs: a six level heading scale, the rules and code surfaces a page of prose draws, and the
measures the sheet is laid out at. */
@import "margin-shared/css/tokens.css";
/* The handful of shape and scale tokens this app adds to the shared set. */
:root { :root {
--font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-book: "Literata", Georgia, "Times New Roman", serif;
--font-heading: "Literata", Georgia, "Times New Roman", serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace; --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--r-sm: 5px;
--r-md: 8px;
--r-lg: 12px;
--r-pill: 999px; --r-pill: 999px;
--pane-sidebar: 248px;
--measure: 46em;
--titlebar-h: 46px;
--traffic-pad: 84px; --traffic-pad: 84px;
--touch-h: 44px; --touch-h: 44px;
--t-1: 11px;
--t-2: 12px;
--t-3: 13px;
--t-4: 15px;
--t-5: 16px; --t-5: 16px;
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
:root,
:root[data-theme="light"] {
--paper: #fcfbf7;
--shell: #f1ece2;
--sidebar: #ece6da;
--raised: #fbfaf6;
--ink: #23201b;
--ink-soft: #6b6458;
--ink-faint: #9b9484;
--line: #e3ddce;
--line-strong: #d6cfbd;
--accent: #2a2622;
--accent-ink: #100e0b;
--accent-wash: rgba(35, 32, 27, 0.08);
--accent-contrast: #faf7f0;
--selection: rgba(35, 32, 27, 0.14);
--glass: rgba(252, 251, 247, 0.86);
--danger: #b4453a;
--danger-ink: #963327;
--danger-wash: rgba(180, 69, 58, 0.1);
--danger-contrast: #faf7f0;
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
--shadow-raised: 0 1px 2px rgba(35, 32, 27, 0.05);
--scrim: rgba(35, 32, 27, 0.28); --scrim: rgba(35, 32, 27, 0.28);
--shadow-raised: 0 1px 2px rgba(35, 32, 27, 0.05);
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--paper: #1d1a16;
--shell: #151310;
--sidebar: #1a1713;
--raised: #232019;
--ink: #ece6da;
--ink-soft: #a89f8e;
--ink-faint: #756d5e;
--line: #2c2823;
--line-strong: #3a352d;
--accent: #efe9dc;
--accent-ink: #ffffff;
--accent-wash: rgba(239, 233, 220, 0.12);
--accent-contrast: #1a1714;
--selection: rgba(239, 233, 220, 0.18);
--glass: rgba(33, 30, 25, 0.88);
--danger: #d97a6f;
--danger-ink: #e58e84;
--danger-wash: rgba(217, 122, 111, 0.16);
--danger-contrast: #1a1714;
--shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5);
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.35);
--scrim: rgba(0, 0, 0, 0.5); --scrim: rgba(0, 0, 0, 0.5);
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.35);
} }
/* Document typography, added for the editor surface. The four step scale above sizes chrome and /* Document typography, added for the editor surface. The four step scale above sizes chrome and
+53 -8
View File
@@ -22,12 +22,23 @@
// wrote nothing because it never got past the first paragraph is the shape of green this suite is // wrote nothing because it never got past the first paragraph is the shape of green this suite is
// built to refuse. // built to refuse.
// //
// WHAT A BROWSER CANNOT REACH. `exportPdf` gates on `isDesktop`, which is `isTauri` and not a // The export is two gestures now rather than one. Cmd-E opens the preview in
// src/components/ExportPreview.tsx, which compiles and puts the pages on screen; Save PDF is what
// reaches the native panel and the write. That changes the shape of this test and not its subject:
// the promise is still that nothing between the keystroke and the write touches the document, and
// the command log below is still the whole of the evidence. If anything, the window in which a
// stray save could hide got longer, because the buffer now sits dirty behind an open panel for as
// long as the user looks at the pages.
//
// WHAT A BROWSER CANNOT REACH. The command gates on `isDesktop`, which is `isTauri` and not a
// phone. `isTauri` reads `__TAURI_INTERNALS__` off the window and tests/disk.ts puts that there at // phone. `isTauri` reads `__TAURI_INTERNALS__` off the window and tests/disk.ts puts that there at
// document start, so the gate opens and the whole path runs: convert, draw, compile, panel, write. // document start, so the gate opens and the whole path runs: convert, draw, compile, preview,
// What is behind it is the dev fixture, so `pdf_compile` answers with a PDF header instead of a // panel, write. What is behind it is the dev fixture, so `pdf_compile` answers with a PDF header
// typeset document and `pdf_write` puts nothing anywhere. That the compiler produces a real PDF and // instead of a typeset document and `pdf_write` puts nothing anywhere. Eight bytes is not a PDF
// that the write lands where it was pointed are Rust questions, and they are asked in // pdf.js can open, so the preview's stage stays empty here and the page count stays at zero: what
// this suite drives is the bar around it, which is live either way because the bytes it saves came
// from the compiler and not from the reader. That the compiler produces a real PDF, that pdf.js
// draws it, and that the write lands where it was pointed are questions for a real build and for
// src-tauri/tests/export_writes_only_the_pdf.rs. The one piece nothing can drive from either side // src-tauri/tests/export_writes_only_the_pdf.rs. The one piece nothing can drive from either side
// is the native save panel itself: it is an AppKit window, so this spec answers the command for it // is the native save panel itself: it is an AppKit window, so this spec answers the command for it
// and cannot say what a real panel would hand back for a name the user typed. That distinction // and cannot say what a real panel would hand back for a name the user typed. That distinction
@@ -200,6 +211,23 @@ async function runFromMenu(page: Page, command: string): Promise<void> {
expect(delivered, "the app has no menu-action listener to fire at").toBeGreaterThan(0); expect(delivered, "the app has no menu-action listener to fire at").toBeGreaterThan(0);
} }
const preview = (page: Page) => page.locator(".preview-panel");
const savePdf = (page: Page) => preview(page).locator(".btn-primary");
/**
* Opens the preview and waits for the compile behind it to finish.
*
* Save is disabled until there are bytes to save, so a live button is the signal that `pdf_compile`
* has answered. Waiting on that rather than on the pages is deliberate: the fixture's eight bytes
* never become pages, and a test that waited for a canvas would be waiting for the compiler this
* build does not have.
*/
async function openPreview(page: Page): Promise<void> {
await runFromMenu(page, "export-pdf");
await expect(preview(page)).toBeVisible();
await expect(savePdf(page)).toBeEnabled({ timeout: EXPORTED });
}
/** /**
* Types a character into the first paragraph and holds the save that would follow, so the buffer is * Types a character into the first paragraph and holds the save that would follow, so the buffer is
* dirty and the file is not, for as long as the caller needs. Answers with the log as it stood the * dirty and the file is not, for as long as the caller needs. Answers with the log as it stood the
@@ -242,8 +270,18 @@ test("an export of a document with unsaved edits writes nothing but the PDF", as
window.__savePanel = target; window.__savePanel = target;
}, TARGET); }, TARGET);
await runFromMenu(page, "export-pdf"); await openPreview(page);
// The compile happened on the way into the preview and nothing has been written yet, which is
// the half of this flow that did not exist before. A panel that had reached for the save dialog
// on its own would show up here.
const compiled = (await sent(page)).slice(line).map((call) => call.command);
expect(compiled).toEqual(["plugin:event|listen", "pdf_compile", "plugin:event|unlisten"]);
await savePdf(page).click();
await expect(toast(page)).toContainText("Exported README.pdf", { timeout: EXPORTED }); await expect(toast(page)).toContainText("Exported README.pdf", { timeout: EXPORTED });
// A written file is the end of the preview's job, so it closes behind the toast.
await expect(preview(page)).toHaveCount(0);
const during = (await sent(page)).slice(line); const during = (await sent(page)).slice(line);
@@ -320,9 +358,10 @@ test("cancelling the save panel leaves the document exactly where it was", async
await expect(page.locator(".prose .mermaid-block")).toHaveCount(1); await expect(page.locator(".prose .mermaid-block")).toHaveCount(1);
const line = await dirtyWithHeldSave(page); const line = await dirtyWithHeldSave(page);
// Left as null, which is what the panel answers when the user presses Cancel. // `__savePanel` is left as null, which is what the panel answers when the user presses Cancel.
await runFromMenu(page, "export-pdf"); await openPreview(page);
await savePdf(page).click();
await expect await expect
.poll(async () => (await commands(page)).filter((c) => c === "plugin:dialog|save").length, { .poll(async () => (await commands(page)).filter((c) => c === "plugin:dialog|save").length, {
timeout: EXPORTED, timeout: EXPORTED,
@@ -340,4 +379,10 @@ test("cancelling the save panel leaves the document exactly where it was", async
expect(await disk(page)).toBe(SOURCE); expect(await disk(page)).toBe(SOURCE);
await expect(page.locator(".dirty-dot")).toBeVisible(); await expect(page.locator(".dirty-dot")).toBeVisible();
await expect(toast(page)).toHaveCount(0); await expect(toast(page)).toHaveCount(0);
// And the preview is still up, holding the bytes it already compiled. Cancelling the save panel
// is somebody deciding not to write this file yet, not somebody finished looking at it, so a
// second Save must not cost a second compile.
await expect(preview(page)).toBeVisible();
await expect(savePdf(page)).toBeEnabled();
}); });