New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature request: Add a 'checkLocation' option to the Suggest plugin #1137
Comments
I second this. Maybe it could be best to have schema-checking instead? That's the source of truth for valid structures. |
Agreed, except for decorations and running arbitrary commands. I'm using it with a hashtag extension (going to post it now) where the hashtags are just hi-lighted with a decoration rather than being a node themselves. |
Completely untested attempt: if (match && checkLocation(editor)) { Add to checkLocation?: (editor: Editor) => boolean, and add checkLocation = () => true, |
Great idea! I’ve implemented this in the feature/allow-option-for-suggestions branch with this commit. But I’ve run into a problem that I haven’t been able to fix yet: circular types ( |
Can you walk us through the nuances of the circular issue? Maybe a video could work? |
@oodavid There are a bunch of TypeScript errors: This is due to the fact that all extensions are registered in the |
From a quick bit or reading it sounds like the solution to circular types is to create a new interface, see microsoft/TypeScript#3496 (comment) Apparently "this works because resolution of interface base types and interface members is deferred, whereas resolution of type aliases is performed eagerly" So something like this? // Somewhere, maybe at the top of the file or have it shared for use in multiple plugins
interface EditorObject extends Editor { }
// Then do:
allow?: (props: {
editor: EditorObject,
range: Range,
}) => boolean, I have not tested this... |
@samwillis Thanks but I can’t get it to work with that :( |
Thats frustrating! It always seems clever tools are brilliant until you get that edge case where they fall over. I have been doing some more looking and I think this describes a possible solution in this case: https://stackoverflow.com/questions/61259112/how-to-solve-circular-dependencies-when-useing-classes-as-types-in-typescript I think you may need to create a As someone new to typescript (only started using it a few months ago) having mostly worked in the dynamic world of Javascript and Python and so being suspicious of the "overhead" of a typed language I must say I'm loving the way it integrates with VS Code, it so quick to spot (potential) bugs. |
The MobX author / core contributor has this article on a seemingly simple fix for circular dependencies. You can probably skip the preamble / examples and jump to "The internal module pattern to the rescue!" |
I tried that pattern a few weeks ago without any luck :(
Should be worth a try.
Unfortunately GitHub doesn’t allow forking on private repos. But I gave you write permissions. With this it should also work. I’m really grateful for any help, because this problem has been with me for months now. tiptap v2 is also my first TypeScript project 😬 |
No worries, its a brilliant project and happy to get stuck into an interesting problem. Will help me learn more about TypeScripts intricacies. I'm working though the circular dependancies at the moment... will let you know what I come up with. |
Right, I think I sort of have my head around it. The issues (at least in this case) is a result of directly returning the end of a command chain. The short version is that this works: allow: ({ editor, range }) => {
if (editor.can().replaceRange(range, 'mention')) return true;
return false;
}, Interestingly this doesn't work: allow: ({ editor, range }) => {
return !!(editor.can().replaceRange(range, 'mention'))
}, The longer version is command chains are (and have to be) recursive types, you have implemented them here (which I'm still getting my head around but getting there): When I experimented with breaking the recursive nature of this it means you loose all types on the command chains, obviously not a solution. So my thinking is can we add some sort of Before coming to that conclusion, I tried creating |
A quick thing to add, I tried changing this: to: if (name === 'run') {
if (!hasStartTransaction && shouldDispatch && !tr.getMeta('preventDispatch')) {
view.dispatch(tr)
}
return () => {
if (callbacks.every(callback => callback === true)) return true;
return false;
}
} And it didn't work (it was a bit of a long shot), so I think what is needed is making the typescript compiler recognise that |
Hey, thanks for diving in! I think the issue is somewhere else (not explicit in Change in the addKeyboardShortcuts?: (this: {
options: Options,
editor: Editor,
type: NodeType,
}) => {
[key: string]: any
}, to this: addKeyboardShortcuts?: (this: {
options: Options,
editor: Editor,
type: NodeType,
}) => {
[key: string]: () => boolean
}, The second one should be the correct one but then everything explodes again 🙃 |
I think any JavaScript changes in here doesn’t matter because I force the return type of |
But maybe that proxy is the problem and we have to find a way to get |
It may well be, I suppose there is also the proxy wrapping the whole editor instance too, which could impact the issue in the I wander if there is a clue to how the TypeScript compiler is interpreting the type with the way it acts differently with:
and
with the later working... Just checked and these also don't work:
Its almost like a branch forces a new type... |
Pretty confident its not confined to chains, I commented out
and it fell over in |
Hmm, then it’s maybe the way how I read the commands from the
Extension.create({
addCommands() {
return {
whatever: (someProp) => ({ editor }) => {
// ...
}
}
},
})
declare module '@tiptap/core' {
interface AllCommands {
whatever: (someProp: any) => Command,
}
} |
That may well work but is obviously not ideal, that's actually the really clever bit. I'm suspicious that Extension/Commands are circular types by nature and which as we know typescript doesn't support. My thinking (again, based upon relatively limited experience and so I could be wrong) it that because you can call other extensions commands from within commands, as soon as you return a value directly (not forcing a brach) from a command within another command you risk creating a circular inferred type. Because keyboard shortcuts are often returning a value from a commend (to indicate success and stop trying other shortcuts) they inherently create a circular type too, by doing the below you force Typescript to stop following the circular nature:
I don't think there is a going to be a perfect solution, at best what is needed is something that means that users of TipTap don't accidentally create a "circular type" error themselves but still get all the nice developer experience with the typed commands on the |
I have tested a bit and it seems to work if the commands are not inferred (tested the shortcuts issue). Maybe we should remove that "magic" part :/ It’s not too bad to use it like so: import { Command } from '@tiptap/core'
Extension.create({
addCommands() {
return {
simpleCommand: () => ({ editor }) => {
// ...
},
commandWithProps: props => ({ editor }) => {
// ...
},
}
},
})
declare module '@tiptap/core' {
interface Commands {
simpleCommand: () => Command,
commandWithProps: (props: any) => Command,
}
} Using the commands works as before. There are also some nice side effects. Since we don’t have two generics for the Extension.create<Options>({
defaultOptions: {
// ...
},
}) instead of Extension.create({
defaultOptions: <Options>{
// ...
},
}) which was already some kind of "hack" and has some downsides. There are also plans to extend the We had to add Extending these interfaces will be much easier if we don't have inferred generic types. |
That doesn't sound like its all bad then. If I'm following right, does that mean it would be possible to add a way to create extensions that add additional configuration options to existing nodes (by extending the NodeConfig interface)? So would it be possible to create a const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
OrderedList,
ListItem.extend({
addGestures() {
return {
'Swipe-Left': () => this.editor.commands.sinkListItem(),
'Swipe-Right': () => this.editor.commands.liftListItem(),
}
},
},
Gestures,
],
}) This would obviously need an API for the Gestures extension to use to add its support to nodes. |
Not in the first step. First I would like to be able to register custom fields within the NodeSpec/MarkSpec. As a next step one could then register more complex things, like helper methods or gestures. In the end it could be possible to register the whole logic of commands via an extension. But you can imagine how much work it will be to mockup an API that registers more APIs. Especially with TypeScript. 😅 |
Hey, a little update from me. After I made the planned changes in this branch, I noticed a problem, which is not insignificant. If you create an extension with a command like this … declare module '@tiptap/core' {
interface Commands {
foo: (param: number) => Command,
}
}
const myExtension = Extension.create({
addCommands() {
return {
foo: (param) => () => {
// ...
},
}
},
}) and want to extend it later with overwriting its command … declare module '@tiptap/core' {
interface Commands {
foo: (param: number, param2: string) => Command,
}
}
const extendedExtension = myExtension.extend({
addCommands() {
return {
foo: (param, param2) => () => {
// ...
},
}
},
}) TypeScript will throw an error:
And I have no idea to prevent this :( |
I suppose that is exactly that TypeScript is supposed to do in that situation as you are trying to change the type signature of an already defined command? Is that affecting something already in the codebase? Does the trick described here work: https://www.damirscorner.com/blog/posts/20190712-ChangeMethodSignatureInTypescriptSubclass.html |
@samwillis Me again. I found a way to overwrite commands types. What you have to do is to add a unique declare module '@tiptap/core' {
interface Commands {
+ myExtension: {
foo: () => Command,
+ }
}
} With that you could overwrite a command type: declare module '@tiptap/core' {
interface Commands {
myExtension: {
foo: () => Command,
}
}
}
const myExtension = Extension.create({
name: 'myExtension',
addCommands() {
return {
foo: () => () => {
return true
},
}
},
})
// overwrite `foo` command
declare module '@tiptap/core' {
interface Commands {
myExtendedExtension: {
foo: (name: string) => Command,
}
}
}
const myExtendedExtension = myExtension.extend({
addCommands() {
return {
// typeof name = string
foo: (name) => () => {
return true
},
}
},
}) But there is one downside where I'm not sure we should ignore it. But that's in the nature of overwriting. Imagine the following command: declare module '@tiptap/core' {
interface Commands {
myExtension: {
foo: (attribute: boolean) => Command,
}
}
}
const myExtension = Extension.create({
name: 'myExtension',
addCommands() {
return {
// error: command should return `boolean`
// `attribute` is now type of `string` because of overwriting
foo: attribute => () => {
return attribute
},
}
},
})
// overwrite `foo` with different props
declare module '@tiptap/core' {
interface Commands {
myExtendedExtension: {
foo: (attribute: string, attribute2: boolean) => Command,
}
}
}
const myExtendedExtension = myExtension.extend({
addCommands() {
return {
foo: (attribute, attribute2) => () => {
return attribute2
},
}
},
}) This could only happen if we change types of existing attributes. In other situations this is not a problem: declare module '@tiptap/core' {
interface Commands {
myExtension: {
foo: (name: string) => Command,
}
myExtendedExtension: {
// no problem
foo: (name: string, attributes: { [key: string]: any }) => Command,
}
}
} In the situations where this leads to problems, you could of course still register commands under a new name. What do you think? |
I don't think it's a problem. I can see that there may be occasions where adding an arg to an existing command is needed but changing the type of an arg will only lead to bugs as it could break other plugins that rely on the original signature and functionality (exactly what typescript is there to help prevent). I think if you need to make a command perform significantly differently it should be under a new name to avoid any conflicts. |
Ok, merged and published. Uff! Biggest change for a long time. |
Brillant! I bet that was a bit of a mission... Thank you! Only thing I have spotted after upgrading is I had a few custom keyboard shortcuts where I was excepting the state as the first arg ( I have just tested for my original bug with Vue3 ("Applying a mismatched transaction" error when trying to run a command from a button), it unfortunately still happens when returning the editor as a |
Ah totally missed to fix that type. Should be fixed right now! I already thought that with Vue 3. But the proxies were still unnecessary in the meantime. |
Currently the suggest plugin triggering is controlled by the char/startOfLine options and so it is triggered in any text node. It would be brilliant If we could limit-to/exclude-from certain nodes.
Something like this would work:
I'm using it for hashtags and have a node type where hash tags are not supported.
The text was updated successfully, but these errors were encountered: